diff --git a/README.md b/README.md index e497cb8..f8c7515 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 [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 ``` diff --git a/package-lock.json b/package-lock.json index f047960..f2de5bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 529a82d..5a50d70 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/backend/client.ts b/src/backend/client.ts index c7572b7..31af725 100644 --- a/src/backend/client.ts +++ b/src/backend/client.ts @@ -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, }); } diff --git a/src/cli.ts b/src/cli.ts index cd1ea96..60c7789 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -114,7 +114,7 @@ configCmd configCmd .command("set ") - .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; } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 3674b50..35779c0 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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); + }); }); diff --git a/src/config/config.ts b/src/config/config.ts index 441cf9b..a3ae880 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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; +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index d4b2807..a23802d 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -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; diff --git a/src/config/store.ts b/src/config/store.ts index a22f07e..7f788ea 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -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: "" }); diff --git a/src/tools/backgroundJobs.test.ts b/src/tools/backgroundJobs.test.ts new file mode 100644 index 0000000..79c4792 --- /dev/null +++ b/src/tools/backgroundJobs.test.ts @@ -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(); + } + }); +}); diff --git a/src/tools/backgroundJobs.ts b/src/tools/backgroundJobs.ts index ed5278e..cc9127f 100644 --- a/src/tools/backgroundJobs.ts +++ b/src/tools/backgroundJobs.ts @@ -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(); +// 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(); 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(); +} diff --git a/src/tools/bashKill.ts b/src/tools/bashKill.ts new file mode 100644 index 0000000..f03153d --- /dev/null +++ b/src/tools/bashKill.ts @@ -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> = { + 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}.` }; + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 1ad9c5d..3123a7e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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, ]; diff --git a/src/ui/ink/index.tsx b/src/ui/ink/index.tsx index 383a98e..f831afd 100644 --- a/src/ui/ink/index.tsx +++ b/src/ui/ink/index.tsx @@ -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 { 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).