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.
@@ -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
+ }
@@ -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 resolves to the subagent's
38
- * final ToolResult. The runner is responsible for streaming live events itself. */
39
- export type DetachedRunner = (signal: AbortSignal) => Promise<ToolResult>;
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 cancel.
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 subsystems, not stubbed): live peer messaging (IRC)
7
- * and pause/resume — a step-budget loop has no safe mid-step checkpoint to resume
8
- * from, so those are intentionally absent rather than faked.
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. Omit ids to target all running.`;
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
- return { success: false, output: "", error: `Unknown subagent action '${action}'. Use list | inspect | await | cancel.` };
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
  }
@@ -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;
@@ -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.length < 20) {
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. Steering is not forwarded to a detached run (no single active drainer).
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,
@@ -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 (back-compat; read-first is advisory)
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);