jeo-code 0.7.30 → 0.7.32

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 (47) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.ja.md +5 -3
  3. package/README.ko.md +5 -3
  4. package/README.md +5 -3
  5. package/README.zh.md +5 -3
  6. package/package.json +3 -3
  7. package/scripts/install.sh +19 -0
  8. package/scripts/uninstall.sh +2 -2
  9. package/src/agent/AGENTS.md +12 -0
  10. package/src/agent/ast-edit-tool.ts +123 -0
  11. package/src/agent/ast-file-scan.ts +62 -0
  12. package/src/agent/ast-grep-tool.ts +107 -0
  13. package/src/agent/ast-match.ts +241 -0
  14. package/src/agent/compaction.ts +6 -19
  15. package/src/agent/debug-session.ts +435 -0
  16. package/src/agent/debug-tool.ts +89 -0
  17. package/src/agent/engine.ts +103 -18
  18. package/src/agent/goal-tool.ts +57 -0
  19. package/src/agent/irc-tool.ts +71 -0
  20. package/src/agent/job-registry.ts +163 -0
  21. package/src/agent/job-tool.ts +106 -0
  22. package/src/agent/lsp-rename-tool.ts +108 -0
  23. package/src/agent/lsp-tool.ts +177 -0
  24. package/src/agent/memory-graph.ts +6 -1
  25. package/src/agent/memory.ts +73 -52
  26. package/src/agent/session.ts +27 -0
  27. package/src/agent/subagent-registry.ts +40 -4
  28. package/src/agent/subagent-tool.ts +27 -7
  29. package/src/agent/subagents.ts +1 -1
  30. package/src/agent/task-tool.ts +27 -5
  31. package/src/agent/tools.ts +100 -5
  32. package/src/agent/ts-language-service.ts +173 -0
  33. package/src/ai/model-manager.ts +58 -26
  34. package/src/ai/sse.ts +22 -7
  35. package/src/ai/think-tags.ts +10 -1
  36. package/src/commands/deep-interview.ts +49 -1
  37. package/src/commands/launch/agents-slash.ts +489 -0
  38. package/src/commands/launch/flags.ts +27 -9
  39. package/src/commands/launch/model-slash.ts +290 -0
  40. package/src/commands/launch/session-slash.ts +305 -0
  41. package/src/commands/launch/slash-handlers.ts +6 -0
  42. package/src/commands/launch/system-slash.ts +81 -0
  43. package/src/commands/launch/tmux.ts +32 -0
  44. package/src/commands/launch.ts +227 -750
  45. package/src/tui/app.ts +32 -12
  46. package/src/tui/components/evolution.ts +23 -3
  47. package/src/tui/components/width.ts +11 -6
@@ -15,6 +15,12 @@ import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
15
15
  import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
16
16
  import { webSearchTool, setWebSearchActiveModel } from "./web-search";
17
17
  import { executeComputerAction } from "../commands/computer";
18
+ import { computerSupervisor } from "./computer-supervisor";
19
+ import { createAstGrepTool } from "./ast-grep-tool";
20
+ import { createAstEditTool } from "./ast-edit-tool";
21
+ import { createLspTool } from "./lsp-tool";
22
+ import { createLspRenameTool } from "./lsp-rename-tool";
23
+ import { createDebugTool } from "./debug-tool";
18
24
  import { isRateLimitError, waitAbortable } from "../util/retry";
19
25
  import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
20
26
  import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
@@ -82,7 +88,21 @@ export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
82
88
  mkdir: (a, cwd) => mkdirTool(a.dirPath ?? a.path ?? a.dir, cwd),
83
89
  delete: (a, cwd) => deleteTool(a.path ?? a.filePath ?? a.targetPath ?? a.dirPath, cwd, !!(a.recursive ?? a.r)),
84
90
  web_search: (a, cwd) => webSearchTool(a, cwd),
