jeo-code 0.7.31 → 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 +23 -0
- package/README.ja.md +3 -1
- package/README.ko.md +3 -1
- package/README.md +3 -1
- package/README.zh.md +3 -1
- 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/debug-session.ts +435 -0
- package/src/agent/debug-tool.ts +89 -0
- package/src/agent/engine.ts +69 -12
- 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/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 +68 -1
- package/src/agent/ts-language-service.ts +173 -0
- 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.ts +208 -749
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `debug` tool — Node.js debugging via the in-process CDP client in
|
|
3
|
+
* `debug-session.ts` (gjc `debug` parity, Node.js JS/TS ONLY — no Bun target, no
|
|
4
|
+
* DAP wire protocol, no other languages; see that file's header for the full scope
|
|
5
|
+
* statement). MUTATING: runs arbitrary user code as a real OS process, so it is
|
|
6
|
+
* excluded from read-only subagent roles the same way `bash`/`computer` are.
|
|
7
|
+
*/
|
|
8
|
+
import type { ToolHandler } from "./engine";
|
|
9
|
+
import type { ToolResult } from "./tools";
|
|
10
|
+
import { debugSession } from "./debug-session";
|
|
11
|
+
|
|
12
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
13
|
+
export const DEBUG_TOOL_PROTOCOL_LINE =
|
|
14
|
+
`debug {action, ...} — Node.js debugging (one active session; 'launch' replaces any previous one). ` +
|
|
15
|
+
`launch {program, args?, cwd?} spawns 'node --inspect-brk' and pauses at program start; set_breakpoint ` +
|
|
16
|
+
`{file, line, condition?} / remove_breakpoint {id}; continue / step_over / step_in / step_out / pause resume ` +
|
|
17
|
+
`or step and report the next pause (or program exit); evaluate {expression, frame?}; stack_trace; scopes ` +
|
|
18
|
+
`{frame?} (lists 'type: objectId'); variables {object_id}; threads; output (captured stdout/stderr so far); ` +
|
|
19
|
+
`terminate.`;
|
|
20
|
+
|
|
21
|
+
function err(message: string): ToolResult {
|
|
22
|
+
return { success: false, output: "", error: message };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createDebugTool(): ToolHandler {
|
|
26
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
27
|
+
const action = String(args.action ?? "").trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
if (action === "launch") {
|
|
30
|
+
const program = typeof args.program === "string" ? args.program.trim() : "";
|
|
31
|
+
if (!program) return err(`debug {action:"launch"} requires a non-empty "program".`);
|
|
32
|
+
const scriptArgs = Array.isArray(args.args) ? args.args.map((a: unknown) => String(a)) : [];
|
|
33
|
+
const resolvedCwd = typeof args.cwd === "string" && args.cwd.trim() ? args.cwd : cwd;
|
|
34
|
+
return debugSession.launch(program, scriptArgs, resolvedCwd);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (action === "set_breakpoint") {
|
|
38
|
+
const file = typeof args.file === "string" ? args.file.trim() : "";
|
|
39
|
+
if (!file) return err(`debug {action:"set_breakpoint"} requires a non-empty "file".`);
|
|
40
|
+
const line = typeof args.line === "number" ? args.line : parseInt(String(args.line ?? ""), 10);
|
|
41
|
+
if (!Number.isFinite(line) || line < 1) return err(`debug {action:"set_breakpoint"} requires a positive integer "line".`);
|
|
42
|
+
const condition = typeof args.condition === "string" && args.condition.trim() ? args.condition : undefined;
|
|
43
|
+
return debugSession.setBreakpoint(file, line, cwd, condition);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (action === "remove_breakpoint") {
|
|
47
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
48
|
+
if (!id) return err(`debug {action:"remove_breakpoint"} requires a non-empty "id".`);
|
|
49
|
+
return debugSession.removeBreakpoint(id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (action === "continue") return debugSession.continue();
|
|
53
|
+
if (action === "step_over") return debugSession.stepOver();
|
|
54
|
+
if (action === "step_in") return debugSession.stepIn();
|
|
55
|
+
if (action === "step_out") return debugSession.stepOut();
|
|
56
|
+
if (action === "pause") return debugSession.pause();
|
|
57
|
+
|
|
58
|
+
if (action === "evaluate") {
|
|
59
|
+
const expression = typeof args.expression === "string" ? args.expression : "";
|
|
60
|
+
if (!expression.trim()) return err(`debug {action:"evaluate"} requires a non-empty "expression".`);
|
|
61
|
+
const frame = typeof args.frame === "number" ? args.frame : 0;
|
|
62
|
+
return debugSession.evaluate(expression, frame);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (action === "stack_trace") return debugSession.stackTrace();
|
|
66
|
+
|
|
67
|
+
if (action === "scopes") {
|
|
68
|
+
const frame = typeof args.frame === "number" ? args.frame : 0;
|
|
69
|
+
const res = debugSession.scopes(frame);
|
|
70
|
+
return { success: res.success, output: res.output, error: res.error };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (action === "variables") {
|
|
74
|
+
const objectId = typeof args.object_id === "string" ? args.object_id
|
|
75
|
+
: typeof args.objectId === "string" ? args.objectId : "";
|
|
76
|
+
if (!objectId) return err(`debug {action:"variables"} requires a non-empty "object_id" (from a prior 'scopes' call).`);
|
|
77
|
+
return debugSession.variables(objectId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (action === "threads") return debugSession.threads();
|
|
81
|
+
if (action === "output") return { success: true, output: debugSession.output() || "(no output yet)" };
|
|
82
|
+
if (action === "terminate") return debugSession.terminate();
|
|
83
|
+
|
|
84
|
+
return err(
|
|
85
|
+
`Unknown debug action '${action}'. Use launch | set_breakpoint | remove_breakpoint | continue | ` +
|
|
86
|
+
`step_over | step_in | step_out | pause | evaluate | stack_trace | scopes | variables | threads | output | terminate.`,
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
}
|
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
|
|
@@ -806,12 +840,32 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
806
840
|
verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
|
|
807
841
|
pendingHookFailure,
|
|
808
842
|
});
|
|
809
|
-
if (doneGate.block
|
|
810
|
-
donePushbackUsed
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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.
|
|
815
869
|
}
|
|
816
870
|
// Caller-owned done gate (e.g. stale-todo reconciliation): ONE bounded
|
|
817
871
|
// bounce, then any later done passes — field case: a 28-step turn ended
|
|
@@ -1173,7 +1227,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1173
1227
|
// here, so this always holds the last REAL execution's results.
|
|
1174
1228
|
lastResults = results;
|
|
1175
1229
|
// Executed tool step = progress: the stall budget clocks time WITHOUT this.
|
|
1176
|
-
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
|
+
}
|
|
1177
1234
|
step++;
|
|
1178
1235
|
}
|
|
1179
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
|
+
}
|