Add bash_kill, background job eviction, and configurable request timeout

Bump to 0.3.0.

- bash_kill tool lets the model terminate a still-running backgrounded
  job (Ctrl+B); all running jobs are killed on process exit so they
  don't outlive locode as orphans.
- Finished background jobs are now evicted from the registry after a
  10-minute TTL instead of accumulating for the life of the session.
- The chat completion request timeout is now configurable
  (requestTimeoutMs / LOCODE_REQUEST_TIMEOUT_MS, default 180000) so
  backends that queue requests behind a concurrency limit (e.g.
  Ollama's OLLAMA_NUM_PARALLEL) under multi-session load aren't forced
  into a hard failure at a fixed 3 minutes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-08 12:48:32 +09:00
co-authored by Claude Sonnet 5
parent 2a35f40c73
commit 4652ecb87b
14 changed files with 214 additions and 12 deletions
+6 -3
View File
@@ -86,10 +86,10 @@ locode hooks list # see every configured hook (plugin + user + project), mer
locode is a full-screen terminal app built with [Ink](https://github.com/vadimdemedes/ink) (the same React-for-CLI framework Claude Code itself is built with) — it needs a real interactive terminal (piped/redirected input isn't supported). It takes over the terminal's alternate screen buffer (like `vim`/`htop`) — your prior scrollback is restored when you exit. The input box is always pinned to the last row of the window; the conversation fills the space above it and old messages scroll off the top as new ones arrive. Press Ctrl+C or type `/exit` to quit.
- **Ctrl+O**: print the full text of the last `/compact` (or auto-compact) summary. The collapsed notice you see right after compacting only shows a one-line hint — press Ctrl+O any time afterward to print the whole thing.
- **Ctrl+B**: while a `bash` command is running, detaches it into the background and returns control to you immediately — the turn continues with a `bash_output`-checkable job id instead of waiting for the command to finish. A notice appears in the transcript once the backgrounded command actually completes. Only `bash` supports this today.
- **Ctrl+B**: while a `bash` command is running, detaches it into the background and returns control to you immediately — the turn continues with a `bash_output`-checkable job id instead of waiting for the command to finish. A notice appears in the transcript once the backgrounded command actually completes. Only `bash` supports this today. The model can kill a still-running backgrounded job with `bash_kill`; any jobs still running when locode itself exits are killed too, so they don't outlive the process as orphans.
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
- **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status`, `bash_output` run automatically. `write_file`, `edit_file`, `bash`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status`, `bash_output` run automatically. `write_file`, `edit_file`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Images**: `read_file` returns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use `/import <path> [caption]`.
- **`@` file mentions**: type `@` in the chat input to open a fuzzy file picker (searches the whole project, skipping `node_modules`/`.git`/`dist`) — keep typing to filter, `↑`/`↓` to navigate, `Tab` (or `Enter`) to insert the highlighted path. Any `@path` left in your message when you hit `Enter` for real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray `@` that isn't an actual file (e.g. an email address) is left as plain text.
- **Git**: `git_status` covers read-only inspection (`status`, `diff`, `log`, `show`, `branches`) and runs automatically. `git_commit` covers `add`, `commit`, `create_branch`, `checkout`, and `push` — each shows the actual diff/status/commits it's about to affect before you confirm (e.g. a commit's preview is the staged diff plus the message, a push's preview is the list of commits it would send).
@@ -128,7 +128,7 @@ Note: even models with genuine native tool-calling support occasionally emit a t
## Config
Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCODE_BASE_URL`, `LOCODE_CONTEXT_WINDOW`, `LOCODE_MAX_ITERATIONS`, `LOCODE_AUTO_COMPACT_THRESHOLD`) > persisted config file > defaults.
Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCODE_BASE_URL`, `LOCODE_CONTEXT_WINDOW`, `LOCODE_MAX_ITERATIONS`, `LOCODE_AUTO_COMPACT_THRESHOLD`, `LOCODE_REQUEST_TIMEOUT_MS`) > persisted config file > defaults.
```sh
locode config set backend ollama
@@ -136,6 +136,9 @@ locode config set model qwen3-coder:30b
locode config set contextWindow 32768 # fallback size when auto-detection fails
locode config set maxIterations 40 # max tool calls per turn before locode gives up (default 25)
locode config set autoCompactThreshold 0.85 # fraction of context window at which auto-compact triggers
locode config set requestTimeoutMs 300000 # per-request timeout in ms (default 180000); raise this if
# your backend queues requests behind a concurrency limit
# (e.g. Ollama's OLLAMA_NUM_PARALLEL) under multi-session load
locode config get
locode config path
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "locode",
"version": "0.1.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "locode",
"version": "0.1.0",
"version": "0.3.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@vscode/ripgrep": "^1.18.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "locode",
"version": "0.2.0",
"version": "0.3.0",
"description": "Agentic coding CLI for local models served via Ollama and LM Studio",
"type": "module",
"bin": {
+5 -1
View File
@@ -1,4 +1,5 @@
import OpenAI from "openai";
import { resolveRequestTimeoutMs } from "../config/config.js";
import type { AppConfig } from "../config/types.js";
export function makeClient(cfg: AppConfig): OpenAI {
@@ -8,7 +9,10 @@ export function makeClient(cfg: AppConfig): OpenAI {
// The SDK defaults to a 10-minute timeout with 2 retries (up to 30 min before a request ever
// fails). For local backends a slow response almost always means the model is genuinely stuck,
// not a transient network blip, so retrying just compounds the wait — fail faster instead.
timeout: 180_000,
// Configurable (`requestTimeoutMs` / LOCODE_REQUEST_TIMEOUT_MS) because a backend that queues
// requests behind a concurrency limit (e.g. Ollama's OLLAMA_NUM_PARALLEL) can legitimately take
// longer than the 180s default to even start serving a request under contention.
timeout: resolveRequestTimeoutMs(),
maxRetries: 0,
});
}
+13 -3
View File
@@ -114,7 +114,7 @@ configCmd
configCmd
.command("set <key> <value>")
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold)")
.description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs)")
.action((key: string, value: string) => {
if (
key !== "backend" &&
@@ -122,9 +122,12 @@ configCmd
key !== "baseUrl" &&
key !== "contextWindow" &&
key !== "maxIterations" &&
key !== "autoCompactThreshold"
key !== "autoCompactThreshold" &&
key !== "requestTimeoutMs"
) {
console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold`);
console.error(
`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations, autoCompactThreshold, requestTimeoutMs`,
);
process.exit(1);
}
const stored = loadStoredConfig();
@@ -142,6 +145,13 @@ configCmd
process.exit(1);
}
stored[key] = n;
} else if (key === "requestTimeoutMs") {
const n = Number(value);
if (!Number.isFinite(n) || n < 10_000 || n > 1_800_000) {
console.error(`requestTimeoutMs must be between 10000 and 1800000, got "${value}".`);
process.exit(1);
}
stored[key] = n;
} else {
stored[key] = value;
}
+24 -2
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import { loadStoredConfig, saveStoredConfig } from "./store.js";
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations } from "./config.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS } from "./defaults.js";
import { resolveAutoCompactThreshold, resolveContextWindowDefault, resolveMaxIterations, resolveRequestTimeoutMs } from "./config.js";
import { DEFAULT_AUTO_COMPACT_THRESHOLD, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS, DEFAULT_REQUEST_TIMEOUT_MS } from "./defaults.js";
describe("config resolution", () => {
afterEach(() => {
@@ -9,6 +9,7 @@ describe("config resolution", () => {
delete process.env.LOCODE_AUTO_COMPACT_THRESHOLD;
delete process.env.LOCODE_CONTEXT_WINDOW;
delete process.env.LOCODE_MAX_ITERATIONS;
delete process.env.LOCODE_REQUEST_TIMEOUT_MS;
});
it("resolves auto-compact threshold default", () => {
@@ -39,4 +40,25 @@ describe("config resolution", () => {
it("resolves max iterations default", () => {
expect(resolveMaxIterations()).toBe(DEFAULT_MAX_ITERATIONS);
});
it("resolves request timeout default", () => {
expect(resolveRequestTimeoutMs()).toBe(DEFAULT_REQUEST_TIMEOUT_MS);
});
it("reads request timeout from env", () => {
process.env.LOCODE_REQUEST_TIMEOUT_MS = "300000";
expect(resolveRequestTimeoutMs()).toBe(300_000);
});
it("reads request timeout from stored config", () => {
saveStoredConfig({ requestTimeoutMs: 240_000 });
expect(resolveRequestTimeoutMs()).toBe(240_000);
});
it("rejects out-of-range request timeouts", () => {
saveStoredConfig({ requestTimeoutMs: 1_000 });
expect(resolveRequestTimeoutMs()).toBe(DEFAULT_REQUEST_TIMEOUT_MS);
process.env.LOCODE_REQUEST_TIMEOUT_MS = "9999999";
expect(resolveRequestTimeoutMs()).toBe(DEFAULT_REQUEST_TIMEOUT_MS);
});
});
+13
View File
@@ -2,6 +2,7 @@ import {
DEFAULT_AUTO_COMPACT_THRESHOLD,
DEFAULT_CONTEXT_WINDOW,
DEFAULT_MAX_ITERATIONS,
DEFAULT_REQUEST_TIMEOUT_MS,
KNOWN_BACKENDS,
type BackendName,
} from "./defaults.js";
@@ -67,3 +68,15 @@ export function resolveAutoCompactThreshold(): number {
}
return DEFAULT_AUTO_COMPACT_THRESHOLD;
}
/** Milliseconds to wait on a single chat completion request before giving up (see backend/client.ts
* for why locode doesn't retry on top of this). Bounded to 10s–30min to reject pathological values. */
export function resolveRequestTimeoutMs(): number {
const stored = loadStoredConfig();
const envValue = Number(process.env.LOCODE_REQUEST_TIMEOUT_MS);
if (Number.isFinite(envValue) && envValue >= 10_000 && envValue <= 1_800_000) return envValue;
if (typeof stored.requestTimeoutMs === "number" && stored.requestTimeoutMs >= 10_000 && stored.requestTimeoutMs <= 1_800_000) {
return stored.requestTimeoutMs;
}
return DEFAULT_REQUEST_TIMEOUT_MS;
}
+5
View File
@@ -19,3 +19,8 @@ export const DEFAULT_MAX_ITERATIONS = 25;
/** Fraction of the context window at which locode automatically summarizes the conversation.
* User-configurable via `locode config set autoCompactThreshold`. */
export const DEFAULT_AUTO_COMPACT_THRESHOLD = 0.85;
/** How long to wait on a single chat completion request before giving up (no retries — see
* backend/client.ts). Raise this via `requestTimeoutMs` if your backend queues requests behind a
* concurrency limit (e.g. Ollama's `OLLAMA_NUM_PARALLEL`) rather than serving them immediately. */
export const DEFAULT_REQUEST_TIMEOUT_MS = 180_000;
+2
View File
@@ -12,6 +12,8 @@ export interface StoredConfig {
maxIterations?: number;
/** Fraction of the context window (0.0–1.0) at which locode auto-compacts the conversation. */
autoCompactThreshold?: number;
/** Milliseconds to wait on a single chat completion request before giving up. */
requestTimeoutMs?: number;
}
const paths = envPaths("locode", { suffix: "" });
+81
View File
@@ -0,0 +1,81 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import type { ResultPromise } from "execa";
import { getBackgroundJob, killBackgroundJob, killAllBackgroundJobs, registerBackgroundJob } from "./backgroundJobs.js";
/** Minimal stand-in for execa's `ResultPromise` — only the surface registerBackgroundJob/kill
* actually touch (stdout/stderr streams, `.kill()`, and resolving/rejecting like a promise). */
function fakeChild() {
const stdout = new EventEmitter();
const stderr = new EventEmitter();
let resolve!: (v: { exitCode: number | null; signal?: string | null }) => void;
const promise = new Promise((res) => {
resolve = res;
});
const kill = vi.fn(() => resolve({ exitCode: null, signal: "SIGTERM" }));
const child = Object.assign(promise, { stdout, stderr, kill }) as unknown as ResultPromise;
return { child, stdout, stderr, kill, resolveExit: resolve };
}
describe("backgroundJobs", () => {
it("kills a running job and reports the signal once it exits", async () => {
const { child, kill } = fakeChild();
const job = registerBackgroundJob("sleep 100", "/tmp", child, "", "");
const result = killBackgroundJob(job.id);
expect(result.ok).toBe(true);
expect(kill).toHaveBeenCalledOnce();
await Promise.resolve(child).then(() => {});
// let the child.then() handler in registerBackgroundJob run
await new Promise((r) => setImmediate(r));
expect(getBackgroundJob(job.id)?.status).toBe("done");
expect(getBackgroundJob(job.id)?.signal).toBe("SIGTERM");
});
it("errors on an unknown job id", () => {
const result = killBackgroundJob("bg-does-not-exist");
expect(result).toEqual({ ok: false, error: 'No background job with id "bg-does-not-exist".' });
});
it("errors when killing a job that already finished", async () => {
const { child, resolveExit } = fakeChild();
const job = registerBackgroundJob("echo hi", "/tmp", child, "", "");
resolveExit({ exitCode: 0 });
await new Promise((r) => setImmediate(r));
const result = killBackgroundJob(job.id);
expect(result).toEqual({ ok: false, error: `Job "${job.id}" has already finished.` });
});
it("killAllBackgroundJobs kills every still-running job", () => {
const a = fakeChild();
const b = fakeChild();
registerBackgroundJob("cmd-a", "/tmp", a.child, "", "");
registerBackgroundJob("cmd-b", "/tmp", b.child, "", "");
killAllBackgroundJobs();
expect(a.kill).toHaveBeenCalledOnce();
expect(b.kill).toHaveBeenCalledOnce();
});
it("evicts a finished job from the registry after the TTL elapses", async () => {
vi.useFakeTimers();
try {
const { child, resolveExit } = fakeChild();
const job = registerBackgroundJob("echo hi", "/tmp", child, "", "");
resolveExit({ exitCode: 0 });
await vi.advanceTimersByTimeAsync(0); // let the child.then() handler run and schedule eviction
expect(getBackgroundJob(job.id)?.status).toBe("done");
await vi.advanceTimersByTimeAsync(10 * 60 * 1000);
expect(getBackgroundJob(job.id)).toBeUndefined();
} finally {
vi.useRealTimers();
}
});
});
+37
View File
@@ -6,6 +6,11 @@ import { truncate } from "../utils/truncate.js";
// (a dev server, a watch build) can't grow its buffers without limit for the rest of the session.
const MAX_BUFFERED_CHARS = 200_000;
// A finished job stays queryable via bash_output/bash_kill for a while (in case the model checks
// on it late), but without this the `jobs` map would grow forever over a long session that
// backgrounds a lot of short-lived commands — nothing ever removed a *finished* entry.
const FINISHED_JOB_TTL_MS = 10 * 60 * 1000;
export interface BackgroundJob {
id: string;
command: string;
@@ -22,6 +27,9 @@ export interface BackgroundJob {
}
const jobs = new Map<string, BackgroundJob>();
// Separate from `jobs` because BackgroundJob is a plain data object handed back through tool
// results (bash_output et al.) — the live execa handle needed to kill a job has no business there.
const children = new Map<string, ResultPromise>();
let nextId = 1;
const listeners = new Set<(job: BackgroundJob) => void>();
@@ -47,6 +55,7 @@ export function registerBackgroundJob(command: string, cwd: string, child: Resul
startedAt: Date.now(),
};
jobs.set(job.id, job);
children.set(job.id, child);
child.stdout?.on("data", (d: Buffer) => {
job.stdout = truncate(job.stdout + d.toString(), MAX_BUFFERED_CHARS);
@@ -62,6 +71,8 @@ export function registerBackgroundJob(command: string, cwd: string, child: Resul
job.exitCode = result.exitCode ?? null;
job.signal = (result as { signal?: string | null }).signal ?? null;
job.finishedAt = Date.now();
children.delete(job.id);
scheduleEviction(job.id);
for (const listener of listeners) listener(job);
},
(err) => {
@@ -69,12 +80,20 @@ export function registerBackgroundJob(command: string, cwd: string, child: Resul
job.exitCode = (err as { exitCode?: number | null }).exitCode ?? null;
job.signal = (err as { signal?: string | null }).signal ?? null;
job.finishedAt = Date.now();
children.delete(job.id);
scheduleEviction(job.id);
for (const listener of listeners) listener(job);
},
);
return job;
}
function scheduleEviction(id: string): void {
const timer = setTimeout(() => jobs.delete(id), FINISHED_JOB_TTL_MS);
// Don't let this timer alone keep the process alive until it fires.
timer.unref?.();
}
export function listBackgroundJobs(): BackgroundJob[] {
return [...jobs.values()];
}
@@ -82,3 +101,21 @@ export function listBackgroundJobs(): BackgroundJob[] {
export function getBackgroundJob(id: string): BackgroundJob | undefined {
return jobs.get(id);
}
/** Kills a still-running backgrounded job. Returns an error message instead of throwing, since
* the caller (the `bash_kill` tool) surfaces it directly as a tool result. */
export function killBackgroundJob(id: string): { ok: true } | { ok: false; error: string } {
const job = jobs.get(id);
if (!job) return { ok: false, error: `No background job with id "${id}".` };
if (job.status === "done") return { ok: false, error: `Job "${id}" has already finished.` };
const child = children.get(id);
if (!child) return { ok: false, error: `Job "${id}" has no live process to kill.` };
child.kill();
return { ok: true };
}
/** Best-effort kill of every still-running job, called once as locode itself is exiting so
* backgrounded shells (dev servers, watch builds) don't outlive the process as orphans. */
export function killAllBackgroundJobs(): void {
for (const child of children.values()) child.kill();
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
import { killBackgroundJob } from "./backgroundJobs.js";
import type { ToolDef } from "./types.js";
const schema = z.object({
jobId: z.string().describe("The jobId of a running background job to kill."),
});
export const bashKillTool: ToolDef<z.infer<typeof schema>> = {
name: "bash_kill",
description: "Kill a bash command that was previously moved to the background with Ctrl+B.",
schema,
mutating: true,
preview: async ({ jobId }) => `Kill background job ${jobId}`,
handler: async ({ jobId }) => {
const result = killBackgroundJob(jobId);
if (!result.ok) return { error: result.error };
return { message: `Sent kill signal to job ${jobId}.` };
},
};
+2
View File
@@ -1,5 +1,6 @@
import { agentTool } from "./agentTool.js";
import { bashTool } from "./bash.js";
import { bashKillTool } from "./bashKill.js";
import { bashOutputTool } from "./bashOutput.js";
import { editFileTool } from "./editFile.js";
import { gitCommitTool, gitStatusTool } from "./git.js";
@@ -22,6 +23,7 @@ export const TOOLS: ToolDef[] = [
editFileTool,
bashTool,
bashOutputTool,
bashKillTool,
gitCommitTool,
agentTool,
];
+3
View File
@@ -8,6 +8,7 @@ import { getLoadedPlugins } from "../../plugins/registry.js";
import { buildSkillTool } from "../../plugins/skillTool.js";
import type { ToolDef } from "../../tools/types.js";
import { flushPendingSaves } from "../../persistence/sessionStore.js";
import { killAllBackgroundJobs } from "../../tools/backgroundJobs.js";
import { App } from "./App.js";
export interface RunInkAppOptions {
@@ -65,6 +66,8 @@ export async function runInkApp(opts: RunInkAppOptions): Promise<void> {
cleanedUp = true;
// Flush in-flight autosaves first so a fire-and-forget persist right before exit isn't lost.
await flushPendingSaves();
// Best-effort: don't leave backgrounded shells (dev servers, watch builds) running as orphans.
killAllBackgroundJobs();
await disconnectAllMcpServers();
// Best-effort — fires on every exit path, so there isn't always a specific session id to attach
// (e.g. exiting from the model picker before a session ever started).