jeo-code 0.12.13 → 0.12.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.ja.md +32 -8
  3. package/README.ko.md +32 -8
  4. package/README.md +32 -8
  5. package/README.zh.md +32 -8
  6. package/package.json +4 -2
  7. package/src/agent/browser-tool.ts +9 -1
  8. package/src/agent/config-schema.ts +8 -0
  9. package/src/agent/context-files.ts +19 -0
  10. package/src/agent/engine.ts +25 -6
  11. package/src/agent/eval-tool.ts +6 -0
  12. package/src/agent/goal-verifier.ts +30 -6
  13. package/src/agent/hooks.ts +52 -4
  14. package/src/agent/job-registry.ts +110 -18
  15. package/src/agent/job-tool.ts +43 -9
  16. package/src/agent/loop-guards.ts +17 -2
  17. package/src/agent/memory.ts +151 -112
  18. package/src/agent/monitor-registry.ts +142 -30
  19. package/src/agent/monitor-tool.ts +34 -6
  20. package/src/agent/notify/discord-daemon.ts +135 -42
  21. package/src/agent/notify/session-endpoint.ts +27 -5
  22. package/src/agent/sandbox/assets.d.ts +4 -0
  23. package/src/agent/sandbox/authority.ts +295 -0
  24. package/src/agent/sandbox/bundle.ts +37 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +283 -0
  27. package/src/agent/sandbox/hands-source.text +14548 -0
  28. package/src/agent/sandbox/hands-worker.ts +168 -0
  29. package/src/agent/sandbox/policy.ts +155 -0
  30. package/src/agent/sandbox/rpc.ts +90 -0
  31. package/src/agent/sandbox/supervisor.ts +210 -0
  32. package/src/agent/sandbox/types.ts +77 -0
  33. package/src/agent/shell-lease.ts +190 -0
  34. package/src/agent/state.ts +167 -0
  35. package/src/agent/subagents.ts +4 -3
  36. package/src/agent/task-tool.ts +8 -0
  37. package/src/agent/todo-tool.ts +30 -1
  38. package/src/agent/tool-schemas.ts +13 -0
  39. package/src/ai/model-discovery.ts +12 -0
  40. package/src/ai/providers/anthropic.ts +27 -2
  41. package/src/ai/providers/antigravity.ts +10 -2
  42. package/src/ai/providers/gemini.ts +20 -16
  43. package/src/ai/providers/ollama.ts +14 -0
  44. package/src/ai/providers/openai-responses.ts +22 -5
  45. package/src/ai/providers/openai.ts +23 -40
  46. package/src/cli/runner.ts +3 -0
  47. package/src/commands/approve.ts +37 -13
  48. package/src/commands/launch/flags.ts +2 -1
  49. package/src/commands/launch/jobs-slash.ts +1 -1
  50. package/src/commands/launch.ts +261 -35
  51. package/src/commands/messenger.ts +105 -39
  52. package/src/commands/notify.ts +2 -2
  53. package/src/commands/team.ts +44 -12
  54. package/src/commands/ultragoal.ts +6 -16
  55. package/src/messenger/activity.ts +2 -1
  56. package/src/messenger/config.ts +51 -9
  57. package/src/messenger/discord-setup.ts +122 -20
  58. package/src/messenger/discord.ts +37 -43
  59. package/src/messenger/gateway.ts +33 -10
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +3 -2
  63. package/src/skills/catalog.ts +207 -0
@@ -3,6 +3,23 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
 
5
5
  import { IGNORED_DIRS } from "./tools";
6
+ import { currentSandboxExecution } from "./hooks";
7
+
8
+ async function sandboxContext(operation: "loadContext", cwd: string): Promise<ProjectContextFile[]>;
9
+ async function sandboxContext(operation: "discoverGuidance", cwd: string): Promise<Array<{ filePath: string; displayPath: string }>>;
10
+ async function sandboxContext(operation: "loadContext" | "discoverGuidance", cwd: string): Promise<ProjectContextFile[] | Array<{ filePath: string; displayPath: string }>> {
11
+ const sandbox = currentSandboxExecution();
12
+ if (!sandbox) throw new Error("SANDBOX_CONTEXT: missing execution scope");
13
+ const result = await sandbox.supervisor.call("__workflow", { operation }, { cwd, signal: sandbox.signal });
14
+ if (!result.success) throw new Error(result.error || "SANDBOX_CONTEXT: context read failed");
15
+ const value: unknown = JSON.parse(result.output);
16
+ const keys = operation === "loadContext" ? ["path", "content"] : ["filePath", "displayPath"];
17
+ if (!Array.isArray(value) || !value.every(item => item !== null && typeof item === "object" &&
18
+ keys.every(key => typeof item[key] === "string"))) {
19
+ throw new Error("SANDBOX_CONTEXT: invalid guest response");
20
+ }
21
+ return value as ProjectContextFile[] | Array<{ filePath: string; displayPath: string }>;
22
+ }
6
23
 