85
- computer: (a) => executeComputerAction(a as any),
91
+ // Interactive-loop parity with the one-shot `jeo computer` CLI (see
92
+ // src/commands/computer.ts's runComputerCommand): arm the fail-closed
93
+ // supervisor and refresh its heartbeat immediately before EVERY call, so the
94
+ // kill-switch/heartbeat gate inside executeComputerAction passes for an
95
+ // actively-invoked tool call — it is never "always on" between calls.
96
+ computer: (a) => {
97
+ computerSupervisor.setKillSwitchLive(true);
98
+ computerSupervisor.heartbeat();
99
+ return executeComputerAction(a as any);
100
+ },
101
+ ast_grep: createAstGrepTool(),
102
+ ast_edit: createAstEditTool(),
103
+ lsp: createLspTool(),
104
+ lsp_rename: createLspRenameTool(),
105
+ debug: createDebugTool(),
86
106
  };
87
107
  /** Tool-protocol description injected into the system prompt. */
88
108
  export const TOOL_PROTOCOL = [
@@ -98,7 +118,12 @@ export const TOOL_PROTOCOL = [
98
118
  "9. delete {path, recursive?} — remove a file (or directory with recursive:true)",
99
119
  "10. web_search {query, recency?, limit?} — search the web (Anthropic-native: synthesized answer + sources + citations)",
100
120
  "11. computer {action, x?, y?, text?, key?, deltaX?, deltaY?, duration?, actions?} — execute desktop automation actions (screenshot, click, double_click, move, drag, scroll, type, keypress, wait, batch)",
101
- "12. done {reason?} — call when the task is fully implemented AND verified",
121
+ "12. ast_grep {pattern, paths} — structural TypeScript/JavaScript search: $NAME captures one node (repeats must match identical code), $_ is a wildcard node, $$$NAME/$$$ capture/ignore zero-or-more sibling nodes in a list (call args, statements, class members, ...)",
122
+ "13. ast_edit {pattern, replacement, paths} — structural TypeScript/JavaScript rewrite; same pattern metavariables, 'replacement' substitutes $NAME/$$$NAME with captured text (empty replacement deletes matches)",
123
+ "14. lsp {action, file, line?, symbol?} — TypeScript/JavaScript language-service queries (definition/references/hover/symbols/diagnostics); 'symbol' resolves the column on 'line' (append #N for the Nth occurrence)",
124
+ "15. lsp_rename {file, line, symbol?, new_name, apply?} — cross-file TypeScript/JavaScript rename (apply defaults true; apply:false previews only)",
125
+ "16. debug {action, ...} — Node.js debugging (launch/set_breakpoint/continue/step_over/step_in/step_out/pause/evaluate/stack_trace/scopes/variables/threads/output/terminate); one active session, 'launch' spawns 'node --inspect-brk' and pauses at start",
126
+ "17. done {reason?} — call when the task is fully implemented AND verified",
102
127
  "",
103
128
  "Reply with STRICT JSON only — no code fences. You MAY include an optional leading",
104
129
  '"reasoning" string (one short sentence on your plan) before "tool":',
@@ -120,9 +145,11 @@ export const READONLY_TOOL_PROTOCOL = [
120
145
  "1. read {filePath, lineRange?} — read a file (lineRange: \"a-b\", \"a-\", \"a\", \"a+n\", or multi \"a-b,c-d\")",
121
146
  "2. find {globPattern} — find files by name",
122
147
  "3. search {pattern, globPattern?, ignoreCase?} — grep for a pattern",
123
- "4. ls {dirPath} — list a directory's entries",
124
- "5. web_search {query, recency?, limit?} — search the web (answer + sources + citations)",
125
- "6. done {reason?} — call when your review/analysis is complete",
148
+ "4. ast_grep {pattern, paths} — structural TypeScript/JavaScript search ($NAME/$_/$$$NAME metavariables — see ast_grep for full syntax)",
149
+ "5. lsp {action, file, line?, symbol?} — TypeScript/JavaScript definition/references/hover/symbols/diagnostics (read-only; cross-file rename is not available to this role)",
150
+ "6. ls {dirPath} — list a directory's entries",
151
+ "7. web_search {query, recency?, limit?} — search the web (answer + sources + citations)",
152
+ "8. done {reason?} — call when your review/analysis is complete",
126
153
  "",
127
154
  "Reply with STRICT JSON only — no prose, no code fences:",
128
155
  '{ "tool": "<name>", "arguments": { ... } }',
@@ -441,6 +468,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
441
468
  let lastMutationSeq = 0;
442
469
  let lastVerificationSeq = 0;
443
470
  let donePushbackUsed = false;
471
+ // Abuse guard for the done-pushback escape hatch: the "second done always
472
+ // passes" latch below is meant for turns where verification is genuinely
473
+ // not applicable (docs/config-only changes) — not for a model that just
474
+ // resends `done` immediately with zero intervening action. Reset to false
475
+ // when the first pushback fires; any executed tool step (even read-only)
476
+ // sets it back to true, so the escape hatch requires at least one attempt.
477
+ let sawActionSincePushback = true;
444
478
  // Caller-owned done gate (onBeforeDone) — also strictly once per turn.
445
479
  let beforeDoneNudgeUsed = false;
446
480
  // F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
@@ -545,14 +579,39 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
545
579
  // (a TUI). Non-interactive/test callers leave onModelStream unset → a single
546
580
  // non-streaming call(), unchanged. The accumulated text is still parsed as one JSON
547
581
  // tool call below, so streaming changes nothing about loop semantics.
582
+ //
583
+ // Emission throttle (~80ms, mirroring the bash live sink): every delta still
584
+ // ACCUMULATES, but re-emitting the full growing buffer per token made the handoff
585
+ // O(len²) over a long response — each emit flattens the rope and the TUI re-scans
586
+ // its head. Timer-less by design; flushStreams() below guarantees the consumers'
587
+ // LAST snapshot is the complete text before it is parsed/committed.
588
+ const STREAM_EMIT_MS = 80;
548
589
  let streamBuf = "";
590
+ let lastModelEmit = 0;
549
591
  const onToken = ev.onModelStream
550
- ? (delta: string) => { streamBuf += delta; ev.onModelStream!(streamBuf); }
592
+ ? (delta: string) => {
593
+ streamBuf += delta;
594
+ const now = Date.now();
595
+ if (now - lastModelEmit >= STREAM_EMIT_MS) { lastModelEmit = now; ev.onModelStream!(streamBuf); }
596
+ }
551
597
  : undefined;
552
598
  let reasonBuf = "";
599
+ let lastReasonEmit = 0;
553
600
  const onReasoning = ev.onReasoningStream
554
- ? (delta: string) => { reasonBuf += delta; ev.onReasoningStream!(reasonBuf); }
601
+ ? (delta: string) => {
602
+ reasonBuf += delta;
603
+ const now = Date.now();
604
+ if (now - lastReasonEmit >= STREAM_EMIT_MS) { lastReasonEmit = now; ev.onReasoningStream!(reasonBuf); }
605
+ }
555
606
  : undefined;
607
+ // The TUI commits its thought/plan blocks from the LAST received snapshot
608
+ // (onAssistant); emit the complete buffers once so a throttled trailing delta
609
+ // can never be dropped from the committed record. Re-emitting an identical
610
+ // snapshot is idempotent for the consumers (they diff before drawing).
611
+ const flushStreams = () => {
612
+ if (streamBuf) ev.onModelStream?.(streamBuf);
613
+ if (reasonBuf) ev.onReasoningStream?.(reasonBuf);
614
+ };
556
615
  // Capture provider-native reasoning ARTIFACTS for replay (always — independent of any
557
616
  // TUI display sink). Stays scoped to THIS step so a later consolidation push can't
558
617
  // inherit a prior step's signatures.
@@ -687,6 +746,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
687
746
  // separate error event here printed the same message twice (live stream + reply).
688
747
  return finish({ done: false, steps: step, doneReason: `Error: ${message}` });
689
748
  }
749
+ flushStreams(); // deliver the final (possibly throttled-out) snapshot before parse/commit
690
750
  if (sawUsage) ev.onUsage?.({ ...acc });
691
751
 
692
752
  let invocation: any;
@@ -780,12 +840,32 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
780
840
  verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
781
841
  pendingHookFailure,
782
842
  });
