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,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lsp_rename` tool — cross-file TypeScript/JavaScript symbol rename (gjc `lsp`
|
|
3
|
+
* `action:"rename"` parity), using the same in-process `ts.LanguageService` as the
|
|
4
|
+
* read-only `lsp` tool. MUTATING: kept as its own tool (not an action on `lsp`) so
|
|
5
|
+
* the read-only-role toolset filter — which excludes tools by NAME — cannot be
|
|
6
|
+
* bypassed by an `apply:true` argument hidden inside a nominally "read-only" tool.
|
|
7
|
+
* Writes go through the existing `writeTool`, so the mutation lock and stale-read
|
|
8
|
+
* guard apply exactly as they do for `write`/`edit`/`ast_edit`.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs/promises";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import type * as ts from "typescript";
|
|
13
|
+
import type { ToolHandler } from "./engine";
|
|
14
|
+
import type { ToolResult } from "./tools";
|
|
15
|
+
import { writeTool } from "./tools";
|
|
16
|
+
import { languageServiceFor, parseSymbolSelector, resolvePosition, lineTextAt, lineOfPosition, formatLocation } from "./ts-language-service";
|
|
17
|
+
|
|
18
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
19
|
+
export const LSP_RENAME_TOOL_PROTOCOL_LINE =
|
|
20
|
+
`lsp_rename {file, line, symbol?, new_name, apply?} — rename a TypeScript/JavaScript symbol across every ` +
|
|
21
|
+
`file in the project via the same in-process language service as 'lsp' (same file/line/symbol addressing). ` +
|
|
22
|
+
`'apply' defaults to true (writes files — mutation lock/stale-read checks apply, same as write/edit); ` +
|
|
23
|
+
`apply:false previews every location that would change without writing anything.`;
|
|
24
|
+
|
|
25
|
+
function err(message: string): ToolResult {
|
|
26
|
+
return { success: false, output: "", error: message };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
30
|
+
|
|
31
|
+
export function createLspRenameTool(): ToolHandler {
|
|
32
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
33
|
+
const fileArg = typeof args.file === "string" ? args.file.trim() : "";
|
|
34
|
+
if (!fileArg) return err(`lsp_rename requires a non-empty "file".`);
|
|
35
|
+
|
|
36
|
+
const newName = typeof args.new_name === "string" ? args.new_name.trim()
|
|
37
|
+
: typeof args.newName === "string" ? args.newName.trim() : "";
|
|
38
|
+
if (!newName) return err(`lsp_rename requires a non-empty "new_name".`);
|
|
39
|
+
if (!IDENTIFIER_RE.test(newName)) return err(`"new_name" must be a valid identifier, got '${newName}'.`);
|
|
40
|
+
|
|
41
|
+
const absPath = path.resolve(cwd, fileArg);
|
|
42
|
+
let text: string;
|
|
43
|
+
try {
|
|
44
|
+
text = await fs.readFile(absPath, "utf-8");
|
|
45
|
+
} catch {
|
|
46
|
+
return err(`Cannot read file '${fileArg}'.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lineRaw = args.line;
|
|
50
|
+
const line = typeof lineRaw === "number" ? lineRaw : parseInt(String(lineRaw ?? ""), 10);
|
|
51
|
+
if (!Number.isFinite(line) || line < 1) return err(`lsp_rename requires a positive integer "line".`);
|
|
52
|
+
|
|
53
|
+
const symbol = parseSymbolSelector(typeof args.symbol === "string" ? args.symbol : undefined);
|
|
54
|
+
const pos = resolvePosition(text, line, symbol);
|
|
55
|
+
if ("error" in pos) return err(pos.error);
|
|
56
|
+
|
|
57
|
+
const service = await languageServiceFor(cwd, absPath);
|
|
58
|
+
const renameInfo = service.getRenameInfo(absPath, pos.position, {});
|
|
59
|
+
if (!renameInfo.canRename) {
|
|
60
|
+
const reason = "localizedErrorMessage" in renameInfo ? renameInfo.localizedErrorMessage : "not a renameable symbol here";
|
|
61
|
+
return err(`Cannot rename here: ${reason}.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const locations = service.findRenameLocations(absPath, pos.position, false, false, {}) ?? [];
|
|
65
|
+
if (locations.length === 0) return { success: true, output: "No rename locations found." };
|
|
66
|
+
|
|
67
|
+
const apply = args.apply !== false; // default true
|
|
68
|
+
|
|
69
|
+
const byFile = new Map<string, ts.TextSpan[]>();
|
|
70
|
+
for (const loc of locations) {
|
|
71
|
+
const spans = byFile.get(loc.fileName) ?? [];
|
|
72
|
+
spans.push(loc.textSpan);
|
|
73
|
+
byFile.set(loc.fileName, spans);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const preview: string[] = [];
|
|
77
|
+
let totalEdits = 0;
|
|
78
|
+
for (const [fName, spans] of byFile) {
|
|
79
|
+
const rel = path.relative(cwd, fName) || fName;
|
|
80
|
+
let content: string;
|
|
81
|
+
try {
|
|
82
|
+
content = await fs.readFile(fName, "utf-8");
|
|
83
|
+
} catch {
|
|
84
|
+
return err(`Cannot read file '${rel}' while applying rename.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const span of spans) {
|
|
88
|
+
totalEdits++;
|
|
89
|
+
const line1 = lineOfPosition(content, span.start);
|
|
90
|
+
preview.push(formatLocation(rel, line1, lineTextAt(content, line1)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (apply) {
|
|
94
|
+
let newText = content;
|
|
95
|
+
for (const span of [...spans].sort((a, b) => b.start - a.start)) {
|
|
96
|
+
newText = newText.slice(0, span.start) + newName + newText.slice(span.start + span.length);
|
|
97
|
+
}
|
|
98
|
+
const res = await writeTool(rel, newText, cwd);
|
|
99
|
+
if (!res.success) return err(`${rel}: ${res.error ?? "write failed"}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const header = apply
|
|
104
|
+
? `Renamed ${totalEdits} occurrence(s) across ${byFile.size} file(s):`
|
|
105
|
+
: `Preview: ${totalEdits} occurrence(s) across ${byFile.size} file(s) would be renamed to '${newName}' (apply:false, nothing written):`;
|
|
106
|
+
return { success: true, output: `${header}\n${preview.join("\n")}` };
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lsp` tool — read-only TypeScript/JavaScript language-service queries (gjc `lsp`
|
|
3
|
+
* parity, scoped to TS/JS/TSX/JSX via the in-process `ts.LanguageService` from
|
|
4
|
+
* `ts-language-service.ts` — NOT a real LSP client/server, NOT multi-language).
|
|
5
|
+
* Safe for every subagent role. Cross-file rename (mutating) is the separate
|
|
6
|
+
* `lsp_rename` tool so the read-only-role toolset filter (which gates by tool
|
|
7
|
+
* NAME, not by argument) cannot be bypassed by a `rename` action hidden in here.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import * as ts from "typescript";
|
|
12
|
+
import type { ToolHandler } from "./engine";
|
|
13
|
+
import type { ToolResult } from "./tools";
|
|
14
|
+
import { languageServiceFor, parseSymbolSelector, resolvePosition, lineTextAt, lineOfPosition, formatLocation } from "./ts-language-service";
|
|
15
|
+
import { resolveTargetFiles } from "./ast-file-scan";
|
|
16
|
+
|
|
17
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
18
|
+
export const LSP_TOOL_PROTOCOL_LINE =
|
|
19
|
+
`lsp {action:"definition"|"references"|"hover"|"symbols"|"diagnostics", file, line?, symbol?} — ` +
|
|
20
|
+
`TypeScript/JavaScript language-service queries (in-process ts.LanguageService, not a real LSP server). ` +
|
|
21
|
+
`'symbol' is a substring on 'line' used to resolve the column automatically (append "#N" to pick the Nth ` +
|
|
22
|
+
`occurrence). 'definition'/'references'/'hover' need file+line(+symbol); 'symbols' needs only file; ` +
|
|
23
|
+
`'diagnostics' takes file (a path, glob, or "*" for the whole project) and needs no line/symbol. Cross-file ` +
|
|
24
|
+
`rename is the separate 'lsp_rename' tool.`;
|
|
25
|
+
|
|
26
|
+
async function readAbs(absPath: string): Promise<string | null> {
|
|
27
|
+
try {
|
|
28
|
+
return await fs.readFile(absPath, "utf-8");
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function err(message: string): ToolResult {
|
|
35
|
+
return { success: false, output: "", error: message };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ResolvedTarget {
|
|
39
|
+
absPath: string;
|
|
40
|
+
rel: string;
|
|
41
|
+
text: string;
|
|
42
|
+
position: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveTarget(args: Record<string, any>, cwd: string): Promise<ResolvedTarget | { error: string }> {
|
|
46
|
+
const fileArg = typeof args.file === "string" ? args.file.trim() : "";
|
|
47
|
+
if (!fileArg) return { error: `lsp requires a non-empty "file".` };
|
|
48
|
+
const absPath = path.resolve(cwd, fileArg);
|
|
49
|
+
const text = await readAbs(absPath);
|
|
50
|
+
if (text === null) return { error: `Cannot read file '${fileArg}'.` };
|
|
51
|
+
|
|
52
|
+
const lineRaw = args.line;
|
|
53
|
+
const line = typeof lineRaw === "number" ? lineRaw : parseInt(String(lineRaw ?? ""), 10);
|
|
54
|
+
if (!Number.isFinite(line) || line < 1) return { error: `lsp requires a positive integer "line".` };
|
|
55
|
+
|
|
56
|
+
const symbol = parseSymbolSelector(typeof args.symbol === "string" ? args.symbol : undefined);
|
|
57
|
+
const pos = resolvePosition(text, line, symbol);
|
|
58
|
+
if ("error" in pos) return { error: pos.error };
|
|
59
|
+
|
|
60
|
+
return { absPath, rel: path.relative(cwd, absPath) || absPath, text, position: pos.position };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MAX_REFERENCES_FULL = 50;
|
|
64
|
+
const MAX_DIAGNOSTIC_FILES = 50;
|
|
65
|
+
|
|
66
|
+
export function createLspTool(): ToolHandler {
|
|
67
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
68
|
+
const action = String(args.action ?? "").trim().toLowerCase();
|
|
69
|
+
|
|
70
|
+
if (action === "definition") {
|
|
71
|
+
const t = await resolveTarget(args, cwd);
|
|
72
|
+
if ("error" in t) return err(t.error);
|
|
73
|
+
const service = await languageServiceFor(cwd, t.absPath);
|
|
74
|
+
const result = service.getDefinitionAndBoundSpan(t.absPath, t.position);
|
|
75
|
+
const defs = result?.definitions ?? [];
|
|
76
|
+
if (defs.length === 0) return { success: true, output: "No definition found." };
|
|
77
|
+
const lines: string[] = [];
|
|
78
|
+
for (const d of defs) {
|
|
79
|
+
const dText = (await readAbs(d.fileName)) ?? "";
|
|
80
|
+
const dLine = lineOfPosition(dText, d.textSpan.start);
|
|
81
|
+
const rel = path.relative(cwd, d.fileName) || d.fileName;
|
|
82
|
+
lines.push(formatLocation(rel, dLine, lineTextAt(dText, dLine)));
|
|
83
|
+
}
|
|
84
|
+
return { success: true, output: lines.join("\n") };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (action === "references") {
|
|
88
|
+
const t = await resolveTarget(args, cwd);
|
|
89
|
+
if ("error" in t) return err(t.error);
|
|
90
|
+
const service = await languageServiceFor(cwd, t.absPath);
|
|
91
|
+
const refs = service.getReferencesAtPosition(t.absPath, t.position) ?? [];
|
|
92
|
+
if (refs.length === 0) return { success: true, output: "No references found." };
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
for (let i = 0; i < refs.length; i++) {
|
|
95
|
+
const r = refs[i]!;
|
|
96
|
+
const rel = path.relative(cwd, r.fileName) || r.fileName;
|
|
97
|
+
const rText = (await readAbs(r.fileName)) ?? "";
|
|
98
|
+
const rLine = lineOfPosition(rText, r.textSpan.start);
|
|
99
|
+
lines.push(i < MAX_REFERENCES_FULL ? formatLocation(rel, rLine, lineTextAt(rText, rLine)) : `${rel}:${rLine}`);
|
|
100
|
+
}
|
|
101
|
+
return { success: true, output: `${refs.length} reference(s):\n${lines.join("\n")}` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (action === "hover") {
|
|
105
|
+
const t = await resolveTarget(args, cwd);
|
|
106
|
+
if ("error" in t) return err(t.error);
|
|
107
|
+
const service = await languageServiceFor(cwd, t.absPath);
|
|
108
|
+
const info = service.getQuickInfoAtPosition(t.absPath, t.position);
|
|
109
|
+
if (!info) return { success: true, output: "No hover info available at that position." };
|
|
110
|
+
const signature = (info.displayParts ?? []).map(p => p.text).join("");
|
|
111
|
+
const doc = (info.documentation ?? []).map(p => p.text).join("").trim();
|
|
112
|
+
return { success: true, output: doc ? `${signature}\n\n${doc}` : signature };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (action === "symbols") {
|
|
116
|
+
const fileArg = typeof args.file === "string" ? args.file.trim() : "";
|
|
117
|
+
if (!fileArg) return err(`lsp {action:"symbols"} requires a non-empty "file".`);
|
|
118
|
+
const absPath = path.resolve(cwd, fileArg);
|
|
119
|
+
const text = await readAbs(absPath);
|
|
120
|
+
if (text === null) return err(`Cannot read file '${fileArg}'.`);
|
|
121
|
+
const service = await languageServiceFor(cwd, absPath);
|
|
122
|
+
const items = service.getNavigationBarItems(absPath);
|
|
123
|
+
const lines: string[] = [];
|
|
124
|
+
const walk = (nodes: readonly ts.NavigationBarItem[], depth: number) => {
|
|
125
|
+
for (const n of nodes) {
|
|
126
|
+
const span = n.spans[0];
|
|
127
|
+
const line1 = span ? lineOfPosition(text, span.start) : undefined;
|
|
128
|
+
lines.push(`${" ".repeat(depth)}${n.text} (${n.kind})${line1 ? ` — line ${line1}` : ""}`);
|
|
129
|
+
if (n.childItems && n.childItems.length) walk(n.childItems, depth + 1);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk(items, 0);
|
|
133
|
+
return { success: true, output: lines.length ? lines.join("\n") : "No symbols found." };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (action === "diagnostics") {
|
|
137
|
+
const fileArg = args.file;
|
|
138
|
+
const rawPaths = fileArg === "*"
|
|
139
|
+
? ["**/*"]
|
|
140
|
+
: Array.isArray(fileArg) ? fileArg.map((p: unknown) => String(p))
|
|
141
|
+
: typeof fileArg === "string" && fileArg.trim() ? [fileArg.trim()]
|
|
142
|
+
: [];
|
|
143
|
+
if (rawPaths.length === 0) {
|
|
144
|
+
return err(`lsp {action:"diagnostics"} requires a "file" (path, glob, or "*" for the whole project).`);
|
|
145
|
+
}
|
|
146
|
+
let files: string[];
|
|
147
|
+
try {
|
|
148
|
+
files = await resolveTargetFiles(rawPaths, cwd);
|
|
149
|
+
} catch (e: any) {
|
|
150
|
+
return err(e.message);
|
|
151
|
+
}
|
|
152
|
+
if (files.length === 0) return { success: true, output: "No matching TypeScript/JavaScript files." };
|
|
153
|
+
|
|
154
|
+
const scanned = files.slice(0, MAX_DIAGNOSTIC_FILES);
|
|
155
|
+
const lines: string[] = [];
|
|
156
|
+
let count = 0;
|
|
157
|
+
for (const absPath of scanned) {
|
|
158
|
+
const service = await languageServiceFor(cwd, absPath);
|
|
159
|
+
const text = (await readAbs(absPath)) ?? "";
|
|
160
|
+
const diags = [...service.getSyntacticDiagnostics(absPath), ...service.getSemanticDiagnostics(absPath)];
|
|
161
|
+
if (diags.length === 0) continue;
|
|
162
|
+
const rel = path.relative(cwd, absPath) || absPath;
|
|
163
|
+
for (const d of diags) {
|
|
164
|
+
count++;
|
|
165
|
+
const msg = ts.flattenDiagnosticMessageText(d.messageText, " ");
|
|
166
|
+
const line1 = d.start !== undefined ? lineOfPosition(text, d.start) : 0;
|
|
167
|
+
lines.push(`${rel}:${line1}: ${msg}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (count === 0) return { success: true, output: `0 diagnostics across ${scanned.length} file(s).` };
|
|
171
|
+
const note = files.length > MAX_DIAGNOSTIC_FILES ? ` (scanned first ${MAX_DIAGNOSTIC_FILES} of ${files.length} files)` : "";
|
|
172
|
+
return { success: true, output: `${count} diagnostic(s)${note}:\n${lines.join("\n")}` };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return err(`Unknown lsp action '${action}'. Use definition | references | hover | symbols | diagnostics.`);
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/agent/session.ts
CHANGED
|
@@ -316,6 +316,33 @@ export async function latestSessionId(cwd = process.cwd()): Promise<string | und
|
|
|
316
316
|
return list[0]?.id;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
/** Result of resolving a full session id or a short id prefix. */
|
|
320
|
+
export type SessionRefResolution =
|
|
321
|
+
| { kind: "ok"; id: string }
|
|
322
|
+
| { kind: "ambiguous"; matches: string[] }
|
|
323
|
+
| { kind: "not-found" };
|
|
324
|
+
|
|
325
|
+
/** Resolve a full session id OR a short id PREFIX (gjc-parity: `--resume <prefix>`,
|
|
326
|
+
* `/session resume <prefix>`) to exactly one session id. Exact match wins immediately
|
|
327
|
+
* (fast path, no directory scan) before falling back to a prefix scan of listSessions(). */
|
|
328
|
+
export async function resolveSessionRef(idOrPrefix: string, cwd = process.cwd()): Promise<SessionRefResolution> {
|
|
329
|
+
const trimmed = idOrPrefix.trim();
|
|
330
|
+
if (!trimmed) return { kind: "not-found" };
|
|
331
|
+
// Fast path: exact file exists.
|
|
332
|
+
try {
|
|
333
|
+
await fs.access(sessionPath(trimmed, cwd));
|
|
334
|
+
return { kind: "ok", id: trimmed };
|
|
335
|
+
} catch {
|
|
336
|
+
// fall through to prefix scan
|
|
337
|
+
}
|
|
338
|
+
const all = await listSessions(cwd);
|
|
339
|
+
const lower = trimmed.toLowerCase();
|
|
340
|
+
const matches = all.filter(s => s.id.toLowerCase().startsWith(lower)).map(s => s.id);
|
|
341
|
+
if (matches.length === 1) return { kind: "ok", id: matches[0]! };
|
|
342
|
+
if (matches.length > 1) return { kind: "ambiguous", matches };
|
|
343
|
+
return { kind: "not-found" };
|
|
344
|
+
}
|
|
345
|
+
|
|
319
346
|
/**
|
|
320
347
|
* Locate the session's JSONL header, apply `mutate`, and rewrite the file in place.
|
|
321
348
|
* `mutate` returns false to signal "no change needed" (skips the write). Shared by
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* `subagent` control tool. Concurrency is real (JS event loop): a detached run's
|
|
7
7
|
* awaits interleave with the parent's between steps.
|
|
8
8
|
*
|
|
9
|
+
* Detached runs also support live steering (peer messaging) via `steer()` /
|
|
10
|
+
* `steerDrainFor()` — a running parent (or another subagent, via the `irc` tool)
|
|
11
|
+
* can push a message into a running detached subagent's inbox; the subagent's own
|
|
12
|
+
* agent loop drains it between steps, same mechanism as ordinary task steering.
|
|
13
|
+
*
|
|
9
14
|
* Lifecycle is bounded to the turn that created the registry — `cancelAll()` on
|
|
10
15
|
* turn teardown guarantees no background promise leaks into the next turn.
|
|
11
16
|
*/
|
|
@@ -34,13 +39,17 @@ interface Entry {
|
|
|
34
39
|
abort: AbortController;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
/** A detached run: receives its own AbortSignal and
|
|
38
|
-
* final ToolResult. The
|
|
39
|
-
|
|
42
|
+
/** A detached run: receives its own AbortSignal and its own registry id, and
|
|
43
|
+
* resolves to the subagent's final ToolResult. The `id` param lets a runner
|
|
44
|
+
* wire up its own steer drain (e.g. `registry.steerDrainFor(id)`); runners that
|
|
45
|
+
* don't need it can ignore the 2nd param. The runner is responsible for
|
|
46
|
+
* streaming live events itself. */
|
|
47
|
+
export type DetachedRunner = (signal: AbortSignal, id: string) => Promise<ToolResult>;
|
|
40
48
|
|
|
41
49
|
export class SubagentRegistry {
|
|
42
50
|
private readonly entries = new Map<string, Entry>();
|
|
43
51
|
private readonly seq = new Map<string, number>();
|
|
52
|
+
private readonly steerInboxes = new Map<string, string[]>();
|
|
44
53
|
|
|
45
54
|
/** Register and START a detached run; returns the (running) record immediately. */
|
|
46
55
|
launch(role: string, task: string, runner: DetachedRunner): SubagentRecord {
|
|
@@ -55,9 +64,10 @@ export class SubagentRegistry {
|
|
|
55
64
|
status: "running",
|
|
56
65
|
startedAt: Date.now(),
|
|
57
66
|
};
|
|
67
|
+
this.steerInboxes.set(id, []);
|
|
58
68
|
const promise = (async () => {
|
|
59
69
|
try {
|
|
60
|
-
const res = await runner(abort.signal);
|
|
70
|
+
const res = await runner(abort.signal, id);
|
|
61
71
|
// A cancel that already fired wins — don't overwrite the terminal state.
|
|
62
72
|
if (record.status === "cancelled") return;
|
|
63
73
|
record.status = res.success ? "completed" : "failed";
|
|
@@ -69,6 +79,7 @@ export class SubagentRegistry {
|
|
|
69
79
|
record.result = err instanceof Error ? err.message : String(err);
|
|
70
80
|
} finally {
|
|
71
81
|
if (record.finishedAt === undefined) record.finishedAt = Date.now();
|
|
82
|
+
this.steerInboxes.delete(id);
|
|
72
83
|
}
|
|
73
84
|
})();
|
|
74
85
|
this.entries.set(id, { record, promise, abort });
|
|
@@ -107,6 +118,31 @@ export class SubagentRegistry {
|
|
|
107
118
|
return targets.map(e => e.record);
|
|
108
119
|
}
|
|
109
120
|
|
|
121
|
+
/** Push a live message into a running id's steer inbox (parent → detached subagent,
|
|
122
|
+
* or subagent → subagent via the `irc` tool). Returns false if the id is unknown,
|
|
123
|
+
* not running, or the (trimmed) message is empty — true once queued. */
|
|
124
|
+
steer(id: string, message: string): boolean {
|
|
125
|
+
const rec = this.get(id);
|
|
126
|
+
if (!rec || rec.status !== "running") return false;
|
|
127
|
+
const trimmed = message.trim();
|
|
128
|
+
if (!trimmed) return false;
|
|
129
|
+
const inbox = this.steerInboxes.get(id);
|
|
130
|
+
if (!inbox) return false;
|
|
131
|
+
inbox.push(trimmed);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A drain closure bound to one id — splices its inbox to empty on each call.
|
|
136
|
+
* Wire this into a detached runner's own agent-loop `steer` option so pending
|
|
137
|
+
* messages are picked up between the subagent's own steps. */
|
|
138
|
+
steerDrainFor(id: string): () => string[] {
|
|
139
|
+
return () => {
|
|
140
|
+
const inbox = this.steerInboxes.get(id);
|
|
141
|
+
if (!inbox || inbox.length === 0) return [];
|
|
142
|
+
return inbox.splice(0, inbox.length);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
110
146
|
/** Cancel the given ids (or all running, when empty): aborts the run and marks the
|
|
111
147
|
* record cancelled. Already-terminal records are returned unchanged. */
|
|
112
148
|
cancel(ids: string[]): SubagentRecord[] {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `subagent` control tool (#9) — the parent's handle on DETACHED subagents launched
|
|
3
3
|
* via `task {detached:true}`. Mirrors gjc's `subagent`/`job` control surface, scoped
|
|
4
|
-
* to an in-process registry: list, inspect, await (optionally bounded), and
|
|
4
|
+
* to an in-process registry: list, inspect, await (optionally bounded), cancel, and
|
|
5
|
+
* steer (live peer messaging into a single running subagent).
|
|
5
6
|
*
|
|
6
|
-
* Out of scope here (separate
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Out of scope here (a separate subsystem, not stubbed): pause/resume — a step-budget
|
|
8
|
+
* loop has no safe mid-step checkpoint to resume from, so that remains intentionally
|
|
9
|
+
* absent rather than faked. Live peer messaging to/among ALL running subagents (not
|
|
10
|
+
* just one target id) is the `irc` tool, built on the same registry.steer().
|
|
9
11
|
*/
|
|
10
12
|
import type { ToolHandler } from "./engine";
|
|
11
13
|
import type { ToolResult } from "./tools";
|
|
@@ -13,9 +15,10 @@ import type { SubagentRegistry, SubagentRecord } from "./subagent-registry";
|
|
|
13
15
|
|
|
14
16
|
/** One-line protocol description appended to the launch system prompt. */
|
|
15
17
|
export const SUBAGENT_TOOL_PROTOCOL_LINE =
|
|
16
|
-
`subagent {action:"list"|"inspect"|"await"|"cancel", ids?, timeoutMs?} — control DETACHED ` +
|
|
18
|
+
`subagent {action:"list"|"inspect"|"await"|"cancel"|"steer", ids?, id?, message?, timeoutMs?} — control DETACHED ` +
|
|
17
19
|
`subagents started with task{detached:true}. 'await' blocks (optionally up to timeoutMs ms) and ` +
|
|
18
|
-
`returns their reports; 'inspect' shows status + result; 'cancel' aborts them
|
|
20
|
+
`returns their reports; 'inspect' shows status + result; 'cancel' aborts them; 'steer' sends {id, message} ` +
|
|
21
|
+
`to inject a live instruction into that ONE running subagent, picked up before its next step. Omit ids to target all running (list/inspect/await/cancel).`;
|
|
19
22
|
|
|
20
23
|
function elapsed(rec: SubagentRecord): string {
|
|
21
24
|
const end = rec.finishedAt ?? Date.now();
|
|
@@ -84,6 +87,23 @@ export function createSubagentTool(registry: SubagentRegistry): ToolHandler {
|
|
|
84
87
|
return { success: true, output: `Cancelled ${recs.length} subagent(s):\n${recs.map(rowLine).join("\n")}` };
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
|
|
90
|
+
if (action === "steer") {
|
|
91
|
+
const targetIds = Array.isArray(args.id) ? [] : args.id !== undefined ? [String(args.id)] : [];
|
|
92
|
+
const message = typeof args.message === "string" ? args.message : typeof args.text === "string" ? args.text : "";
|
|
93
|
+
if (targetIds.length !== 1) {
|
|
94
|
+
return { success: false, output: "", error: `subagent 'steer' requires exactly one target 'id' (a single string, not 'ids'). Got: ${JSON.stringify(args.id)}.` };
|
|
95
|
+
}
|
|
96
|
+
const id = targetIds[0]!;
|
|
97
|
+
if (!message.trim()) {
|
|
98
|
+
return { success: false, output: "", error: "subagent 'steer' requires a non-empty 'message' (or 'text')." };
|
|
99
|
+
}
|
|
100
|
+
const ok = registry.steer(id, message);
|
|
101
|
+
if (!ok) {
|
|
102
|
+
return { success: false, output: "", error: `Cannot steer '${id}': no running subagent with that id.` };
|
|
103
|
+
}
|
|
104
|
+
return { success: true, output: `Steered ${id}: ${message.trim()}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { success: false, output: "", error: `Unknown subagent action '${action}'. Use list | inspect | await | cancel | steer.` };
|
|
88
108
|
};
|
|
89
109
|
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -252,7 +252,7 @@ export function bashCommandAllowed(command: string, prefixes: string[]): boolean
|
|
|
252
252
|
*/
|
|
253
253
|
export function subagentToolset(role: SubagentRole): Record<string, ToolHandler> {
|
|
254
254
|
if (role.readOnly) {
|
|
255
|
-
const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete"]);
|
|
255
|
+
const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete", "ast_edit", "computer", "lsp_rename", "debug"]);
|
|
256
256
|
const ro: Record<string, ToolHandler> = {};
|
|
257
257
|
for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
|
|
258
258
|
if (MUTATING.has(name)) continue;
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -78,6 +78,27 @@ const MAX_FANOUT = 4;
|
|
|
78
78
|
* parent turn. Split larger efforts into sequential task calls. */
|
|
79
79
|
const MAX_SERIAL_EXECUTOR = 6;
|
|
80
80
|
|
|
81
|
+
/** Minimum distinct, non-filler words a spawn-gate justification must contain.
|
|
82
|
+
* A bare length check (`.length >= 20`) previously accepted any 20-char string
|
|
83
|
+
* ("xxxxxxxxxxxxxxxxxxxx", "asdf asdf asdf asdf"), which defeated the intent of
|
|
84
|
+
* the gate (force the model to actually justify the parallelism, not just pay a
|
|
85
|
+
* cheap toll). This does not attempt real NLP — it only rejects the trivially
|
|
86
|
+
* degenerate cases a model would use to rubber-stamp past the gate. */
|
|
87
|
+
const JUSTIFICATION_MIN_DISTINCT_WORDS = 4;
|
|
88
|
+
|
|
89
|
+
/** True when `text` reads as an actual justification rather than filler padding
|
|
90
|
+
* used only to clear the ≥20-char length bar. */
|
|
91
|
+
function isMeaningfulJustification(text: string): boolean {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
if (trimmed.length < 20) return false;
|
|
94
|
+
// Reject strings that are one character (or one repeated short token) padded out —
|
|
95
|
+
// e.g. "xxxxxxxxxxxxxxxxxxxx" or "aaaa aaaa aaaa aaaa".
|
|
96
|
+
if (/^(.)\1*$/.test(trimmed.replace(/\s+/g, ""))) return false;
|
|
97
|
+
const words = trimmed.toLowerCase().match(/[a-z0-9']+/g) ?? [];
|
|
98
|
+
const distinct = new Set(words);
|
|
99
|
+
return distinct.size >= JUSTIFICATION_MIN_DISTINCT_WORDS;
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
/** Broadcast steering hub for a fan-out batch. Each concurrent worker registers
|
|
82
103
|
* ONCE and then sees every parent steer message exactly once (append-only log +
|
|
83
104
|
* per-worker cursor), so a mid-batch redirect reaches all running subagents
|
|
@@ -320,13 +341,13 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
320
341
|
// stays bounded at MAX_FANOUT (read-only) or 1 (mutating) regardless.
|
|
321
342
|
if (items.length > MAX_FANOUT) {
|
|
322
343
|
const justification = typeof args.justification === "string" ? args.justification.trim() : "";
|
|
323
|
-
if (justification
|
|
344
|
+
if (!isMeaningfulJustification(justification)) {
|
|
324
345
|
return {
|
|
325
346
|
success: false,
|
|
326
347
|
output: "",
|
|
327
348
|
error:
|
|
328
349
|
`Fan-out of ${items.length} tasks exceeds the default gate of ${MAX_FANOUT}. ` +
|
|
329
|
-
`Either reduce the batch, or resend with a "justification" string (≥20 chars) explaining why these tasks are independent and must run in one batch.`,
|
|
350
|
+
`Either reduce the batch, or resend with a "justification" string (≥20 chars, ≥${JUSTIFICATION_MIN_DISTINCT_WORDS} distinct words, not filler) explaining why these tasks are independent and must run in one batch.`,
|
|
330
351
|
};
|
|
331
352
|
}
|
|
332
353
|
}
|
|
@@ -371,10 +392,11 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
371
392
|
}
|
|
372
393
|
// Detached form (#9): register a background run and return immediately so the
|
|
373
394
|
// parent can keep working, then list/inspect/await/cancel via the `subagent`
|
|
374
|
-
// tool.
|
|
395
|
+
// tool. Live peer messaging (steer/irc) DOES reach a detached run — it drains
|
|
396
|
+
// its own registry inbox (registry.steerDrainFor(id)) between its own steps.
|
|
375
397
|
if (args.detached === true && opts.registry) {
|
|
376
|
-
const rec = opts.registry.launch(role.id, taskText, signal =>
|
|
377
|
-
runOne(role, taskText, ctx(args.context), cwd, { signal }),
|
|
398
|
+
const rec = opts.registry.launch(role.id, taskText, (signal, id) =>
|
|
399
|
+
runOne(role, taskText, ctx(args.context), cwd, { signal, steer: opts.registry!.steerDrainFor(id) }),
|
|
378
400
|
);
|
|
379
401
|
return {
|
|
380
402
|
success: true,
|
package/src/agent/tools.ts
CHANGED
|
@@ -226,7 +226,7 @@ function excerptForRecovery(content: string, centerLine?: number): string {
|
|
|
226
226
|
* Refreshes the snapshot to CURRENT so the immediate retry (model just saw fresh content) passes. */
|
|
227
227
|
async function staleReadError(absPath: string, filePath: string, verb: string): Promise<ToolResult | null> {
|
|
228
228
|
const snap = lastReadSnapshots.get(absPath);
|
|
229
|
-
if (!snap) return null; // never read → no guard (
|
|
229
|
+
if (!snap) return null; // never read → no guard here (see the narrow blind-replace guard in editTool)
|
|
230
230
|
const st = await fs.stat(absPath).catch(() => null);
|
|
231
231
|
if (!st || !st.isFile()) return null; // deleted/replaced-by-dir: let the op surface its own error
|
|
232
232
|
if (st.mtimeMs === snap.mtimeMs && st.size === snap.size) return null;
|
|
@@ -509,6 +509,24 @@ export async function editTool(
|
|
|
509
509
|
error: `Invalid edit range ${startLine}..${endLine}: out of bounds or reversed (file has ${lines.length} lines)`,
|
|
510
510
|
};
|
|
511
511
|
}
|
|
512
|
+
// Narrow blind-replace guard: a ≔A..B REPLACE with NO anchors on EITHER
|
|
513
|
+
// bound carries zero content verification (SEARCH/REPLACE self-verifies via
|
|
514
|
+
// the search text matching; an anchored ≔ self-verifies via the hash). If the
|
|
515
|
+
// agent never read this file THIS SESSION, it has nothing to base a blind
|
|
516
|
+
// line-range replace on — reject instead of silently overwriting unseen lines.
|
|
517
|
+
// (Insert/append directives above are unaffected: they don't destroy content.
|
|
518
|
+
// Checked AFTER the bounds check so an out-of-range request still reports as
|
|
519
|
+
// out-of-range rather than being masked by this guard.)
|
|
520
|
+
if (!aStart && !aEnd && !lastReadSnapshots.has(absPath)) {
|
|
521
|
+
return {
|
|
522
|
+
success: false,
|
|
523
|
+
output: "",
|
|
524
|
+
error:
|
|
525
|
+
`Edit rejected: ${filePath} — a line-range replace with no anchors and no prior read this session ` +
|
|
526
|
+
`has nothing to verify against. Read the file first, then retry (or include the hashline anchors ` +
|
|
527
|
+
`from read output, or use a <<<<<<< SEARCH block instead).`,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
512
530
|
const mismatch = anchorMismatch(s, aStart) ?? anchorMismatch(e, aEnd);
|
|
513
531
|
if (mismatch) return mismatch;
|
|
514
532
|
lines.splice(s - 1, e - s + 1, payload);
|
|
@@ -647,6 +665,47 @@ async function drainPipe(
|
|
|
647
665
|
return out;
|
|
648
666
|
}
|
|
649
667
|
|
|
668
|
+
// ── Destructive-bash hard block (applies to EVERY bash call, main agent and
|
|
669
|
+
// subagent alike — including the bundled `executor` role, whose bash is
|
|
670
|
+
// otherwise unconstrained since `bashAllowedPrefixes` is an opt-in allowlist
|
|
671
|
+
// no bundled role sets). This is a narrow denylist for unambiguously
|
|
672
|
+
// catastrophic, effectively-irreversible commands, not a general policy
|
|
673
|
+
// filter — everyday destructive-but-scoped commands (rm -rf node_modules,
|
|
674
|
+
// git reset --hard, force-push) stay unconstrained by design. ──
|
|
675
|
+
function stripLeadingSudo(tokens: string[]): string[] {
|
|
676
|
+
return tokens[0] === "sudo" ? tokens.slice(1) : tokens;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** True for `rm -rf /`, `rm -fr ~`, `sudo rm -Rf /`, etc. — a recursive,
|
|
680
|
+
* forced delete whose target normalizes to the filesystem root or the home
|
|
681
|
+
* directory. Scoped deletes (`rm -rf ./dist`, `rm -rf /tmp/x`) are untouched. */
|
|
682
|
+
function isRootOrHomeWipe(command: string): boolean {
|
|
683
|
+
for (const raw of command.split(/(?:&&|\|\||[;|])/)) {
|
|
684
|
+
const tokens = stripLeadingSudo(raw.trim().split(/\s+/).filter(Boolean));
|
|
685
|
+
if (tokens.length === 0) continue;
|
|
686
|
+
const cmd = tokens[0].replace(/^.*\//, "");
|
|
687
|
+
if (cmd !== "rm") continue;
|
|
688
|
+
const flagTokens = tokens.slice(1).filter(t => t.startsWith("-"));
|
|
689
|
+
const flags = flagTokens.join("");
|
|
690
|
+
if (!/r/i.test(flags) || !/f/i.test(flags)) continue;
|
|
691
|
+
const targets = tokens.slice(1).filter(t => !t.startsWith("-"));
|
|
692
|
+
for (const t of targets) {
|
|
693
|
+
const norm = t.replace(/\/+$/, "");
|
|
694
|
+
if (norm === "" || norm === "/" || norm === "~" || norm === "$HOME" || norm === "${HOME}") return true;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Returns a short human-readable reason when `command` matches a catastrophic,
|
|
701
|
+
* effectively-irreversible pattern; null when it does not. */
|
|
702
|
+
function bashDestructiveReason(command: string): string | null {
|
|
703
|
+
if (isRootOrHomeWipe(command)) return "a recursive force-delete of the filesystem root or home directory (rm -rf / or ~)";
|
|
704
|
+
if (/:\(\)\s*\{\s*:\s*\|\s*:&?\s*;?\s*\}\s*;\s*:/.test(command)) return "a fork-bomb pattern";
|
|
705
|
+
if (/\bdd\b[^\n]*\bof=\/dev\/(disk|[rs]?[hsv]d[a-z]?|nvme\w*|xvd\w*)/i.test(command)) return "a raw disk-device overwrite (dd ... of=/dev/...)";
|
|
706
|
+
if (/\bmkfs(?:\.\w+)?\s+.*\/dev\//i.test(command)) return "a filesystem format on a raw device (mkfs)";
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
650
709
|
export async function bashTool(
|
|
651
710
|
command: string,
|
|
652
711
|
cwd: string = process.cwd(),
|
|
@@ -660,6 +719,14 @@ export async function bashTool(
|
|
|
660
719
|
const fx = applyBashFixups(command);
|
|
661
720
|
command = fx.command;
|
|
662
721
|
}
|
|
722
|
+
const destructive = bashDestructiveReason(command);
|
|
723
|
+
if (destructive) {
|
|
724
|
+
return {
|
|
725
|
+
success: false,
|
|
726
|
+
output: "",
|
|
727
|
+
error: `bash rejected: this command looks like ${destructive}. If this is genuinely intended, ask the user to run it manually — it is not permitted from the agent.`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
663
730
|
try {
|
|
664
731
|
// The mutation lock is keyed on the PROJECT cwd, not the run subdir.
|
|
665
732
|
await assertBashAllowed(cwd);
|