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:
@@ -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
|
||||
```
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}.` };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user