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.
- package/CHANGELOG.md +46 -0
- package/README.ja.md +5 -3
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/package.json +3 -3
- package/scripts/install.sh +19 -0
- package/scripts/uninstall.sh +2 -2
- package/src/agent/AGENTS.md +12 -0
- package/src/agent/ast-edit-tool.ts +123 -0
- package/src/agent/ast-file-scan.ts +62 -0
- package/src/agent/ast-grep-tool.ts +107 -0
- package/src/agent/ast-match.ts +241 -0
- package/src/agent/compaction.ts +6 -19
- package/src/agent/debug-session.ts +435 -0
- package/src/agent/debug-tool.ts +89 -0
- package/src/agent/engine.ts +103 -18
- package/src/agent/goal-tool.ts +57 -0
- package/src/agent/irc-tool.ts +71 -0
- package/src/agent/job-registry.ts +163 -0
- package/src/agent/job-tool.ts +106 -0
- package/src/agent/lsp-rename-tool.ts +108 -0
- package/src/agent/lsp-tool.ts +177 -0
- package/src/agent/memory-graph.ts +6 -1
- package/src/agent/memory.ts +73 -52
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +40 -4
- package/src/agent/subagent-tool.ts +27 -7
- package/src/agent/subagents.ts +1 -1
- package/src/agent/task-tool.ts +27 -5
- package/src/agent/tools.ts +100 -5
- package/src/agent/ts-language-service.ts +173 -0
- package/src/ai/model-manager.ts +58 -26
- package/src/ai/sse.ts +22 -7
- package/src/ai/think-tags.ts +10 -1
- package/src/commands/deep-interview.ts +49 -1
- package/src/commands/launch/agents-slash.ts +489 -0
- package/src/commands/launch/flags.ts +27 -9
- package/src/commands/launch/model-slash.ts +290 -0
- package/src/commands/launch/session-slash.ts +305 -0
- package/src/commands/launch/slash-handlers.ts +6 -0
- package/src/commands/launch/system-slash.ts +81 -0
- package/src/commands/launch/tmux.ts +32 -0
- package/src/commands/launch.ts +227 -750
- package/src/tui/app.ts +32 -12
- package/src/tui/components/evolution.ts +23 -3
- package/src/tui/components/width.ts +11 -6
package/src/agent/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
124
|
-
"5.
|
|
125
|
-
"6.
|
|
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) => {
|
|
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) => {
|
|
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
|
|
784
|
-
donePushbackUsed
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
|
|
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))
|
|
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
|
+
}
|