783
- if (doneGate.block && !donePushbackUsed) {
784
- donePushbackUsed = true; // second done always passes — escape hatch
785
- pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
786
- history.push({ role: "user", content: doneGate.message });
787
- step++;
788
- continue;
843
+ if (doneGate.block) {
844
+ if (!donePushbackUsed) {
845
+ donePushbackUsed = true;
846
+ sawActionSincePushback = false; // require a real attempt before the 2nd done is honored
847
+ pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
848
+ history.push({ role: "user", content: doneGate.message });
849
+ step++;
850
+ continue;
851
+ }
852
+ if (!sawActionSincePushback) {
853
+ // Abuse guard: the model re-sent `done` immediately with zero intervening
854
+ // tool calls — not the intended "verification genuinely not applicable"
855
+ // escape hatch. Bounce once more instead of silently accepting it.
856
+ pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
857
+ history.push({
858
+ role: "user",
859
+ content:
860
+ `${doneGate.message}\n\nYou called 'done' again without taking any further action. ` +
861
+ `Either run the relevant verification (test/build/lint) or take another concrete step, ` +
862
+ `then call done — do not resend 'done' unchanged.`,
863
+ });
864
+ step++;
865
+ continue;
866
+ }
867
+ // donePushbackUsed && sawActionSincePushback: the model made at least one
868
+ // real attempt since the pushback — honor the escape hatch.
789
869
  }
790
870
  // Caller-owned done gate (e.g. stale-todo reconciliation): ONE bounded
791
871
  // bounce, then any later done passes — field case: a 28-step turn ended
@@ -837,10 +917,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
837
917
  // after a SUCCESSFUL write because nothing ever told the model to stop
838
918
  // repeating — a recovery prompt resolves that without killing the turn.
839
919
  // - 3rd identical step (repeated through the explicit correction) → stop.
840
- const callSigs = toolCalls.map(c => `${c.tool}:${JSON.stringify(c.arguments ?? {})}`);
841
- // Fixed-size digest of the whole step — `write` signatures embed entire file
842
- // bodies, so the repeat/cycle guards compare digests, not megabyte strings.
843
- const sig = hashSignature(callSigs.join(" | "));
920
+ // Per-call fixed-size digests — `write` arguments embed entire file bodies, so
921
+ // every guard below compares/records digests, never megabyte strings. Hashing
922
+ // once per call here (instead of joining full sigs, hashing, then re-hashing
923
+ // inside budget.record) avoids re-scanning a large write body 3× per step.
924
+ const callSigs = toolCalls.map(c => hashSignature(`${c.tool}:${JSON.stringify(c.arguments ?? {})}`));
925
+ const sig = callSigs.length === 1 ? callSigs[0] : hashSignature(callSigs.join(" | "));
844
926
  if (sig === lastSig) repeatCount++;
845
927
  else {
846
928
  repeatCount = 1;
@@ -1145,7 +1227,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1145
1227
  // here, so this always holds the last REAL execution's results.
1146
1228
  lastResults = results;
1147
1229
  // Executed tool step = progress: the stall budget clocks time WITHOUT this.
1148
- if (results.some(r => r.executed)) lastProgressAt = Date.now();
1230
+ if (results.some(r => r.executed)) {
1231
+ lastProgressAt = Date.now();
1232
+ sawActionSincePushback = true; // any real step after a pushback re-enables the done escape hatch
1233
+ }
1149
1234
  step++;
1150
1235
  }
1151
1236
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `goal` tool — lets the agent explicitly set/read/clear the session's natural-
3
+ * language stop condition, mirroring gjc's own `goal` tool. Wraps the existing
4
+ * file-persisted GoalState (src/agent/goal-verifier.ts) that the human-typed
5
+ * `/goal <condition>` slash command already writes to and that the engine's
6
+ * per-`done` verifier (src/commands/launch.ts, verifyGoal) already reads from —
7
+ * this tool just gives the MODEL the same lever, not a new state store.
8
+ */
9
+ import type { ToolHandler } from "./engine";
10
+ import type { ToolResult } from "./tools";
11
+ import { readGoalState, writeGoalState, clearGoalState, type GoalState } from "./goal-verifier";
12
+
13
+ /** One-line protocol description appended to the launch system prompt. */
14
+ export const GOAL_TOOL_PROTOCOL_LINE =
15
+ `goal {action:"set", condition} sets a natural-language stop condition for this session — ` +
16
+ `it is auto-verified against the conversation every time you call done, and a NOT_MET/IMPOSSIBLE ` +
17
+ `verdict blocks your reply and tells you what's missing. goal {action:"get"} shows the current ` +
18
+ `condition and its latest verdict. goal {action:"clear"} removes it.`;
19
+
20
+ function formatState(state: GoalState | null): string {
21
+ if (!state) return `No goal set for this session. Set one with goal {action:"set", condition}.`;
22
+ const last = state.verdicts[state.verdicts.length - 1];
23
+ const verdictLine = last
24
+ ? `Last verdict: ${last.verdict}${last.gap ? ` — ${last.gap}` : ""}`
25
+ : "No verdicts yet (verified on your next done call).";
26
+ return `Goal: "${state.condition}" (set ${new Date(state.setAt).toLocaleTimeString()})\n${verdictLine}`;
27
+ }
28
+
29
+ export function createGoalTool(): ToolHandler {
30
+ return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
31
+ const action = String(args.action ?? "get").trim().toLowerCase();
32
+
33
+ if (action === "set") {
34
+ const condition = typeof args.condition === "string" ? args.condition.trim() : "";
35
+ if (!condition) {
36
+ return { success: false, output: "", error: `goal {action:"set"} requires a non-empty 'condition'.` };
37
+ }
38
+ await writeGoalState({ condition, setAt: Date.now(), verdicts: [] }, cwd);
39
+ return {
40
+ success: true,
41
+ output: `Goal set: "${condition}". It will be auto-verified against the conversation each time you call done.`,
42
+ };
43
+ }
44
+
45
+ if (action === "clear") {
46
+ await clearGoalState(cwd);
47
+ return { success: true, output: "Goal cleared." };
48
+ }
49
+
50
+ if (action === "get") {
51
+ const state = await readGoalState(cwd);
52
+ return { success: true, output: formatState(state) };
53
+ }
54
+
55
+ return { success: false, output: "", error: `Unknown goal action '${action}'. Use set | get | clear.` };
56
+ };
57
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `irc` tool — lets the PARENT list running DETACHED subagents as "peers" and send
3
+ * a live message to one or all of them (gjc `irc` parity, scoped to an in-process
4
+ * registry: no separate peer directory, no channels — a running detached subagent
5
+ * IS a peer). Built entirely on `SubagentRegistry.running()` / `.steer()`; this file
6
+ * holds no state of its own.
7
+ *
8
+ * Delivery mechanism is identical to `subagent {action:"steer"}` — a pushed message
9
+ * sits in that subagent's registry inbox until its own agent loop drains it between
10
+ * steps (see `subagent-registry.ts` steer()/steerDrainFor()). "send to all" simply
11
+ * loops registry.steer() over every currently running id.
12
+ */
13
+ import type { ToolHandler } from "./engine";
14
+ import type { ToolResult } from "./tools";
15
+ import type { SubagentRegistry, SubagentRecord } from "./subagent-registry";
16
+
17
+ /** One-line protocol description appended to the launch system prompt. */
18
+ export const IRC_TOOL_PROTOCOL_LINE =
19
+ `irc {action:"list"|"send", to?, message?} — live peer messaging with DETACHED ` +
20
+ `subagents started with task{detached:true}. 'list' shows running subagents as peers; ` +
21
+ `'send' delivers {to, message} to one running peer (to: its id) or every running peer ` +
22
+ `(to:"all"), picked up before its next step.`;
23
+
24
+ function peerLine(rec: SubagentRecord): string {
25
+ return `- ${rec.id} (${rec.role}) · ${rec.task}`;
26
+ }
27
+
28
+ export function createIrcTool(registry: SubagentRegistry): ToolHandler {
29
+ return async (args: Record<string, any>, _cwd: string): Promise<ToolResult> => {
30
+ const action = String(args.action ?? "list").trim().toLowerCase();
31
+
32
+ if (action === "list") {
33
+ const peers = registry.running();
34
+ if (peers.length === 0) {
35
+ return { success: true, output: "No running subagent peers. Launch one with task {detached:true}." };
36
+ }
37
+ return { success: true, output: peers.map(peerLine).join("\n") };
38
+ }
39
+
40
+ if (action === "send") {
41
+ const message = typeof args.message === "string" ? args.message : typeof args.text === "string" ? args.text : "";
42
+ const to = typeof args.to === "string" ? args.to.trim() : "";
43
+ if (!message.trim()) {
44
+ return { success: false, output: "", error: "irc 'send' requires a non-empty 'message' (or 'text')." };
45
+ }
46
+ if (!to) {
47
+ return { success: false, output: "", error: "irc 'send' requires a 'to' target: a running subagent id, or \"all\"." };
48
+ }
49
+
50
+ if (to === "all") {
51
+ const peers = registry.running();
52
+ let sent = 0;
53
+ for (const peer of peers) {
54
+ if (registry.steer(peer.id, message)) sent++;
55
+ }
56
+ if (sent === 0) {
57
+ return { success: false, output: "", error: "No running subagent peers to send to." };
58
+ }
59
+ return { success: true, output: `Sent to ${sent} running peer(s).` };
60
+ }
61
+
62
+ const ok = registry.steer(to, message);
63
+ if (!ok) {
64
+ return { success: false, output: "", error: `No running subagent peer '${to}'.` };
65
+ }
66
+ return { success: true, output: `Sent to ${to}.` };
67
+ }
68
+
69
+ return { success: false, output: "", error: `Unknown irc action '${action}'. Use list | send.` };
70
+ };
71
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * In-process registry for BACKGROUND shell processes (gjc `job`/async-bash parity,
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
5
+ * can keep working and later list / tail / await / cancel it via the `job` control
6
+ * tool while stdout/stderr drain into a bounded buffer in the background.
7
+ *
8
+ * Lifecycle is bounded to the turn that created the registry — `cancelAll()` on
9
+ * turn teardown guarantees no background process leaks into the next turn.
10
+ */
11
+
12
+ export type JobStatus = "running" | "exited" | "killed" | "failed";
13
+
14
+ export interface JobRecord {
15
+ /** Stable id, e.g. "job-1". */
16
+ id: string;
17
+ command: string;
18
+ cwd: string;
19
+ status: JobStatus;
20
+ startedAt: number;
21
+ finishedAt?: number;
22
+ exitCode?: number | null;
23
+ }
24
+
25
+ interface Entry {
26
+ record: JobRecord;
27
+ proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
28
+ promise: Promise<void>;
29
+ }
30
+
31
+ const OUTPUT_CAP = 20000;
32
+ const TRUNCATION_MARKER = "[…output truncated…]\n";
33
+
34
+ export class JobRegistry {
35
+ private readonly entries = new Map<string, Entry>();
36
+ private readonly buffers = new Map<string, string>();
37
+ private readonly truncated = new Set<string>();
38
+ private seq = 0;
39
+
40
+ private append(id: string, chunk: string): void {
41
+ if (!chunk) return;
42
+ let buf = (this.buffers.get(id) ?? "") + chunk;
43
+ if (buf.length > OUTPUT_CAP) {
44
+ buf = buf.slice(buf.length - OUTPUT_CAP);
45
+ if (!this.truncated.has(id)) {
46
+ this.truncated.add(id);
47
+ buf = TRUNCATION_MARKER + buf;
48
+ }
49
+ }
50
+ this.buffers.set(id, buf);
51
+ }
52
+
53
+ /** Spawn and START a background OS process; returns the (running) record immediately. */
54
+ start(command: string, cwd: string): JobRecord {
55
+ this.seq += 1;
56
+ const id = `job-${this.seq}`;
57
+ const record: JobRecord = {
58
+ id,
59
+ command,
60
+ cwd,
61
+ status: "running",
62
+ startedAt: Date.now(),
63
+ };
64
+ this.buffers.set(id, "");
65
+
66
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
67
+ try {
68
+ proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
69
+ } catch (err) {
70
+ record.status = "failed";
71
+ record.finishedAt = Date.now();
72
+ this.append(id, err instanceof Error ? err.message : String(err));
73
+ // No process handle to store — nothing further to drain or cancel.
74
+ this.entries.set(id, { record, proc: undefined as any, promise: Promise.resolve() });
75
+ return record;
76
+ }
77
+
78
+ const drain = async (stream: ReadableStream<Uint8Array>) => {
79
+ const decoder = new TextDecoder();
80
+ for await (const chunk of stream) {
81
+ this.append(id, decoder.decode(chunk, { stream: true }));
82
+ }
83
+ };
84
+
85
+ const promise = (async () => {
86
+ try {
87
+ await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
88
+ const exitCode = await proc.exited;
89
+ if (record.status === "killed") return;
90
+ record.status = "exited";
91
+ record.exitCode = exitCode;
92
+ } catch (err) {
93
+ if (record.status === "killed") return;
94
+ record.status = "failed";
95
+ this.append(id, err instanceof Error ? err.message : String(err));
96
+ } finally {
97
+ if (record.finishedAt === undefined) record.finishedAt = Date.now();
98
+ }
99
+ })();
100
+
101
+ this.entries.set(id, { record, proc, promise });
102
+ return record;
103
+ }
104
+
105
+ list(): JobRecord[] {
106
+ return [...this.entries.values()].map(e => e.record);
107
+ }
108
+
109
+ get(id: string): JobRecord | undefined {
110
+ return this.entries.get(id)?.record;
111
+ }
112
+
113
+ running(): JobRecord[] {
114
+ return this.list().filter(r => r.status === "running");
115
+ }
116
+
117
+ /** Current buffered output for one job (empty string if the id is unknown). */
118
+ tail(id: string): string {
119
+ return this.buffers.get(id) ?? "";
120
+ }
121
+
122
+ /** Wait for the given ids. With `timeoutMs` the wait is bounded — unfinished jobs
123
+ * simply stay "running" in the returned snapshot. */
124
+ async awaitIds(ids: string[], timeoutMs?: number): Promise<JobRecord[]> {
125
+ const targets = ids
126
+ .map(id => this.entries.get(id))
127
+ .filter((e): e is Entry => e !== undefined);
128
+ const all = Promise.all(targets.map(e => e.promise)).then(() => {});
129
+ if (timeoutMs !== undefined && timeoutMs > 0) {
130
+ let handle: ReturnType<typeof setTimeout> | undefined;
131
+ const timer = new Promise<void>(resolve => {
132
+ handle = setTimeout(resolve, timeoutMs);
133
+ });
134
+ await Promise.race([all, timer]);
135
+ if (handle !== undefined) clearTimeout(handle);
136
+ } else {
137
+ await all;
138
+ }
139
+ return targets.map(e => e.record);
140
+ }
141
+
142
+ /** Cancel the given ids: SIGTERM via `proc.kill()` and mark killed. Already-terminal
143
+ * records are returned unchanged. */
144
+ cancel(ids: string[]): JobRecord[] {
145
+ const out: JobRecord[] = [];
146
+ for (const id of ids) {
147
+ const e = this.entries.get(id);
148
+ if (!e) continue;
149
+ if (e.record.status === "running") {
150
+ e.record.status = "killed";
151
+ e.record.finishedAt = Date.now();
152
+ e.proc?.kill();
153
+ }
154
+ out.push(e.record);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /** Kill every still-running job (turn teardown / Ctrl-C). */
160
+ cancelAll(): JobRecord[] {
161
+ return this.cancel(this.running().map(r => r.id));
162
+ }
163
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `job` control tool — the parent's handle on BACKGROUND shell processes launched
3
+ * via `job {action:"start"}`. Mirrors gjc's `job`/async-bash control surface, scoped
4
+ * to an in-process registry: start, list, tail, await (optionally bounded), and cancel.
5
+ */
6
+ import type { ToolHandler } from "./engine";
7
+ import type { ToolResult } from "./tools";
8
+ import type { JobRegistry, JobRecord } from "./job-registry";
9
+
10
+ /** One-line protocol description appended to the launch system prompt. */
11
+ export const JOB_TOOL_PROTOCOL_LINE =
12
+ `job {action:"start", command, cwd?} runs a shell command in the BACKGROUND as a real ` +
13
+ `parallel OS process and returns a job id immediately. job {action:"list"|"tail"|"await"|"cancel", ` +
14
+ `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.`;
16
+
17
+ function truncate(s: string, n: number): string {
18
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
19
+ }
20
+
21
+ function elapsed(rec: JobRecord): string {
22
+ const end = rec.finishedAt ?? Date.now();
23
+ return `${Math.max(0, Math.round((end - rec.startedAt) / 1000))}s`;
24
+ }
25
+
26
+ function rowLine(rec: JobRecord): string {
27
+ return `- ${rec.id} [${rec.status.toUpperCase()}] ${elapsed(rec)} · ${truncate(rec.command, 100)}`;
28
+ }
29
+
30
+ function idsOf(args: Record<string, any>): string[] {
31
+ if (Array.isArray(args.ids)) return args.ids.map((x: unknown) => String(x));
32
+ if (args.id !== undefined) return [String(args.id)];
33
+ return [];
34
+ }
35
+
36
+ export function createJobTool(registry: JobRegistry): ToolHandler {
37
+ return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
38
+ const action = String(args.action ?? "list").trim().toLowerCase();
39
+ const ids = idsOf(args);
40
+
41
+ if (action === "start") {
42
+ const command = typeof args.command === "string" ? args.command.trim() : "";
43
+ if (!command) {
44
+ return { success: false, output: "", error: `job {action:"start"} requires a non-empty 'command'.` };
45
+ }
46
+ const resolvedCwd = typeof args.cwd === "string" && args.cwd.trim() ? args.cwd : cwd;
47
+ const rec = registry.start(command, resolvedCwd);
48
+ return {
49
+ success: true,
50
+ output: `[background] started job '${rec.id}': ${truncate(command, 120)}. It runs in the background — ` +
51
+ `keep working, then use the 'job' tool ({action:"tail"|"await"|"list"|"cancel", ids?}) to check on it.`,
52
+ };
53
+ }
54
+
55
+ if (action === "list") {
56
+ const rows = registry.list();
57
+ if (rows.length === 0) {
58
+ return { success: true, output: `No background jobs this turn. Launch one with job {action:"start", command}.` };
59
+ }
60
+ const running = rows.filter(r => r.status === "running").length;
61
+ return { success: true, output: `${rows.length} job(s), ${running} running:\n${rows.map(rowLine).join("\n")}` };
62
+ }
63
+
64
+ if (action === "tail") {
65
+ const targets = ids.length ? ids : registry.running().map(r => r.id);
66
+ if (targets.length === 0) {
67
+ return { success: true, output: "No running jobs to tail." };
68
+ }
69
+ const unknown = targets.filter(id => !registry.get(id));
70
+ if (unknown.length > 0) {
71
+ return { success: false, output: "", error: `No job matches ${unknown.join(", ")}.` };
72
+ }
73
+ const blocks = targets.map(id => {
74
+ const rec = registry.get(id)!;
75
+ const out = registry.tail(id);
76
+ return `- ${rec.id} [${rec.status.toUpperCase()}]:\n${out || "(no output yet)"}`;
77
+ });
78
+ return { success: true, output: blocks.join("\n\n") };
79
+ }
80
+
81
+ if (action === "await") {
82
+ const targets = ids.length ? ids : registry.running().map(r => r.id);
83
+ if (targets.length === 0) {
84
+ return { success: true, output: "No running jobs to await." };
85
+ }
86
+ const timeoutMs = typeof args.timeoutMs === "number" && args.timeoutMs > 0 ? args.timeoutMs : undefined;
87
+ const recs = await registry.awaitIds(targets, timeoutMs);
88
+ const stillRunning = recs.filter(r => r.status === "running").length;
89
+ const head = stillRunning > 0
90
+ ? `Awaited ${recs.length} job(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again or cancel.`
91
+ : `Awaited ${recs.length} job(s); all settled.`;
92
+ return { success: stillRunning === 0, output: `${head}\n\n${recs.map(rowLine).join("\n")}` };
93
+ }
94
+
95
+ if (action === "cancel") {
96
+ const targets = ids.length ? ids : registry.running().map(r => r.id);
97
+ if (targets.length === 0) {
98
+ return { success: true, output: "No running jobs to cancel." };
99
+ }
100
+ const recs = registry.cancel(targets);
101
+ return { success: true, output: `Cancelled ${recs.length} job(s):\n${recs.map(rowLine).join("\n")}` };
102
+ }
103
+
104
+ return { success: false, output: "", error: `Unknown job action '${action}'. Use start | list | tail | await | cancel.` };
105
+ };
106
+ }