7
24
  /**
8
25
  * The user's home directory, or `undefined` when it genuinely cannot be determined.
@@ -293,6 +310,7 @@ export function invalidateWorkspaceScan(cwd?: string): void {
293
310
  }
294
311
 
295
312
  export async function discoverAgentGuidanceFiles(cwd = process.cwd()): Promise<Array<{ filePath: string; displayPath: string }>> {
313
+ if (currentSandboxExecution()) return sandboxContext("discoverGuidance", cwd);
296
314
  const scan = await getWorkspaceScan(cwd);
297
315
  const out: Array<{ filePath: string; displayPath: string }> = [...scan.localGuidance];
298
316
  // No resolvable home → NO home roots. Previously this fell back to `path.join("", …)`,
@@ -325,6 +343,7 @@ export async function discoverAgentGuidanceFiles(cwd = process.cwd()): Promise<A
325
343
  // cache. This keeps the result disk-truthful — a guidance/AGENTS.md edit is reflected on the
326
344
  // next spawn — while still avoiding the dominant per-spawn cost (re-reading every file).
327
345
  export async function loadProjectContext(cwd = process.cwd()): Promise<ProjectContextFile[]> {
346
+ if (currentSandboxExecution()) return sandboxContext("loadContext", cwd);
328
347
  const result: ProjectContextFile[] = [];
329
348
  let baseChars = 0;
330
349
  let guidanceChars = 0;
@@ -25,14 +25,14 @@ import { createDebugTool } from "./debug-tool";
25
25
  import { createBrowserTool } from "./browser-tool";
26
26
  import { isRateLimitError, isTransientStreamDropError, isProviderStreamError, waitAbortable } from "../util/retry";
27
27
  import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
28
- import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
28
+ import { currentSandboxExecution, withSandboxExecution, runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
29
29
  import { truncateToolOutput, formatToolResultBody } from "./tool-output";
30
30
  export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFACTS, truncateToolOutput, spillToolResult } from "./tool-output";
31
31
  import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, toolTarget, type StepBudgetConfig } from "./step-budget";
32
32
 
33
33
  import { historyTokens, trimToolResultsInPlace, stripReasoningArtifactsInPlace } from "./compaction";
34
34
  import { jeoEnv } from "../util/env";
35
- import { GUARD_LIMITS, isVerificationSignal, repeatHint, classifyDoneGate } from "./loop-guards";
35
+ import { GUARD_LIMITS, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate } from "./loop-guards";
36
36
  import { stripLeakedReasoningTags } from "../ai/think-tags";
37
37
 
38
38
 
@@ -111,6 +111,17 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
111
111
  calc: a => calcTool(Array.isArray(a.calculations) ? a.calculations : []),
112
112
  };
113
113
 
114
+ /** Trusted wrappers that preserve role checks before forwarding to hands. */
115
+ export const SANDBOX_MEDIATED_TOOLS = new WeakSet<ToolHandler>();
116
+ // Engine and workflow callers share these handlers; the async scope is session-local.
117
+ for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
118
+ DEFAULT_TOOLS[name] = (args, cwd, onProgress) => {
119
+ const sandbox = currentSandboxExecution();
120
+ return sandbox ? sandbox.supervisor.call(name, args, { cwd, signal: sandbox.signal, onProgress }) : handler(args, cwd, onProgress);
121
+ };
122
+ SANDBOX_MEDIATED_TOOLS.add(DEFAULT_TOOLS[name]);
123
+ }
124
+
114
125
  /** Tool names whose calls have no workspace side effects. Shared by execution,
115
126
  * failure accounting, and the refusal-recovery replay exemption. */
116
127
  const READONLY_TOOL_NAMES: Record<string, true> = {
@@ -520,6 +531,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
520
531
  history.unshift({ role: "system", content: opts.systemPrompt });
521
532
  }
522
533
  const tools = opts.tools ?? DEFAULT_TOOLS;
534
+ const sandbox = currentSandboxExecution();
535
+ const parentTools: Record<string, true> = { task: true, subagent: true, irc: true, todo: true, sandbox_request: true };
523
536
  const maxSteps = opts.maxSteps ?? 15;
524
537
  // gjc-style retry flow: the step limit is a flexible BUDGET, not a bare counter.
525
538
  // While the recent window shows real progress the budget extends itself; a stalled
@@ -1277,7 +1290,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1277
1290
  } else {
1278
1291
  try {
1279
1292
  const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
1280
- const res = await handler(args ?? {}, cwd, onProgress);
1293
+ const sandboxedHandler = sandbox && !Object.hasOwn(parentTools, tool) && !SANDBOX_MEDIATED_TOOLS.has(handler)
1294
+ ? (input: Record<string, any>, _cwd: string, progress?: (text: string) => void) => sandbox.supervisor.call(tool, input, { cwd, signal: opts.signal, onProgress: progress })
1295
+ : handler;
1296
+ const res = await (sandbox
1297
+ ? withSandboxExecution({ ...sandbox, signal: opts.signal }, () => sandboxedHandler(args ?? {}, cwd, onProgress))
1298
+ : sandboxedHandler(args ?? {}, cwd, onProgress));
1281
1299
  success = res.success;
1282
1300
  images = res.images;
1283
1301
  output = res.success ? res.output : (res.error ? (res.output ? `${res.error}\n${res.output}` : res.error) : res.output);
@@ -1463,9 +1481,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1463
1481
  for (let i = 0; i < toolCalls.length; i++) {
1464
1482
  if (!results[i].executed) continue;
1465
1483
  const t = toolCalls[i].tool;
1466
- const verificationCall = t === "bash" && isVerificationSignal(String(toolCalls[i].arguments?.command ?? toolCalls[i].arguments?.cmd ?? ""));
1484
+ const command = String(toolCalls[i].arguments?.command ?? toolCalls[i].arguments?.cmd ?? "");
1485
+ const verificationCall = t === "bash" && isVerificationSignal(command);
1467
1486
  if (verificationCall) {
1468
- if (results[i].success) {
1487
+ if (results[i].success && verificationProducedEvidence(command, String(results[i].output ?? ""))) {
1469
1488
  sawVerification = true;
1470
1489
  lastVerificationSeq = ++mutVerSeq;
1471
1490
  } else {
@@ -1514,7 +1533,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1514
1533
  sawMutation = true;
1515
1534
  lastMutationSeq = ++mutVerSeq;
1516
1535
  } else if (t === "bash") {
1517
- // Non-verification shell commands may mutate files even when they exit unsuccessfully.
1536
+ // Even simple commands can run shell startup hooks or inherited functions.
1518
1537
  sawMutation = true;
1519
1538
  lastMutationSeq = ++mutVerSeq;
1520
1539
  }
@@ -49,6 +49,7 @@ import {
49
49
  } from "./task-tool";
50
50
  import { getSubagentRole, defaultSubagentRole, subagentRoleIds } from "./subagents";
51
51
  import { loadProjectContext, type ProjectContextFile } from "./context-files";
52
+ import { currentSandboxExecution } from "./hooks";
52
53
 
53
54
  export interface EvalToolOptions {
54
55
  config: SubagentTaskConfig;
@@ -101,6 +102,9 @@ const DEFAULT_EVAL_TIMEOUT_MS = 300_000; // 5 minutes
101
102
  const MAX_EVAL_TIMEOUT_MS = 1_800_000; // 30 minutes — same outer ceiling as JEO_CALL_TIMEOUT_MS's default
102
103
 
103
104
  export function evalToolProtocolLine(config?: Pick<import("./state").Config, "subagents">): string {
105
+ if (currentSandboxExecution()) {
106
+ return "eval {code, timeoutMs?} executes JavaScript in the persistent session hands kernel at the mapped working directory. Filesystem/process access stays inside the container. task(), parallel(), and pipeline() orchestration are not available there; use the parent task tool to delegate model work.";
107
+ }
104
108
  return (
105
109
  `eval {code, timeoutMs?} — Dynamic Workflows: write an async JS function BODY (return value or throw) ` +
106
110
  `composing subagent dispatch with REAL control flow (loops/conditionals/sequencing) instead of one static tool call. ` +
@@ -224,6 +228,8 @@ type WorkerMsg = TaskRequestMsg | LogMsg | DoneMsg;
224
228
 
225
229
  export function createEvalTool(opts: EvalToolOptions): ToolHandler {
226
230
  return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
231
+ const sandbox = currentSandboxExecution();
232
+ if (sandbox) return sandbox.supervisor.call("eval", args, { cwd, signal: opts.signal ?? sandbox.signal });
227
233
  const code = typeof args.code === "string" ? args.code : "";
228
234
  if (!code.trim()) {
229
235
  return { success: false, output: "", error: `eval requires a non-empty 'code' body (an async function BODY — the last expression or an explicit 'return' becomes the result).` };
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
- import { callLlm, type Message } from "./loop";
3
+ import { callLlm } from "./loop";
4
+ import type { Message } from "./loop";
4
5
 
5
6
  export interface GoalVerdict {
6
7
  verdict: "MET" | "NOT_MET" | "IMPOSSIBLE";
@@ -204,25 +205,42 @@ export function applyPendingEvidence(state: GoalState, evidence: TurnEvidence):
204
205
 
205
206
  import * as path from "node:path";
206
207
  import * as fs from "node:fs/promises";
207
- import { getLocalJeoDir } from "./state";
208
+ import { getLocalJeoDir, readWorkflowFile, writeWorkflowFile } from "./state";
209
+ import { currentSandboxExecution } from "./hooks";
208
210
 
209
211
  export function getGoalPath(cwd: string = process.cwd()): string {
210
212
  return path.join(getLocalJeoDir(cwd), "state", "goal.json");
211
213
  }
212
214
 
213
- export async function readGoalState(cwd: string = process.cwd()): Promise<GoalState | null> {
215
+ /** Return exact persisted bytes (or absence) so corruption is classified in the brain. */
216
+ export async function readGoalFile(cwd: string = process.cwd()): Promise<string | null> {
217
+ const sandbox = currentSandboxExecution();
218
+ if (sandbox) {
219
+ const result = await sandbox.supervisor.call("__workflow", { operation: "readGoal" }, { cwd, signal: sandbox.signal });
220
+ if (!result.success) throw new Error(result.error || "SANDBOX_WORKFLOW: goal read failed");
221
+ const value: unknown = JSON.parse(result.output);
222
+ if (value !== null && typeof value !== "string") throw new Error("SANDBOX_WORKFLOW: invalid goal file response");
223
+ return value;
224
+ }
214
225
  const p = getGoalPath(cwd);
215
- let data: string;
216
226
  try {
217
- data = await fs.readFile(p, "utf-8");
227
+ return await readWorkflowFile(p, cwd);
218
228
  } catch (error) {
219
229
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
220
230
  throw error;
221
231
  }
222
- return parseGoalState(data, p);
232
+ }
233
+
234
+ export async function readGoalState(cwd: string = process.cwd()): Promise<GoalState | null> {
235
+ const data = await readGoalFile(cwd);
236
+ return data === null ? null : parseGoalState(data, getGoalPath(cwd));
223
237
  }
224
238
 
225
239
  export async function writeGoalState(state: GoalState, cwd: string = process.cwd()): Promise<void> {
240
+ if (currentSandboxExecution()) {
241
+ await writeWorkflowFile(getGoalPath(cwd), JSON.stringify(state, null, 2), cwd);
242
+ return;
243
+ }
226
244
  const p = getGoalPath(cwd);
227
245
  await fs.mkdir(path.dirname(p), { recursive: true });
228
246
  const tempPath = `${p}.${randomUUID()}.tmp`;
@@ -235,6 +253,12 @@ export async function writeGoalState(state: GoalState, cwd: string = process.cwd
235
253
  }
236
254
 
237
255
  export async function clearGoalState(cwd: string = process.cwd()): Promise<void> {
256
+ const sandbox = currentSandboxExecution();
257
+ if (sandbox) {
258
+ const result = await sandbox.supervisor.call("__workflow", { operation: "clearGoal" }, { cwd, signal: sandbox.signal });
259
+ if (!result.success) throw new Error(result.error || "SANDBOX_WORKFLOW: goal clear failed");
260
+ return;
261
+ }
238
262
  const p = getGoalPath(cwd);
239
263
  await fs.unlink(p).catch(() => {});
240
264
  }
@@ -1,3 +1,24 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { SandboxSupervisor } from "./sandbox/types";
3
+ import type { GrantAuthority } from "./sandbox/authority";
4
+
5
+ export interface SandboxExecutionScope {
6
+ supervisor: SandboxSupervisor;
7
+ authority?: GrantAuthority;
8
+ signal?: AbortSignal;
9
+ requestAccess?: (host: string, mode: "ro" | "rw") => Promise<unknown>;
10
+ }
11
+
12
+ const sandboxExecution = new AsyncLocalStorage<SandboxExecutionScope>();
13
+
14
+ export function currentSandboxExecution(): SandboxExecutionScope | undefined {
15
+ return sandboxExecution.getStore();
16
+ }
17
+
18
+ export function withSandboxExecution<T>(scope: SandboxExecutionScope, run: () => T): T {
19
+ return sandboxExecution.run(scope, run);
20
+ }
21
+
1
22
  import * as fs from "node:fs/promises";
2
23
  import * as path from "node:path";
3
24
  import { readGlobalConfig } from "./state";
@@ -63,12 +84,25 @@ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
63
84
  return result;
64
85
  }
65
86
 
66
- export async function loadHooks(cwd: string): Promise<NonNullable<HookConfig["hooks"]>> {
87
+ export async function loadHooks(cwd: string, signal?: AbortSignal): Promise<NonNullable<HookConfig["hooks"]>> {
67
88
  const config = await readGlobalConfig();
68
89
  if (!config.hooks?.enabled) {
69
90
  return [];
70
91
  }
71
92
 
93
+ const sandbox = currentSandboxExecution();
94
+ if (sandbox) {
95
+ const result = await sandbox.supervisor.exec(["bun", "-e", "const file=Bun.file('.jeo/hooks.json');let value={};try{if(await file.exists())value=await file.json()}catch{}process.stdout.write(JSON.stringify(value));"], { cwd, signal: signal ?? sandbox.signal });
96
+ if (!result.success || result.exitCode !== 0) throw new Error(`Sandbox project hook configuration read failed (exit code ${result.exitCode ?? "unavailable"}): ${result.error || result.output}`);
97
+ let local: unknown;
98
+ try { local = JSON.parse(result.stdout ?? result.output); }
99
+ catch { throw new Error("Malformed sandbox project hook configuration"); }
100
+ if (local && typeof local === "object") {
101
+ if ("enabled" in local && local.enabled === false) return [];
102
+ if ("hooks" in local && Array.isArray(local.hooks)) return local.hooks;
103
+ }
104
+ return config.hooks.hooks || [];
105
+ }
72
106
  const local = await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
73
107
  if (local.kind === "disabled") return [];
74
108
  if (local.kind === "hooks") return local.hooks;
@@ -86,6 +120,19 @@ async function runHookCommand(
86
120
  if (signal?.aborted) {
87
121
  return { stdout: "", stderr: "", output: "", exitCode: null, timedOut: false, aborted: true };
88
122
  }
123
+ const sandbox = currentSandboxExecution();
124
+ if (sandbox) {
125
+ const timeout = AbortSignal.timeout(timeoutMs);
126
+ const result = await sandbox.supervisor.exec(["/bin/sh", "-c", runCmd], {
127
+ cwd, stdin: JSON.stringify(payload), signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
128
+ });
129
+ if (result.exitCode === undefined) throw new Error(result.error || "Malformed sandbox hook result: missing exitCode");
130
+ return {
131
+ stdout: result.stdout ?? result.output, stderr: result.stderr ?? "", output: result.output,
132
+ exitCode: result.exitCode, timedOut: result.timedOut === true || timeout.aborted,
133
+ aborted: result.aborted === true || signal?.aborted === true,
134
+ };
135
+ }
89
136
 
90
137
  const proc = Bun.spawn(["bash", "-c", runCmd], {
91
138
  cwd,
@@ -154,6 +201,7 @@ async function runHookCommand(
154
201
  };
155
202
  }
156
203
 
204
+
157
205
  /** True when a hook's `match.tool` selects `tool`. Accepts a single name or a
158
206
  * `|`-separated list (`"edit|write"`) — so one post-edit tsc hook can cover
159
207
  * every mutating tool without duplicate entries. No match = all tools. */
@@ -170,7 +218,7 @@ export async function runPreToolHooks(
170
218
  onNotice?: (msg: string) => void
171
219
  ): Promise<{ vetoed: boolean; error?: string; output?: string }> {
172
220
  try {
173
- const hooks = await loadHooks(cwd);
221
+ const hooks = await loadHooks(cwd, signal);
174
222
  const preToolHooks = hooks.filter(
175
223
  h => h.event === "pre-tool" && hookMatchesTool(h.match?.tool, tool)
176
224
  );
@@ -243,7 +291,7 @@ export async function runPostTurnHooksForBatch(
243
291
  // "hooks ran clean" (clear a pending failure) differently from "no hook ran".
244
292
  let ran = 0;
245
293
  try {
246
- const hooks = await loadHooks(cwd);
294
+ const hooks = await loadHooks(cwd, signal);
247
295
  const jobs = hooks
248
296
  .filter(h => h.event === "post-turn")
249
297
  .map(hook => ({ hook, matched: calls.filter(c => hookMatchesTool(hook.match?.tool, c.tool)) }))
@@ -311,7 +359,7 @@ export async function runPostImplementationHooks(
311
359
  onNotice?: (msg: string) => void
312
360
  ): Promise<{ success: boolean; output: string }> {
313
361
  try {
314
- const hooks = await loadHooks(cwd);
362
+ const hooks = await loadHooks(cwd, signal);
315
363
  const postImplHooks = hooks.filter(h => h.event === "post-implementation");
316
364
 
317
365
  let combinedOutput = "";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * In-process registry for BACKGROUND shell processes (gjc `job`/async-bash parity,
3
3
  * scoped down to one runtime). Unlike the synchronous `bash` tool, `start()` spawns
4
- * a real parallel OS process via `Bun.spawn` and returns immediately — the parent
4
+ * a real parallel OS process (see `shell-lease.ts`) and returns immediately — the parent
5
5
  * can keep working and later list / tail / await / cancel it via the `job` control
6
6
  * tool while stdout/stderr drain into a bounded buffer in the background.
7
7
  *
@@ -12,6 +12,8 @@
12
12
  *
13
13
  */
14
14
 
15
+ import { settlesWithin, spawnLeasedShell, type LeasedShell } from "./shell-lease";
16
+
15
17
  export type JobStatus = "running" | "exited" | "killed" | "failed";
16
18
 
17
19
  export interface JobRecord {
@@ -27,17 +29,25 @@ export interface JobRecord {
27
29
 
28
30
  interface Entry {
29
31
  record: JobRecord;
30
- proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
31
32
  promise: Promise<void>;
33
+ cancel?: () => boolean;
34
+ /** SIGKILL reached the pinned process group. */
35
+ killDelivered?: boolean;
36
+ /** Termination of every process holding the job's group or pipes cannot be confirmed. */
37
+ incomplete?: boolean;
32
38
  }
33
39
 
34
40
  const OUTPUT_CAP = 20000;
35
41
  const TRUNCATION_MARKER = "[…output truncated…]\n";
42
+ const PIPE_LINGER_MS = 500;
36
43
 
37
44
  export class JobRegistry {
38
45
  private readonly entries = new Map<string, Entry>();
39
46
  private readonly buffers = new Map<string, string>();
40
47
  private readonly truncated = new Set<string>();
48
+ private readonly reported = new Set<string>();
49
+ private readonly pendingMutations = new Set<string>();
50
+ private readonly active = new Set<string>();
41
51
  private seq = 0;
42
52
 
43
53
  private append(id: string, chunk: string): void {
@@ -66,42 +76,91 @@ export class JobRegistry {
66
76
  };
67
77
  this.buffers.set(id, "");
68
78
 
69
- let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
79
+ let shell: LeasedShell;
70
80
  try {
71
- proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
81
+ shell = spawnLeasedShell(command, cwd);
72
82
  } catch (err) {
73
83
  record.status = "failed";
74
84
  record.finishedAt = Date.now();
75
85
  this.append(id, err instanceof Error ? err.message : String(err));
86
+ this.pendingMutations.add(id);
76
87
  // No process handle to store — nothing further to drain or cancel.
77
- this.entries.set(id, { record, proc: undefined as any, promise: Promise.resolve() });
88
+ this.entries.set(id, { record, promise: Promise.resolve() });
78
89
  return record;
79
90
  }
91
+ this.active.add(id);
80
92
 
81
- const drain = async (stream: ReadableStream<Uint8Array>) => {
93
+ const entry: Entry = { record, promise: Promise.resolve() };
94
+ entry.cancel = (): boolean => {
95
+ const outcome = shell.killGroup();
96
+ if (outcome === "delivered") { entry.killDelivered = true; return true; }
97
+ entry.incomplete = true;
98
+ this.append(id, outcome === "unpinned"
99
+ ? "\n[job process group is no longer pinned; termination cannot be confirmed]\n"
100
+ : outcome === "unsupported"
101
+ ? "\n[job process tree termination cannot be confirmed on Windows]\n"
102
+ : `\n[job process group termination attempt failed: ${String(outcome.error)}]\n`);
103
+ shell.killDirect();
104
+ return false;
105
+ };
106
+ const drain = async (reader: { read: () => Promise<{ done?: boolean; value?: Uint8Array }> }) => {
82
107
  const decoder = new TextDecoder();
83
- for await (const chunk of stream) {
84
- this.append(id, decoder.decode(chunk, { stream: true }));
108
+ while (true) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ if (value) this.append(id, decoder.decode(value, { stream: true }));
85
112
  }
113
+ this.append(id, decoder.decode());
86
114
  };
115
+ const drains = Promise.all([drain(shell.stdout), drain(shell.stderr)]);
116
+ void drains.catch(() => {});
87
117
 
88
118
  const promise = (async () => {
89
119
  try {
90
- await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
91
- const exitCode = await proc.exited;
92
- if (record.status === "killed") return;
93
- record.status = "exited";
120
+ const exitCode = await shell.exit;
121
+ let lingered = false;
122
+ if (!(await settlesWithin(drains, PIPE_LINGER_MS))) {
123
+ lingered = true;
124
+ // A descendant still holds stdout/stderr. Only the pinned group may be signalled; if EOF does
125
+ // not follow the delivered SIGKILL, the holder left the group and stays unresolved.
126
+ if ((entry.killDelivered || entry.cancel!()) && !(await settlesWithin(drains, PIPE_LINGER_MS))) {
127
+ entry.incomplete = true;
128
+ this.append(id, "\n[job pipe remains open outside the process group; termination cannot be confirmed]\n");
129
+ }
130
+ shell.dispose();
131
+ await drains;
132
+ }
133
+ if (exitCode === null && !entry.killDelivered && !entry.incomplete) {
134
+ entry.incomplete = true;
135
+ this.append(id, "\n[job process group lease ended without an exit report; termination cannot be confirmed]\n");
136
+ }
137
+ if (record.status === "killed" && !entry.incomplete) return;
138
+ if (!lingered && !entry.incomplete) {
139
+ record.status = "exited";
140
+ record.exitCode = exitCode;
141
+ return;
142
+ }
143
+ record.status = "failed";
94
144
  record.exitCode = exitCode;
145
+ if (lingered) this.append(id, "\n[job output incomplete: a descendant kept stdout or stderr open after the shell exited]\n");
95
146
  } catch (err) {
147
+ // Output can no longer be observed: stop the group now so the holder can be reaped.
148
+ if (!entry.killDelivered) entry.cancel!();
96
149
  if (record.status === "killed") return;
97
150
  record.status = "failed";
98
151
  this.append(id, err instanceof Error ? err.message : String(err));
99
152
  } finally {
153
+ shell.release();
154
+ await shell.reaped;
155
+ shell.dispose();
156
+ if (!entry.incomplete) this.active.delete(id);
157
+ this.pendingMutations.add(id);
100
158
  if (record.finishedAt === undefined) record.finishedAt = Date.now();
101
159
  }
102
160
  })();
103
161
 
104
- this.entries.set(id, { record, proc, promise });
162
+ entry.promise = promise;
163
+ this.entries.set(id, entry);
105
164
  return record;
106
165
  }
107
166
 
@@ -117,6 +176,34 @@ export class JobRegistry {
117
176
  return this.list().filter(r => r.status === "running");
118
177
  }
119
178
 
179
+ /** Include running jobs and terminal jobs not yet reported by await. */
180
+ awaiting(): JobRecord[] {
181
+ return this.list().filter(r => r.status === "running" || !this.reported.has(r.id));
182
+ }
183
+
184
+ markReported(records: JobRecord[]): void {
185
+ for (const record of records) {
186
+ if (record.status !== "running") this.reported.add(record.id);
187
+ }
188
+ }
189
+
190
+ /** Report each process completion once to invalidate earlier verification. */
191
+ consumeMutations(ids?: string[]): boolean {
192
+ if (ids === undefined) {
193
+ const pending = this.pendingMutations.size > 0;
194
+ this.pendingMutations.clear();
195
+ return pending;
196
+ }
197
+ let pending = false;
198
+ for (const id of ids) pending = this.pendingMutations.delete(id) || pending;
199
+ return pending;
200
+ }
201
+
202
+ /** Processes not yet settled or whose completion has not reached the engine. */
203
+ unobserved(): JobRecord[] {
204
+ return this.list().filter(record => this.active.has(record.id) || this.pendingMutations.has(record.id));
205
+ }
206
+
120
207
  /** Current buffered output for one job (empty string if the id is unknown). */
121
208
  tail(id: string): string {
122
209
  return this.buffers.get(id) ?? "";
@@ -142,25 +229,30 @@ export class JobRegistry {
142
229
  return targets.map(e => e.record);
143
230
  }
144
231
 
145
- /** Cancel the given ids: SIGTERM via `proc.kill()` and mark killed. Already-terminal
146
- * records are returned unchanged. */
232
+ /** Cancel the process group on POSIX, or the direct process on Windows. */
147
233
  cancel(ids: string[]): JobRecord[] {
148
234
  const out: JobRecord[] = [];
149
235
  for (const id of ids) {
150
236
  const e = this.entries.get(id);
151
237
  if (!e) continue;
238
+ if (e.incomplete) { out.push(e.record); continue; }
152
239
  if (e.record.status === "running") {
153
- e.record.status = "killed";
240
+ const killed = e.cancel?.();
241
+ e.record.status = killed ? "killed" : "failed";
154
242
  e.record.finishedAt = Date.now();
155
- e.proc?.kill();
156
243
  }
157
244
  out.push(e.record);
158
245
  }
159
246
  return out;
160
247
  }
161
248
 
249
+ /** Include unresolved process groups even if their shell has exited. */
250
+ cancellable(): JobRecord[] {
251
+ return this.list().filter(r => r.status === "running" || this.entries.get(r.id)?.incomplete);
252
+ }
253
+
162
254
  /** Kill every still-running job (session teardown / Ctrl-C). */
163
255
  cancelAll(): JobRecord[] {
164
- return this.cancel(this.running().map(r => r.id));
256
+ return this.cancel(this.cancellable().map(r => r.id));
165
257
  }
166
258
  }
@@ -6,13 +6,17 @@
6
6
  import type { ToolHandler } from "./engine";
7
7
  import type { ToolResult } from "./tools";
8
8
  import type { JobRegistry, JobRecord } from "./job-registry";
9
+ import { settlesWithin } from "./shell-lease";
10
+
11
+ /** Upper bound on pipe-linger, escape detection and notice-queue drain after a cancel. */
12
+ const CANCEL_SETTLE_MS = 3000;
9
13
 
10
14
  /** One-line protocol description appended to the launch system prompt. */
11
15
  export const JOB_TOOL_PROTOCOL_LINE =
12
16
  `job {action:"start", command, cwd?} runs a shell command in the BACKGROUND as a real ` +
13
17
  `parallel OS process and returns a job id immediately. job {action:"list"|"tail"|"await"|"cancel", ` +
14
18
  `ids?, timeoutMs?} — 'tail' shows buffered output so far; 'await' blocks (optionally up to timeoutMs ` +
15
- `ms) until it exits; 'cancel' kills it; omit ids to target all running.`;
19
+ `ms) until it exits; 'cancel' kills it; omit ids to target all running, plus unreported completions for 'await'.`;
16
20
 
17
21
  function truncate(s: string, n: number): string {
18
22
  return s.length > n ? s.slice(0, n - 1) + "…" : s;
@@ -24,7 +28,8 @@ function elapsed(rec: JobRecord): string {
24
28
  }
25
29
 
26
30
  function rowLine(rec: JobRecord): string {
27
- return `- ${rec.id} [${rec.status.toUpperCase()}] ${elapsed(rec)} · ${truncate(rec.command, 100)}`;
31
+ const exit = rec.exitCode != null ? ` (exit code ${rec.exitCode})` : "";
32
+ return `- ${rec.id} [${rec.status.toUpperCase()}]${exit} ${elapsed(rec)} · ${truncate(rec.command, 100)}`;
28
33
  }
29
34
 
30
35
  function idsOf(args: Record<string, any>): string[] {
@@ -33,8 +38,8 @@ function idsOf(args: Record<string, any>): string[] {
33
38
  return [];
34
39
  }
35
40
 
36
- export function createJobTool(registry: JobRegistry): ToolHandler {
37
- return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
41
+ export function createJobTool(registry: JobRegistry, reportMutations = true): ToolHandler {
42
+ const handle: ToolHandler = async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
38
43
  const action = String(args.action ?? "list").trim().toLowerCase();
39
44
  const ids = idsOf(args);
40
45
 
@@ -79,28 +84,57 @@ export function createJobTool(registry: JobRegistry): ToolHandler {
79
84
  }
80
85
 
81
86
  if (action === "await") {
82
- const targets = ids.length ? ids : registry.running().map(r => r.id);
87
+ const targets = ids.length ? ids : registry.awaiting().map(r => r.id);
83
88
  if (targets.length === 0) {
84
- return { success: true, output: "No running jobs to await." };
89
+ return { success: true, output: "No jobs to await." };
90
+ }
91
+ const unknown = targets.filter(id => !registry.get(id));
92
+ if (unknown.length > 0) {
93
+ return { success: false, output: "", error: `No job matches ${unknown.join(", ")}.` };
85
94
  }
86
95
  const timeoutMs = typeof args.timeoutMs === "number" && args.timeoutMs > 0 ? args.timeoutMs : undefined;
87
96
  const recs = await registry.awaitIds(targets, timeoutMs);
97
+ if (reportMutations) registry.markReported(recs);
88
98
  const stillRunning = recs.filter(r => r.status === "running").length;
89
99
  const head = stillRunning > 0
90
100
  ? `Awaited ${recs.length} job(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again or cancel.`
91
101
  : `Awaited ${recs.length} job(s); all settled.`;
92
- return { success: stillRunning === 0, output: `${head}\n\n${recs.map(rowLine).join("\n")}` };
102
+ const success = recs.every(r => r.status === "exited" && r.exitCode === 0);
103
+ return { success, output: `${head}\n\n${recs.map(rowLine).join("\n")}\n\nUse job {action:"tail", ids:[...]} to inspect output.` };
93
104
  }
94
105
 
95
106
  if (action === "cancel") {
96
- const targets = ids.length ? ids : registry.running().map(r => r.id);
107
+ const cancellable = new Set(registry.cancellable().map(r => r.id));
108
+ const targets = ids.length ? [...new Set(ids)] : [...cancellable];
97
109
  if (targets.length === 0) {
98
110
  return { success: true, output: "No running jobs to cancel." };
99
111
  }
112
+ const unknown = targets.filter(id => !registry.get(id));
113
+ if (unknown.length > 0) {
114
+ return { success: false, output: "", error: `No job matches ${unknown.join(", ")}.` };
115
+ }
116
+ const attempted = targets.filter(id => cancellable.has(id));
100
117
  const recs = registry.cancel(targets);
101
- return { success: true, output: `Cancelled ${recs.length} job(s):\n${recs.map(rowLine).join("\n")}` };
118
+ // A delivered SIGKILL is only verified once the output pipes close; settlement can still
119
+ // downgrade `killed` to `failed` (a holder that left the group), so wait before judging.
120
+ const settled = attempted.length === 0 || await settlesWithin(registry.awaitIds(attempted), CANCEL_SETTLE_MS);
121
+ const failed = recs.filter(r => cancellable.has(r.id) && (!settled || r.status !== "killed")).length;
122
+ const head = attempted.length ? `Cancelled ${attempted.length - failed} job(s); ${failed} cancellation(s) failed; ${recs.length - attempted.length} already settled.` : "No running jobs to cancel; requested jobs are already settled.";
123
+ return { success: failed === 0, output: `${head}\n${recs.map(rowLine).join("\n")}`, mutationMayHaveOccurred: attempted.length > 0 };
102
124
  }
103
125
 
104
126
  return { success: false, output: "", error: `Unknown job action '${action}'. Use start | list | tail | await | cancel.` };
105
127
  };
128
+ return async (args, cwd) => {
129
+ const action = String(args.action ?? "list").trim().toLowerCase();
130
+ const ids = idsOf(args);
131
+ const targets = action === "list" ? registry.list().map(r => r.id)
132
+ : action === "await" ? (ids.length ? ids : registry.awaiting().map(r => r.id))
133
+ : action === "tail" ? (ids.length ? ids : registry.running().map(r => r.id))
134
+ : action === "cancel" ? (ids.length ? ids : registry.cancellable().map(r => r.id))
135
+ : [];
136
+ const result = await handle(args, cwd);
137
+ if (reportMutations && !result.error && registry.consumeMutations(targets)) result.mutationMayHaveOccurred = true;
138
+ return result;
139
+ };
106
140
  }