@chances-ai/engine 27.0.0 → 29.0.0
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/dist/agents/bundled.d.ts +5 -0
- package/dist/agents/bundled.d.ts.map +1 -0
- package/dist/agents/bundled.js +66 -0
- package/dist/agents/bundled.js.map +1 -0
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +1 -0
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/parse.d.ts +3 -0
- package/dist/agents/parse.d.ts.map +1 -1
- package/dist/agents/parse.js +17 -0
- package/dist/agents/parse.js.map +1 -1
- package/dist/agents/types.d.ts +8 -0
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/ai/adapters/ai-sdk-stream.js +15 -0
- package/dist/ai/adapters/ai-sdk-stream.js.map +1 -1
- package/dist/ai/types.d.ts +12 -0
- package/dist/ai/types.d.ts.map +1 -1
- package/dist/core/coordinator-mode.d.ts +32 -0
- package/dist/core/coordinator-mode.d.ts.map +1 -0
- package/dist/core/coordinator-mode.js +98 -0
- package/dist/core/coordinator-mode.js.map +1 -0
- package/dist/core/coordinator-tools.d.ts +22 -0
- package/dist/core/coordinator-tools.d.ts.map +1 -0
- package/dist/core/coordinator-tools.js +262 -0
- package/dist/core/coordinator-tools.js.map +1 -0
- package/dist/core/engine.d.ts +50 -6
- package/dist/core/engine.d.ts.map +1 -1
- package/dist/core/engine.js +107 -17
- package/dist/core/engine.js.map +1 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +3 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/task-tool.d.ts +85 -1
- package/dist/core/task-tool.d.ts.map +1 -1
- package/dist/core/task-tool.js +456 -500
- package/dist/core/task-tool.js.map +1 -1
- package/dist/hashline/apply.d.ts +9 -0
- package/dist/hashline/apply.d.ts.map +1 -0
- package/dist/hashline/apply.js +523 -0
- package/dist/hashline/apply.js.map +1 -0
- package/dist/hashline/block.d.ts +25 -0
- package/dist/hashline/block.d.ts.map +1 -0
- package/dist/hashline/block.js +71 -0
- package/dist/hashline/block.js.map +1 -0
- package/dist/hashline/format.d.ts +106 -0
- package/dist/hashline/format.d.ts.map +1 -0
- package/dist/hashline/format.js +191 -0
- package/dist/hashline/format.js.map +1 -0
- package/dist/hashline/fs.d.ts +87 -0
- package/dist/hashline/fs.d.ts.map +1 -0
- package/dist/hashline/fs.js +123 -0
- package/dist/hashline/fs.js.map +1 -0
- package/dist/hashline/index.d.ts +27 -0
- package/dist/hashline/index.d.ts.map +1 -0
- package/dist/hashline/index.js +27 -0
- package/dist/hashline/index.js.map +1 -0
- package/dist/hashline/input.d.ts +101 -0
- package/dist/hashline/input.d.ts.map +1 -0
- package/dist/hashline/input.js +378 -0
- package/dist/hashline/input.js.map +1 -0
- package/dist/hashline/messages.d.ts +87 -0
- package/dist/hashline/messages.d.ts.map +1 -0
- package/dist/hashline/messages.js +94 -0
- package/dist/hashline/messages.js.map +1 -0
- package/dist/hashline/mismatch.d.ts +45 -0
- package/dist/hashline/mismatch.d.ts.map +1 -0
- package/dist/hashline/mismatch.js +118 -0
- package/dist/hashline/mismatch.js.map +1 -0
- package/dist/hashline/normalize.d.ts +22 -0
- package/dist/hashline/normalize.d.ts.map +1 -0
- package/dist/hashline/normalize.js +31 -0
- package/dist/hashline/normalize.js.map +1 -0
- package/dist/hashline/parser.d.ts +24 -0
- package/dist/hashline/parser.d.ts.map +1 -0
- package/dist/hashline/parser.js +295 -0
- package/dist/hashline/parser.js.map +1 -0
- package/dist/hashline/patcher.d.ts +111 -0
- package/dist/hashline/patcher.d.ts.map +1 -0
- package/dist/hashline/patcher.js +332 -0
- package/dist/hashline/patcher.js.map +1 -0
- package/dist/hashline/recovery.d.ts +41 -0
- package/dist/hashline/recovery.d.ts.map +1 -0
- package/dist/hashline/recovery.js +175 -0
- package/dist/hashline/recovery.js.map +1 -0
- package/dist/hashline/snapshots.d.ts +62 -0
- package/dist/hashline/snapshots.d.ts.map +1 -0
- package/dist/hashline/snapshots.js +127 -0
- package/dist/hashline/snapshots.js.map +1 -0
- package/dist/hashline/tokenizer.d.ts +66 -0
- package/dist/hashline/tokenizer.d.ts.map +1 -0
- package/dist/hashline/tokenizer.js +408 -0
- package/dist/hashline/tokenizer.js.map +1 -0
- package/dist/hashline/types.d.ts +117 -0
- package/dist/hashline/types.d.ts.map +1 -0
- package/dist/hashline/types.js +13 -0
- package/dist/hashline/types.js.map +1 -0
- package/dist/tools/builtins/_hashline-fs.d.ts +16 -0
- package/dist/tools/builtins/_hashline-fs.d.ts.map +1 -0
- package/dist/tools/builtins/_hashline-fs.js +62 -0
- package/dist/tools/builtins/_hashline-fs.js.map +1 -0
- package/dist/tools/builtins/_image.d.ts +26 -0
- package/dist/tools/builtins/_image.d.ts.map +1 -0
- package/dist/tools/builtins/_image.js +76 -0
- package/dist/tools/builtins/_image.js.map +1 -0
- package/dist/tools/builtins/_login-shell.d.ts +17 -0
- package/dist/tools/builtins/_login-shell.d.ts.map +1 -0
- package/dist/tools/builtins/_login-shell.js +66 -0
- package/dist/tools/builtins/_login-shell.js.map +1 -0
- package/dist/tools/builtins/_notebook.d.ts +15 -0
- package/dist/tools/builtins/_notebook.d.ts.map +1 -0
- package/dist/tools/builtins/_notebook.js +81 -0
- package/dist/tools/builtins/_notebook.js.map +1 -0
- package/dist/tools/builtins/_pdf.d.ts +19 -0
- package/dist/tools/builtins/_pdf.d.ts.map +1 -0
- package/dist/tools/builtins/_pdf.js +42 -0
- package/dist/tools/builtins/_pdf.js.map +1 -0
- package/dist/tools/builtins/_shared.d.ts +11 -0
- package/dist/tools/builtins/_shared.d.ts.map +1 -1
- package/dist/tools/builtins/_shared.js +40 -1
- package/dist/tools/builtins/_shared.js.map +1 -1
- package/dist/tools/builtins/ast-edit.d.ts +18 -0
- package/dist/tools/builtins/ast-edit.d.ts.map +1 -0
- package/dist/tools/builtins/ast-edit.js +109 -0
- package/dist/tools/builtins/ast-edit.js.map +1 -0
- package/dist/tools/builtins/ast-grep.d.ts +6 -0
- package/dist/tools/builtins/ast-grep.d.ts.map +1 -0
- package/dist/tools/builtins/ast-grep.js +67 -0
- package/dist/tools/builtins/ast-grep.js.map +1 -0
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +13 -2
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +112 -31
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/read.d.ts.map +1 -1
- package/dist/tools/builtins/read.js +187 -11
- package/dist/tools/builtins/read.js.map +1 -1
- package/dist/tools/builtins.d.ts.map +1 -1
- package/dist/tools/builtins.js +4 -0
- package/dist/tools/builtins.js.map +1 -1
- package/dist/tools/file-lock.d.ts +8 -0
- package/dist/tools/file-lock.d.ts.map +1 -1
- package/dist/tools/file-lock.js +22 -0
- package/dist/tools/file-lock.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/types.d.ts +26 -0
- package/dist/tools/types.d.ts.map +1 -1
- package/dist/tools/types.js.map +1 -1
- package/package.json +8 -3
package/dist/core/task-tool.js
CHANGED
|
@@ -15,6 +15,26 @@ export const TASK_TOOL_NAME = "task";
|
|
|
15
15
|
* same well-known name. (7.5 §14.1) Exported so `buildEngine`'s isolated
|
|
16
16
|
* per-session ToolRegistry can drop+re-register the stateful pty closure. */
|
|
17
17
|
export const PTY_TOOL_NAME = "pty";
|
|
18
|
+
/** (7.8 §3.2) The coordinator's orchestration tool names. `buildChildEngine`
|
|
19
|
+
* drops these from every child's tool surface (anti-recursion): a worker MUST
|
|
20
|
+
* NOT be able to spawn / message / stop workers — multi-level orchestration is a
|
|
21
|
+
* later EXTENSION, not v1 (north-star 3.5). Defined here (not in
|
|
22
|
+
* `coordinator-tools.ts`) so `buildChildEngine` can reference them without a
|
|
23
|
+
* cycle (coordinator-tools imports `createChildAgentRuntime` from this file). */
|
|
24
|
+
export const SPAWN_WORKER_TOOL_NAME = "spawn_worker";
|
|
25
|
+
export const SEND_MESSAGE_TOOL_NAME = "send_message";
|
|
26
|
+
export const STOP_WORKER_TOOL_NAME = "stop_worker";
|
|
27
|
+
export const DISMISS_WORKER_TOOL_NAME = "dismiss_worker";
|
|
28
|
+
export const CREATE_TEAM_TOOL_NAME = "create_team";
|
|
29
|
+
export const LIST_WORKERS_TOOL_NAME = "list_workers";
|
|
30
|
+
export const COORDINATOR_TOOL_NAMES = new Set([
|
|
31
|
+
SPAWN_WORKER_TOOL_NAME,
|
|
32
|
+
SEND_MESSAGE_TOOL_NAME,
|
|
33
|
+
STOP_WORKER_TOOL_NAME,
|
|
34
|
+
DISMISS_WORKER_TOOL_NAME,
|
|
35
|
+
CREATE_TEAM_TOOL_NAME,
|
|
36
|
+
LIST_WORKERS_TOOL_NAME,
|
|
37
|
+
]);
|
|
18
38
|
/** Upper bound on the child's `maxTurns`. The parent's config-supplied value
|
|
19
39
|
* is honored; this clamp only ensures a misconfigured 1 000-turn budget
|
|
20
40
|
* doesn't let a single `task` invocation wedge the agent for hours. */
|
|
@@ -85,38 +105,311 @@ function strArg(args, key, required) {
|
|
|
85
105
|
return v;
|
|
86
106
|
}
|
|
87
107
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
108
|
+
* (7.8 §3.2 / codex R1-§3 MUST) The SHARED child-agent build — persona
|
|
109
|
+
* resolution + tool filtering + cross-provider fork gate + worktree creation +
|
|
110
|
+
* per-subagent PTY registration + child `AgentEngine` construction. Extracted
|
|
111
|
+
* verbatim from `createTaskTool.execute` so the one-shot `task` AND the
|
|
112
|
+
* persistent `spawn_worker` build IDENTICALLY (no drift). The CALLER owns the
|
|
113
|
+
* run orchestration + WHEN to finalize: the one-shot finalizes the worktree +
|
|
114
|
+
* drains PTY right after its single run; a persistent worker defers both to
|
|
115
|
+
* terminal close (else the worktree would be torn out between messages).
|
|
91
116
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
* `agentIdMode` controls event-tagging identity (the one byte-level difference
|
|
118
|
+
* between callers, preserved exactly):
|
|
119
|
+
* - `"none"` → sync one-shot: `{agentName}` (no agentId; the parent labels
|
|
120
|
+
* sync children at the tool:call/result boundary).
|
|
121
|
+
* - `"deferred"` → background one-shot: `{agentId:"", agentName}`; the caller
|
|
122
|
+
* sets `.agentId` to the registry task id after `launch()`.
|
|
123
|
+
* - `{agentId}` → persistent worker: tagged from birth.
|
|
124
|
+
*/
|
|
125
|
+
export async function buildChildEngine(deps, args, childMaxTurns, agentIdMode, creationSignal) {
|
|
126
|
+
const catalog = deps.agents;
|
|
127
|
+
const prompt = args.prompt;
|
|
128
|
+
const subagentType = args.subagentType;
|
|
129
|
+
let agent;
|
|
130
|
+
if (subagentType !== undefined && subagentType.length > 0) {
|
|
131
|
+
if (!catalog || catalog.size === 0) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
output: `subagent_type '${subagentType}' was provided but no agent catalog is configured for this session.`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
agent = catalog.get(subagentType);
|
|
138
|
+
if (!agent) {
|
|
139
|
+
const available = [...catalog.keys()].sort().join(", ");
|
|
140
|
+
return { ok: false, output: `Unknown agent type '${subagentType}'. Available: ${available}` };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Build the child registry from a single computed survivor list (drop `task`;
|
|
144
|
+
// intersect persona allow; subtract persona disallow — all fail-closed).
|
|
145
|
+
const parentNames = new Set();
|
|
146
|
+
const parentByName = new Map();
|
|
147
|
+
for (const t of deps.parentTools.list()) {
|
|
148
|
+
// Drop `task` (recursion) AND the coordinator orchestration tools — a child
|
|
149
|
+
// must not spawn/message/stop workers (anti-recursion, 7.8 §3.2).
|
|
150
|
+
if (t.name === TASK_TOOL_NAME || COORDINATOR_TOOL_NAMES.has(t.name))
|
|
151
|
+
continue;
|
|
152
|
+
parentNames.add(t.name);
|
|
153
|
+
parentByName.set(t.name, t);
|
|
154
|
+
}
|
|
155
|
+
if (agent) {
|
|
156
|
+
if (agent.tools !== "*") {
|
|
157
|
+
const unknown = agent.tools.filter((n) => !parentNames.has(n));
|
|
158
|
+
if (unknown.length > 0) {
|
|
159
|
+
const available = [...parentNames].sort().join(", ");
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
output: `Agent '${agent.name}' references unknown tool(s) in 'tools': ${unknown.join(", ")}. Available tools: ${available}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const unknownDisallowed = agent.disallowedTools.filter((n) => !parentNames.has(n));
|
|
167
|
+
if (unknownDisallowed.length > 0) {
|
|
168
|
+
const available = [...parentNames].sort().join(", ");
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
output: `Agent '${agent.name}' references unknown tool(s) in 'disallowedTools': ${unknownDisallowed.join(", ")}. Available tools: ${available}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const allowSet = agent && agent.tools !== "*" ? new Set(agent.tools) : parentNames;
|
|
176
|
+
const denySet = new Set(agent?.disallowedTools ?? []);
|
|
177
|
+
// Resolve effective isolation (input override > frontmatter > none).
|
|
178
|
+
const inputIsolation = args.isolation;
|
|
179
|
+
let isolationMode = "none";
|
|
180
|
+
if (inputIsolation !== undefined) {
|
|
181
|
+
if (inputIsolation !== "worktree" && inputIsolation !== "none") {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
output: `Invalid 'isolation' value '${inputIsolation}'. Expected 'worktree' or 'none'.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
isolationMode = inputIsolation;
|
|
188
|
+
}
|
|
189
|
+
else if (agent?.isolation === "worktree") {
|
|
190
|
+
isolationMode = "worktree";
|
|
191
|
+
}
|
|
192
|
+
const childTools = new ToolRegistry();
|
|
193
|
+
const mcpFilterActive = isolationMode === "worktree" && !agent?.unsafeAllowMutatingMcp;
|
|
194
|
+
let mcpToolsFiltered = 0;
|
|
195
|
+
for (const t of parentByName.values()) {
|
|
196
|
+
if (!allowSet.has(t.name))
|
|
197
|
+
continue;
|
|
198
|
+
if (denySet.has(t.name))
|
|
199
|
+
continue;
|
|
200
|
+
if (t.name === PTY_TOOL_NAME)
|
|
201
|
+
continue;
|
|
202
|
+
if (mcpFilterActive && isMcpToolName(t.name) && !isReadOnlyMcpCategory(t.category)) {
|
|
203
|
+
mcpToolsFiltered++;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
childTools.register(t);
|
|
207
|
+
}
|
|
208
|
+
// Resolve model override (persona model MUST resolve through the registry).
|
|
209
|
+
let childSelection;
|
|
210
|
+
if (agent?.model) {
|
|
211
|
+
if (!deps.registry) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
output: `Agent '${agent.name}' specifies model '${agent.model}' but the task tool was wired without a ModelRegistry; cannot validate.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const resolved = deps.registry.get(agent.model);
|
|
218
|
+
if (!resolved) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
output: `Agent '${agent.name}' specifies model '${agent.model}' which is not in the registry. Run 'chances doctor' to see configured providers and known models.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
childSelection = new ModelSelection({ model: agent.model });
|
|
225
|
+
}
|
|
226
|
+
const childTurnsBudget = agent?.maxTurns
|
|
227
|
+
? Math.min(Math.max(1, agent.maxTurns), CHILD_MAX_TURNS_CEILING)
|
|
228
|
+
: childMaxTurns;
|
|
229
|
+
// fork-from-parent resolution (BEFORE worktree, so a gate refusal returns
|
|
230
|
+
// cleanly with nothing to tear down).
|
|
231
|
+
const inputContext = args.context;
|
|
232
|
+
if (inputContext !== undefined && inputContext !== "fork" && inputContext !== "fresh") {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
output: `Invalid 'context' value '${inputContext}'. Expected 'fork' or 'fresh'.`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const forkRequested = inputContext === "fork";
|
|
239
|
+
let effectiveSelection = childSelection;
|
|
240
|
+
let forkedChildSession;
|
|
241
|
+
let firstPrompt = prompt;
|
|
242
|
+
if (forkRequested) {
|
|
243
|
+
if (!deps.parentSession) {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
output: "context:'fork' was requested but the task tool was wired without a parent session; fork is unavailable. Use a fresh subagent with a self-contained prompt.",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const parentChoice = deps.parentSelection?.get() ?? {};
|
|
250
|
+
const parentModel = deps.router.pick({
|
|
251
|
+
preferredModel: parentChoice.model,
|
|
252
|
+
preferredProvider: parentChoice.provider,
|
|
253
|
+
needsTools: true,
|
|
254
|
+
}).model;
|
|
255
|
+
if (!effectiveSelection) {
|
|
256
|
+
effectiveSelection = new ModelSelection({
|
|
257
|
+
provider: parentChoice.provider,
|
|
258
|
+
model: parentChoice.model,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const childChoice = effectiveSelection.get();
|
|
262
|
+
const childModel = deps.router.pick({
|
|
263
|
+
preferredModel: childChoice.model,
|
|
264
|
+
preferredProvider: childChoice.provider,
|
|
265
|
+
needsTools: true,
|
|
266
|
+
}).model;
|
|
267
|
+
if (childModel.provider !== parentModel.provider && !deps.allowCrossProviderFork) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
output: `context:'fork' would send the full conversation transcript to provider '${childModel.provider}' (model ${childModel.id}), which differs from this session's provider '${parentModel.provider}'. Set config.agent.allowCrossProviderFork=true to permit cross-provider forks, or drop the persona model override.`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
forkedChildSession = SessionManager.forkFrom(deps.parentSession, "subagent");
|
|
274
|
+
firstPrompt = buildForkDirective(prompt, isolationMode === "worktree");
|
|
275
|
+
const inheritedText = forkedChildSession.messages().map(messageToText).join("\n");
|
|
276
|
+
const toolDefsText = childTools
|
|
277
|
+
.list()
|
|
278
|
+
.map((t) => `${t.name}\n${t.description}\n${JSON.stringify(t.parameters)}`)
|
|
279
|
+
.join("\n");
|
|
280
|
+
const systemText = deps.memory?.asSystemContext() ?? "";
|
|
281
|
+
const estimate = estimateTokens(`${systemText}\n${toolDefsText}\n${inheritedText}\n${firstPrompt}`) +
|
|
282
|
+
SYSTEM_BASE_RESERVE_TOKENS;
|
|
283
|
+
const budget = deps.forkMaxContextTokens ??
|
|
284
|
+
(childModel.contextWindow
|
|
285
|
+
? Math.floor(childModel.contextWindow * FORK_WINDOW_FRACTION)
|
|
286
|
+
: CONSERVATIVE_FORK_DEFAULT);
|
|
287
|
+
if (estimate > budget) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
output: `Fork context (~${estimate} tokens, incl. tools + system) exceeds the budget (${budget} tokens) for model ${childModel.id}. Spawn a fresh subagent with an explicit prompt, /compact the conversation first, or raise config.agent.forkMaxContextTokens.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Create the worktree (cancellation flows in via `creationSignal`).
|
|
295
|
+
let worktreeHandle;
|
|
296
|
+
if (isolationMode === "worktree") {
|
|
297
|
+
try {
|
|
298
|
+
worktreeHandle = await createAgentWorktree(deps.workspaceRoot, { signal: creationSignal });
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
if (e instanceof WorktreeError) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
output: `Isolation worktree could not be created (${e.code}): ${e.message}. Hint: ensure the workspace is a git repository and 'git' is on PATH.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
throw e;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const worktreeCwd = worktreeHandle?.path;
|
|
311
|
+
const agentName = agent?.name ?? "default";
|
|
312
|
+
const agentContext = agentIdMode === "none"
|
|
313
|
+
? { agentName }
|
|
314
|
+
: agentIdMode === "deferred"
|
|
315
|
+
? { agentId: "", agentName }
|
|
316
|
+
: { agentId: agentIdMode.agentId, agentName };
|
|
317
|
+
// Per-subagent PTY instance scoped to a fresh agentId.
|
|
318
|
+
const childPtyAgentId = createId("sub");
|
|
319
|
+
if (deps.ptySessions && deps.nativeCreatePtySession) {
|
|
320
|
+
childTools.register(createPtyTool({
|
|
321
|
+
registry: deps.ptySessions,
|
|
322
|
+
agentId: childPtyAgentId,
|
|
323
|
+
agentName,
|
|
324
|
+
workspaceRoot: deps.workspaceRoot,
|
|
325
|
+
defaultCwd: worktreeCwd ?? deps.workspaceRoot,
|
|
326
|
+
worktreeCwd,
|
|
327
|
+
createHandle: deps.nativeCreatePtySession,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
const childSession = forkedChildSession ?? SessionManager.create("subagent");
|
|
331
|
+
const engine = new AgentEngine({
|
|
332
|
+
bus: deps.bus,
|
|
333
|
+
router: deps.router,
|
|
334
|
+
tools: childTools,
|
|
335
|
+
gate: deps.gate,
|
|
336
|
+
getApprovalMode: deps.getApprovalMode,
|
|
337
|
+
resolveMcpMentions: deps.resolveMcpMentions,
|
|
338
|
+
session: childSession,
|
|
339
|
+
memory: deps.memory,
|
|
340
|
+
workspaceRoot: deps.workspaceRoot,
|
|
341
|
+
maxTurns: childTurnsBudget,
|
|
342
|
+
// A subagent is non-interactive — a max-turns ceiling must FAIL (so the
|
|
343
|
+
// parent's task result surfaces it), not pause for a "continue?".
|
|
344
|
+
pauseOnMaxTurns: false,
|
|
345
|
+
suppressTerminalErrors: true,
|
|
346
|
+
suppressLifecycleEvents: true,
|
|
347
|
+
agentContext,
|
|
348
|
+
systemBaseOverride: agent?.systemPrompt,
|
|
349
|
+
selection: effectiveSelection,
|
|
350
|
+
worktreeCwd,
|
|
351
|
+
});
|
|
352
|
+
return {
|
|
353
|
+
ok: true,
|
|
354
|
+
engine,
|
|
355
|
+
agentName,
|
|
356
|
+
agentContext,
|
|
357
|
+
firstPrompt,
|
|
358
|
+
worktreeHandle,
|
|
359
|
+
childPtyAgentId,
|
|
360
|
+
mcpToolsFiltered,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* (7.8 §3.2) Wrap a built child engine into a {@link ChildAgentRuntime} for the
|
|
365
|
+
* persistent-worker `WorkerRegistry` — `runMessage` runs one message against the
|
|
366
|
+
* persistent child session (context accumulates across messages), and
|
|
367
|
+
* `finalize`/`drainPty` defer the worktree retention + PTY drain to terminal
|
|
368
|
+
* close. Returns the fork-wrapped `firstPrompt` for the registry's first mailbox
|
|
369
|
+
* entry. The minted `agentId` tags the worker's events from birth.
|
|
119
370
|
*/
|
|
371
|
+
export async function createChildAgentRuntime(deps, args, childMaxTurns, creationSignal) {
|
|
372
|
+
const agentId = createId("worker");
|
|
373
|
+
const built = await buildChildEngine(deps, args, childMaxTurns, { agentId }, creationSignal);
|
|
374
|
+
if (!built.ok)
|
|
375
|
+
return built;
|
|
376
|
+
const { engine, agentName, worktreeHandle, childPtyAgentId, mcpToolsFiltered } = built;
|
|
377
|
+
const drain = makeDrainChildPty(deps, childPtyAgentId);
|
|
378
|
+
const runtime = {
|
|
379
|
+
agentId,
|
|
380
|
+
agentName,
|
|
381
|
+
runMessage: async (prompt, signal) => {
|
|
382
|
+
const startedAt = Date.now();
|
|
383
|
+
try {
|
|
384
|
+
const result = await engine.runTurn(prompt, tokenFromSignal(signal), {
|
|
385
|
+
expandMentions: false,
|
|
386
|
+
});
|
|
387
|
+
return {
|
|
388
|
+
ok: true,
|
|
389
|
+
text: result.text.trim() || "(worker finished without producing any text)",
|
|
390
|
+
durationMs: Date.now() - startedAt,
|
|
391
|
+
tokens: { input: result.inputTokens, output: result.outputTokens },
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
catch (e) {
|
|
395
|
+
if (signal.aborted || (e instanceof AppError && e.code === ErrorCode.Cancelled)) {
|
|
396
|
+
return { ok: false, text: "Worker message cancelled", durationMs: Date.now() - startedAt };
|
|
397
|
+
}
|
|
398
|
+
const msg = e instanceof AppError
|
|
399
|
+
? `Worker failed (${e.code}): ${e.message}`
|
|
400
|
+
: `Worker failed: ${e.message || String(e)}`;
|
|
401
|
+
return { ok: false, text: msg, durationMs: Date.now() - startedAt };
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
finalize: async () => {
|
|
405
|
+
// Probe + cleanup ONCE at terminal close. Returns the retention banner
|
|
406
|
+
// (or mcp-filter notice) to surface, or undefined when nothing was kept.
|
|
407
|
+
return finalizeWorktreeBanner(worktreeHandle, deps.workspaceRoot, mcpToolsFiltered);
|
|
408
|
+
},
|
|
409
|
+
drainPty: drain,
|
|
410
|
+
};
|
|
411
|
+
return { ok: true, runtime, firstPrompt: built.firstPrompt };
|
|
412
|
+
}
|
|
120
413
|
export function createTaskTool(deps) {
|
|
121
414
|
const childMaxTurns = Math.min(Math.max(1, deps.maxTurns ?? DEFAULT_MAX_TURNS), CHILD_MAX_TURNS_CEILING);
|
|
122
415
|
const baseDescription = "Delegate a self-contained sub-task to a child agent. By default the child starts with a FRESH, empty conversation (it sees only your `prompt`) — use this when a task benefits from a clean context: focused file exploration, isolated refactor planning, scoped research. Set `context: \"fork\"` to instead give the child a copy of THIS conversation's completed history (everything you've already read/run), for \"continue this exact line of work\" tasks. The child has access to the same tools as you (except `task` itself — no recursion) and shares your permission gate, so session approvals carry through. The child runs to completion and returns its final assistant text as a single payload. Token usage / cost flow back to this session's telemetry.";
|
|
@@ -224,362 +517,47 @@ export function createTaskTool(deps) {
|
|
|
224
517
|
},
|
|
225
518
|
async execute(args, ctx) {
|
|
226
519
|
const prompt = strArg(args, "prompt", true);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (subagentType !== undefined && subagentType.length > 0) {
|
|
230
|
-
if (!catalog || catalog.size === 0) {
|
|
231
|
-
return {
|
|
232
|
-
ok: false,
|
|
233
|
-
output: `subagent_type '${subagentType}' was provided but no agent catalog is configured for this session.`,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
agent = catalog.get(subagentType);
|
|
237
|
-
if (!agent) {
|
|
238
|
-
const available = [...catalog.keys()].sort().join(", ");
|
|
239
|
-
return {
|
|
240
|
-
ok: false,
|
|
241
|
-
output: `Unknown agent type '${subagentType}'. Available: ${available}`,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
// Build the child registry from a single computed survivor list.
|
|
246
|
-
// 1. Snapshot parent → drop `task` (recursion still blocked in 3.3).
|
|
247
|
-
// 2. If persona has explicit `tools`, intersect (fail-closed on
|
|
248
|
-
// unknown names — codex Round-1 #5).
|
|
249
|
-
// 3. If persona has `disallowedTools`, subtract (same fail-closed
|
|
250
|
-
// semantics; unknown disallow name → ok:false rather than
|
|
251
|
-
// leaving the real tool reachable).
|
|
252
|
-
// 4. Construct one ToolRegistry from the final list. ToolRegistry's
|
|
253
|
-
// v1 surface only exposes `register` + `list`, so we never
|
|
254
|
-
// register-then-remove — we compute survivors first, then build.
|
|
255
|
-
const parentNames = new Set();
|
|
256
|
-
const parentByName = new Map();
|
|
257
|
-
for (const t of deps.parentTools.list()) {
|
|
258
|
-
if (t.name === TASK_TOOL_NAME)
|
|
259
|
-
continue;
|
|
260
|
-
parentNames.add(t.name);
|
|
261
|
-
parentByName.set(t.name, t);
|
|
262
|
-
}
|
|
263
|
-
if (agent) {
|
|
264
|
-
if (agent.tools !== "*") {
|
|
265
|
-
const unknown = agent.tools.filter((n) => !parentNames.has(n));
|
|
266
|
-
if (unknown.length > 0) {
|
|
267
|
-
const available = [...parentNames].sort().join(", ");
|
|
268
|
-
return {
|
|
269
|
-
ok: false,
|
|
270
|
-
output: `Agent '${agent.name}' references unknown tool(s) in 'tools': ${unknown.join(", ")}. Available tools: ${available}`,
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
const unknownDisallowed = agent.disallowedTools.filter((n) => !parentNames.has(n));
|
|
275
|
-
if (unknownDisallowed.length > 0) {
|
|
276
|
-
const available = [...parentNames].sort().join(", ");
|
|
277
|
-
return {
|
|
278
|
-
ok: false,
|
|
279
|
-
output: `Agent '${agent.name}' references unknown tool(s) in 'disallowedTools': ${unknownDisallowed.join(", ")}. Available tools: ${available}`,
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
const allowSet = agent && agent.tools !== "*"
|
|
284
|
-
? new Set(agent.tools)
|
|
285
|
-
: parentNames; // `*` or no agent → full surface (minus `task`)
|
|
286
|
-
const denySet = new Set(agent?.disallowedTools ?? []);
|
|
287
|
-
// (4.1) Resolve effective isolation mode. Order:
|
|
288
|
-
// 1) `task` tool input `isolation` (model-callable override)
|
|
289
|
-
// 2) agent-catalog frontmatter `isolation`
|
|
290
|
-
// 3) default 'none'
|
|
291
|
-
// The schema enum makes this self-documenting; we still validate
|
|
292
|
-
// the value defensively because `JSONValue` is permissive.
|
|
293
|
-
const inputIsolation = strArg(args, "isolation", false);
|
|
294
|
-
let isolationMode = "none";
|
|
295
|
-
if (inputIsolation !== undefined) {
|
|
296
|
-
if (inputIsolation !== "worktree" && inputIsolation !== "none") {
|
|
297
|
-
return {
|
|
298
|
-
ok: false,
|
|
299
|
-
output: `Invalid 'isolation' value '${inputIsolation}'. Expected 'worktree' or 'none'.`,
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
isolationMode = inputIsolation;
|
|
303
|
-
}
|
|
304
|
-
else if (agent?.isolation === "worktree") {
|
|
305
|
-
isolationMode = "worktree";
|
|
306
|
-
}
|
|
307
|
-
const childTools = new ToolRegistry();
|
|
308
|
-
const mcpFilterActive = isolationMode === "worktree" && !agent?.unsafeAllowMutatingMcp;
|
|
309
|
-
let mcpToolsFiltered = 0;
|
|
310
|
-
for (const t of parentByName.values()) {
|
|
311
|
-
if (!allowSet.has(t.name))
|
|
312
|
-
continue;
|
|
313
|
-
if (denySet.has(t.name))
|
|
314
|
-
continue;
|
|
315
|
-
// (5.1) Parent's `pty` instance is closed over the parent's
|
|
316
|
-
// agentId — registering it as-is for the child would create
|
|
317
|
-
// child sessions in the parent's ownership bucket, defeating
|
|
318
|
-
// D10 symmetric isolation and the `drainOwnedBy(childAgentId)`
|
|
319
|
-
// contract. Skip here and re-register with the child's id below.
|
|
320
|
-
if (t.name === PTY_TOOL_NAME)
|
|
321
|
-
continue;
|
|
322
|
-
// (4.1 — Round-1 MUST-FIX #2) MCP tools run in their own
|
|
323
|
-
// process; the worktree's cwd does NOT confine their writes.
|
|
324
|
-
// Inside an isolated subagent, filter MCP-source tools (named
|
|
325
|
-
// `mcp__<server>__<tool>`) to those whose declared category is
|
|
326
|
-
// in the read-only set. Unannotated tools default to
|
|
327
|
-
// `integration` which we treat as potentially mutating.
|
|
328
|
-
// Opt-out via frontmatter `unsafeAllowMutatingMcp: true`.
|
|
329
|
-
if (mcpFilterActive && isMcpToolName(t.name) && !isReadOnlyMcpCategory(t.category)) {
|
|
330
|
-
mcpToolsFiltered++;
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
childTools.register(t);
|
|
334
|
-
}
|
|
335
|
-
// Resolve model override. `agent.model` MUST resolve through the
|
|
336
|
-
// registry; misses fail closed (codex Round-1 #4).
|
|
337
|
-
let childSelection;
|
|
338
|
-
if (agent?.model) {
|
|
339
|
-
if (!deps.registry) {
|
|
340
|
-
return {
|
|
341
|
-
ok: false,
|
|
342
|
-
output: `Agent '${agent.name}' specifies model '${agent.model}' but the task tool was wired without a ModelRegistry; cannot validate.`,
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
const resolved = deps.registry.get(agent.model);
|
|
346
|
-
if (!resolved) {
|
|
347
|
-
return {
|
|
348
|
-
ok: false,
|
|
349
|
-
output: `Agent '${agent.name}' specifies model '${agent.model}' which is not in the registry. Run 'chances doctor' to see configured providers and known models.`,
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
childSelection = new ModelSelection({ model: agent.model });
|
|
353
|
-
}
|
|
354
|
-
const childTurnsBudget = agent?.maxTurns
|
|
355
|
-
? Math.min(Math.max(1, agent.maxTurns), CHILD_MAX_TURNS_CEILING)
|
|
356
|
-
: childMaxTurns;
|
|
357
|
-
// (5.5) fork-from-parent resolution. Runs BEFORE any worktree is created
|
|
358
|
-
// so a gate refusal returns cleanly with nothing to tear down. All the
|
|
359
|
-
// fork-only state (the deep-cloned child session + the model selection
|
|
360
|
-
// the child runs under) is computed once here, at execute time, so the
|
|
361
|
-
// background path's deferred `run()` closure forks the parent at SPAWN
|
|
362
|
-
// time, not at run time (D7).
|
|
363
|
-
const inputContext = strArg(args, "context", false);
|
|
364
|
-
if (inputContext !== undefined && inputContext !== "fork" && inputContext !== "fresh") {
|
|
365
|
-
return {
|
|
366
|
-
ok: false,
|
|
367
|
-
output: `Invalid 'context' value '${inputContext}'. Expected 'fork' or 'fresh'.`,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
const forkRequested = inputContext === "fork";
|
|
371
|
-
// `effectiveSelection` is what the child engine runs under: a persona
|
|
372
|
-
// `model` override always wins; a fork with no override inherits the
|
|
373
|
-
// parent's CURRENT selection (pinned now); a fresh subagent keeps today's
|
|
374
|
-
// behaviour (undefined ⇒ engine default).
|
|
375
|
-
let effectiveSelection = childSelection;
|
|
376
|
-
let forkedChildSession;
|
|
377
|
-
// (D6) The prompt the child actually receives. For a fork it is wrapped
|
|
378
|
-
// with the directive (built here so the budget estimate counts it too —
|
|
379
|
-
// it depends only on `isolationMode`, not the not-yet-created worktree);
|
|
380
|
-
// a fresh subagent passes the prompt as-is.
|
|
381
|
-
let childPrompt = prompt;
|
|
382
|
-
if (forkRequested) {
|
|
383
|
-
if (!deps.parentSession) {
|
|
384
|
-
return {
|
|
385
|
-
ok: false,
|
|
386
|
-
output: "context:'fork' was requested but the task tool was wired without a parent session; fork is unavailable. Use a fresh subagent with a self-contained prompt.",
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
// Resolve effective parent + child model descriptors through the router
|
|
390
|
-
// (same fallback the engine uses), so the budget + cross-provider gate
|
|
391
|
-
// see the model the child will actually run on.
|
|
392
|
-
const parentChoice = deps.parentSelection?.get() ?? {};
|
|
393
|
-
const parentModel = deps.router.pick({
|
|
394
|
-
preferredModel: parentChoice.model,
|
|
395
|
-
preferredProvider: parentChoice.provider,
|
|
396
|
-
needsTools: true,
|
|
397
|
-
}).model;
|
|
398
|
-
if (!effectiveSelection) {
|
|
399
|
-
// No persona override: inherit the parent's current model, pinned at
|
|
400
|
-
// execute time so a later `/model` switch (or background defer) can't
|
|
401
|
-
// move the child off the model the user forked on.
|
|
402
|
-
effectiveSelection = new ModelSelection({
|
|
403
|
-
provider: parentChoice.provider,
|
|
404
|
-
model: parentChoice.model,
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
const childChoice = effectiveSelection.get();
|
|
408
|
-
const childModel = deps.router.pick({
|
|
409
|
-
preferredModel: childChoice.model,
|
|
410
|
-
preferredProvider: childChoice.provider,
|
|
411
|
-
needsTools: true,
|
|
412
|
-
}).model;
|
|
413
|
-
// (D11) Cross-provider transcript-disclosure gate (fail-closed; not
|
|
414
|
-
// bypassable by auto-approve modes since it refuses before the gate).
|
|
415
|
-
if (childModel.provider !== parentModel.provider && !deps.allowCrossProviderFork) {
|
|
416
|
-
return {
|
|
417
|
-
ok: false,
|
|
418
|
-
output: `context:'fork' would send the full conversation transcript to provider '${childModel.provider}' (model ${childModel.id}), which differs from this session's provider '${parentModel.provider}'. Set config.agent.allowCrossProviderFork=true to permit cross-provider forks, or drop the persona model override.`,
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
// (D5 / R2 MUST-FIX) Model-aware budget. Fork the parent ONCE here (used
|
|
422
|
-
// for both the estimate and as the child's seeded session) and build the
|
|
423
|
-
// directive now so the estimate covers the SAME shape the child engine
|
|
424
|
-
// will send (`engine.ts` runTurnImpl: system + tool schemas + inherited
|
|
425
|
-
// messages + the directive-wrapped user turn) — not just the inherited
|
|
426
|
-
// history. Under-counting the tool schemas / directive would let a fork
|
|
427
|
-
// pass this guard and still overflow the model's window on its first
|
|
428
|
-
// request, which is exactly what this preflight exists to prevent.
|
|
429
|
-
forkedChildSession = SessionManager.forkFrom(deps.parentSession, "subagent");
|
|
430
|
-
childPrompt = buildForkDirective(prompt, isolationMode === "worktree");
|
|
431
|
-
const inheritedText = forkedChildSession.messages().map(messageToText).join("\n");
|
|
432
|
-
// `childTools` is fully populated here except the per-agent `pty`
|
|
433
|
-
// instance (registered in each branch below) — a one-tool undercount
|
|
434
|
-
// the window fraction + base reserve absorb. Memory context is the
|
|
435
|
-
// other model-visible system addition we can size cheaply; plan/worktree
|
|
436
|
-
// reminders + the base prompt are covered by SYSTEM_BASE_RESERVE_TOKENS.
|
|
437
|
-
const toolDefsText = childTools
|
|
438
|
-
.list()
|
|
439
|
-
.map((t) => `${t.name}\n${t.description}\n${JSON.stringify(t.parameters)}`)
|
|
440
|
-
.join("\n");
|
|
441
|
-
const systemText = deps.memory?.asSystemContext() ?? "";
|
|
442
|
-
const estimate = estimateTokens(`${systemText}\n${toolDefsText}\n${inheritedText}\n${childPrompt}`) +
|
|
443
|
-
SYSTEM_BASE_RESERVE_TOKENS;
|
|
444
|
-
const budget = deps.forkMaxContextTokens ??
|
|
445
|
-
(childModel.contextWindow
|
|
446
|
-
? Math.floor(childModel.contextWindow * FORK_WINDOW_FRACTION)
|
|
447
|
-
: CONSERVATIVE_FORK_DEFAULT);
|
|
448
|
-
if (estimate > budget) {
|
|
449
|
-
return {
|
|
450
|
-
ok: false,
|
|
451
|
-
output: `Fork context (~${estimate} tokens, incl. tools + system) exceeds the budget (${budget} tokens) for model ${childModel.id}. Spawn a fresh subagent with an explicit prompt, /compact the conversation first, or raise config.agent.forkMaxContextTokens.`,
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
// (5.1) Drain helper: called on every subagent termination
|
|
456
|
-
// path (sync return / sync throw / background return). The
|
|
457
|
-
// registry only kills sessions still owned by `childAgentId`
|
|
458
|
-
// — sessions the parent has `pty.adopt()`-ed are re-bound to
|
|
459
|
-
// the parent's agentId and are no-ops here. Failure to drain
|
|
460
|
-
// is logged once on the bus but never blocks the caller; a
|
|
461
|
-
// 2-second deadline matches `AsyncTaskRegistry.killAll` from
|
|
462
|
-
// 3.4 so engine teardown shape is uniform.
|
|
463
|
-
const drainChildPty = async (childAgentId) => {
|
|
464
|
-
if (!deps.ptySessions)
|
|
465
|
-
return;
|
|
466
|
-
try {
|
|
467
|
-
// (5.1 codex Round-2 SHOULD-FIX #1) Capture survivors and
|
|
468
|
-
// surface them on the bus so operators see when a subagent
|
|
469
|
-
// PTY ignored TERM-then-KILL past the 2 s deadline. Matches
|
|
470
|
-
// the CLI shutdown's `pty dispose` survivor log so the two
|
|
471
|
-
// teardown paths report the same shape.
|
|
472
|
-
const { survivors } = await deps.ptySessions.drainOwnedBy(childAgentId, 2_000);
|
|
473
|
-
if (survivors.length > 0) {
|
|
474
|
-
deps.bus.emit({
|
|
475
|
-
type: "log",
|
|
476
|
-
level: "warn",
|
|
477
|
-
message: `subagent ${childAgentId}: ${survivors.length} pty session(s) past 2s drain deadline: ${survivors.join(", ")}`,
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
catch (e) {
|
|
482
|
-
deps.bus.emit({
|
|
483
|
-
type: "log",
|
|
484
|
-
level: "warn",
|
|
485
|
-
message: `pty drainOwnedBy(${childAgentId}) failed: ${e.message ?? e}`,
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
};
|
|
489
|
-
// 3.4 branch: `run_in_background:true` ONLY when the registry was
|
|
490
|
-
// wired. When undefined, the field is silently ignored (codex
|
|
491
|
-
// Round-1 SHOULD-FIX #1 — see design "Registry-absent fallback").
|
|
520
|
+
// 3.4 branch: `run_in_background:true` ONLY when the registry was wired.
|
|
521
|
+
// When undefined, the field is silently ignored (codex Round-1 SHOULD #1).
|
|
492
522
|
const wantBackground = deps.backgroundTasks !== undefined && boolArg(args, "run_in_background") === true;
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
catch (e) {
|
|
510
|
-
if (e instanceof WorktreeError) {
|
|
511
|
-
return {
|
|
512
|
-
ok: false,
|
|
513
|
-
output: `Isolation worktree could not be created (${e.code}): ${e.message}. Hint: ensure the workspace is a git repository and 'git' is on PATH.`,
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
throw e;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
523
|
+
// (7.8 §3.2) Build the child agent via the SHARED helper (persona / tool
|
|
524
|
+
// filtering / fork gates / worktree / PTY / engine). The one byte-level
|
|
525
|
+
// difference between the two one-shot paths is event-tagging identity:
|
|
526
|
+
// - sync → `"none"` ({agentName}; the parent labels sync
|
|
527
|
+
// children at its own tool:call/result boundary).
|
|
528
|
+
// - background → `"deferred"` ({agentId:"", agentName}); we set
|
|
529
|
+
// `.agentId` to the registry task id after `launch()`.
|
|
530
|
+
const built = await buildChildEngine(deps, {
|
|
531
|
+
prompt,
|
|
532
|
+
subagentType: strArg(args, "subagent_type", false),
|
|
533
|
+
isolation: strArg(args, "isolation", false),
|
|
534
|
+
context: strArg(args, "context", false),
|
|
535
|
+
}, childMaxTurns, wantBackground ? "deferred" : "none", ctx.signal);
|
|
536
|
+
if (!built.ok)
|
|
537
|
+
return { ok: false, output: built.output };
|
|
538
|
+
const { engine, agentName, agentContext, firstPrompt, worktreeHandle, mcpToolsFiltered } = built;
|
|
519
539
|
const worktreeCwd = worktreeHandle?.path;
|
|
520
|
-
// (5.
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
// block, so no recompute is needed here even though the worktree itself is
|
|
524
|
-
// created later.
|
|
540
|
+
// (5.1) Drain the child's PTY sessions on every termination path. Bound
|
|
541
|
+
// to the single child PTY agentId minted inside `buildChildEngine`.
|
|
542
|
+
const drainChildPty = makeDrainChildPty(deps, built.childPtyAgentId);
|
|
525
543
|
if (wantBackground) {
|
|
526
544
|
// The registry runs `run(signal)` under its own unlinked
|
|
527
|
-
// AbortController — parent's `ctx.signal` does NOT bind.
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
// returns the handle before the registry kicks `run()` into
|
|
533
|
-
// microtask land. The child engine's `agentContext` is a SHARED
|
|
534
|
-
// mutable object — we set `.agentId` after `launch()` returns,
|
|
535
|
-
// and because `AgentEngine.emit` re-reads `ctx.agentId` per
|
|
536
|
-
// emit, the engine will pick up the registry-issued id by the
|
|
537
|
-
// time the child's first data frame fires.
|
|
538
|
-
const agentContextObj = {
|
|
539
|
-
agentId: "",
|
|
540
|
-
agentName,
|
|
541
|
-
};
|
|
542
|
-
// (5.1) PTY-ownership agentId for the child. Minted here, NOT
|
|
543
|
-
// re-using `handle.taskId` (that's pinned to AsyncTaskRegistry
|
|
544
|
-
// semantics; PTY ownership is a separate concern). The drain
|
|
545
|
-
// path at the end of `run()` calls `drainOwnedBy(ptyAgentId)`,
|
|
546
|
-
// matching sessions the model started through this subagent's
|
|
547
|
-
// `pty` tool. Adopted sessions have re-bound to the parent's
|
|
548
|
-
// agentId and are no-ops there.
|
|
549
|
-
const childPtyAgentId = createId("sub");
|
|
550
|
-
if (deps.ptySessions && deps.nativeCreatePtySession) {
|
|
551
|
-
childTools.register(createPtyTool({
|
|
552
|
-
registry: deps.ptySessions,
|
|
553
|
-
agentId: childPtyAgentId,
|
|
554
|
-
agentName,
|
|
555
|
-
workspaceRoot: deps.workspaceRoot,
|
|
556
|
-
// (5.1 codex Round-2 MUST-FIX #3) Isolated subagent's
|
|
557
|
-
// PTY commands default to the worktree path, not the
|
|
558
|
-
// parent's. Without this override, `pty.start({command:
|
|
559
|
-
// "pwd"})` from inside an isolated subagent would
|
|
560
|
-
// execute against the parent checkout.
|
|
561
|
-
defaultCwd: worktreeCwd ?? deps.workspaceRoot,
|
|
562
|
-
worktreeCwd,
|
|
563
|
-
createHandle: deps.nativeCreatePtySession,
|
|
564
|
-
}));
|
|
565
|
-
}
|
|
545
|
+
// AbortController — parent's `ctx.signal` does NOT bind. `launch()` is
|
|
546
|
+
// synchronous and returns the handle before the registry kicks `run()`
|
|
547
|
+
// into microtask land; the child engine's `agentContext` is the shared
|
|
548
|
+
// mutable object from `buildChildEngine`, so setting `.agentId` after
|
|
549
|
+
// launch lands before the child's first data frame fires.
|
|
566
550
|
try {
|
|
567
551
|
const handle = deps.backgroundTasks.launch({
|
|
568
552
|
name: agentName,
|
|
569
553
|
prompt,
|
|
570
|
-
// (5.7) Carry the worktree's RELATIVE path + branch (PII-free)
|
|
571
|
-
// the OTel exporter
|
|
572
|
-
//
|
|
554
|
+
// (5.7) Carry the worktree's RELATIVE path + branch (PII-free) for
|
|
555
|
+
// the OTel exporter. Relative to the worktree's OWN canonicalRoot
|
|
556
|
+
// (codex R2 Q4) so a caller cwd inside a pre-existing worktree
|
|
557
|
+
// can't leak an absolute-ish `..` path.
|
|
573
558
|
...(worktreeHandle
|
|
574
559
|
? {
|
|
575
560
|
worktree: {
|
|
576
|
-
// RELATIVE to the worktree's OWN `canonicalRoot` (codex R2
|
|
577
|
-
// Q4) — not `deps.workspaceRoot`, which can differ when the
|
|
578
|
-
// caller's cwd sits inside a pre-existing user worktree, in
|
|
579
|
-
// which case `relative()` could emit `..` and leak an
|
|
580
|
-
// absolute-ish path. Against canonicalRoot this is always a
|
|
581
|
-
// clean `.chances/worktrees/agent-…`. The exporter guards
|
|
582
|
-
// again defensively before stamping.
|
|
583
561
|
relativePath: relative(worktreeHandle.canonicalRoot, worktreeHandle.path),
|
|
584
562
|
branch: worktreeHandle.branch,
|
|
585
563
|
},
|
|
@@ -587,55 +565,18 @@ export function createTaskTool(deps) {
|
|
|
587
565
|
: {}),
|
|
588
566
|
run: async (signal) => {
|
|
589
567
|
const startedAt = Date.now();
|
|
590
|
-
const childSession = forkedChildSession ?? SessionManager.create("subagent");
|
|
591
|
-
const childEngine = new AgentEngine({
|
|
592
|
-
bus: deps.bus,
|
|
593
|
-
router: deps.router,
|
|
594
|
-
tools: childTools,
|
|
595
|
-
gate: deps.gate,
|
|
596
|
-
getApprovalMode: deps.getApprovalMode,
|
|
597
|
-
// (5.4 D12) inherit the parent's MCP mention resolver.
|
|
598
|
-
resolveMcpMentions: deps.resolveMcpMentions,
|
|
599
|
-
session: childSession,
|
|
600
|
-
memory: deps.memory,
|
|
601
|
-
workspaceRoot: deps.workspaceRoot,
|
|
602
|
-
maxTurns: childTurnsBudget,
|
|
603
|
-
// (7.7 §6) A subagent is non-interactive — a max-turns ceiling
|
|
604
|
-
// must FAIL (so the parent's task result surfaces it), not pause
|
|
605
|
-
// for a "continue?" no one can answer.
|
|
606
|
-
pauseOnMaxTurns: false,
|
|
607
|
-
suppressTerminalErrors: true,
|
|
608
|
-
// 3.4: background child suppresses lifecycle events
|
|
609
|
-
// (turn:start/turn:end/error) so the TUI's `busy` flag
|
|
610
|
-
// doesn't flip when the background advances a turn.
|
|
611
|
-
suppressLifecycleEvents: true,
|
|
612
|
-
systemBaseOverride: agent?.systemPrompt,
|
|
613
|
-
selection: effectiveSelection,
|
|
614
|
-
// 3.4: stamp every data-frame event the child emits
|
|
615
|
-
// with the registry-issued task id + agent name so
|
|
616
|
-
// subscribers can demultiplex parent vs. child output.
|
|
617
|
-
agentContext: agentContextObj,
|
|
618
|
-
// (4.1) Worktree isolation — child engine flips its
|
|
619
|
-
// `ToolContext.workspaceRoot`/`cwd` to the worktree
|
|
620
|
-
// path through AsyncLocalStorage.
|
|
621
|
-
worktreeCwd,
|
|
622
|
-
});
|
|
623
568
|
const childToken = tokenFromSignal(signal);
|
|
624
569
|
try {
|
|
625
|
-
const result = await
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
570
|
+
const result = await engine.runTurn(firstPrompt, childToken, {
|
|
571
|
+
expandMentions: false,
|
|
572
|
+
});
|
|
573
|
+
// (Round-2 SHOULD-FIX #2) Re-check cancellation BEFORE finalize:
|
|
574
|
+
// a `kill` after the last tool call but before here should clean
|
|
575
|
+
// up the worktree, not retain it as success.
|
|
631
576
|
if (signal.aborted) {
|
|
632
577
|
if (worktreeHandle)
|
|
633
578
|
await worktreeHandle.cleanup();
|
|
634
|
-
return {
|
|
635
|
-
ok: false,
|
|
636
|
-
text: "Subagent cancelled",
|
|
637
|
-
durationMs: Date.now() - startedAt,
|
|
638
|
-
};
|
|
579
|
+
return { ok: false, text: "Subagent cancelled", durationMs: Date.now() - startedAt };
|
|
639
580
|
}
|
|
640
581
|
const finalText = result.text.trim() || "(subagent finished without producing any text)";
|
|
641
582
|
const wrapped = await finalizeWorktree(worktreeHandle, deps.workspaceRoot, finalText, mcpToolsFiltered);
|
|
@@ -643,22 +584,14 @@ export function createTaskTool(deps) {
|
|
|
643
584
|
ok: true,
|
|
644
585
|
text: wrapped,
|
|
645
586
|
durationMs: Date.now() - startedAt,
|
|
646
|
-
tokens: {
|
|
647
|
-
input: result.inputTokens,
|
|
648
|
-
output: result.outputTokens,
|
|
649
|
-
},
|
|
587
|
+
tokens: { input: result.inputTokens, output: result.outputTokens },
|
|
650
588
|
};
|
|
651
589
|
}
|
|
652
590
|
catch (e) {
|
|
653
|
-
// Failure / cancel: tear down the worktree (idempotent).
|
|
654
591
|
if (worktreeHandle)
|
|
655
592
|
await worktreeHandle.cleanup();
|
|
656
593
|
if (e instanceof AppError && e.code === ErrorCode.Cancelled) {
|
|
657
|
-
return {
|
|
658
|
-
ok: false,
|
|
659
|
-
text: "Subagent cancelled",
|
|
660
|
-
durationMs: Date.now() - startedAt,
|
|
661
|
-
};
|
|
594
|
+
return { ok: false, text: "Subagent cancelled", durationMs: Date.now() - startedAt };
|
|
662
595
|
}
|
|
663
596
|
const msg = e instanceof AppError
|
|
664
597
|
? `Subagent failed (${e.code}): ${e.message}`
|
|
@@ -666,20 +599,17 @@ export function createTaskTool(deps) {
|
|
|
666
599
|
return { ok: false, text: msg, durationMs: Date.now() - startedAt };
|
|
667
600
|
}
|
|
668
601
|
finally {
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
|
|
673
|
-
// a `<task-notification>` for a subagent that still
|
|
674
|
-
// holds live shells in the background.
|
|
675
|
-
await drainChildPty(childPtyAgentId);
|
|
602
|
+
// Drain before the registry surfaces the completion
|
|
603
|
+
// notification, so the model never sees a `<task-notification>`
|
|
604
|
+
// for a subagent that still holds live shells.
|
|
605
|
+
await drainChildPty();
|
|
676
606
|
}
|
|
677
607
|
},
|
|
678
608
|
});
|
|
679
|
-
|
|
609
|
+
agentContext.agentId = handle.taskId;
|
|
680
610
|
const body = [
|
|
681
611
|
`(background-launched) task_id=${handle.taskId} name=${agentName}`,
|
|
682
|
-
...(
|
|
612
|
+
...(worktreeCwd && worktreeHandle
|
|
683
613
|
? [`isolation=worktree path=${relative(deps.workspaceRoot, worktreeHandle.path) || worktreeHandle.path} branch=${worktreeHandle.branch}`]
|
|
684
614
|
: []),
|
|
685
615
|
"",
|
|
@@ -688,72 +618,19 @@ export function createTaskTool(deps) {
|
|
|
688
618
|
return { ok: true, output: body };
|
|
689
619
|
}
|
|
690
620
|
catch (e) {
|
|
691
|
-
// launch() failed —
|
|
692
|
-
// down so we don't leak it into the next stale-GC pass.
|
|
621
|
+
// launch() failed — tear down any worktree so it doesn't leak.
|
|
693
622
|
if (worktreeHandle)
|
|
694
623
|
await worktreeHandle.cleanup();
|
|
695
|
-
// Capacity refusal or registry-disposed surfaces as a labeled
|
|
696
|
-
// AppError(Tool); fold it into ok:false so the model sees the
|
|
697
|
-
// recovery message.
|
|
698
624
|
if (e instanceof AppError && e.code === ErrorCode.Tool) {
|
|
699
625
|
return { ok: false, output: e.message };
|
|
700
626
|
}
|
|
701
627
|
throw e;
|
|
702
628
|
}
|
|
703
629
|
}
|
|
704
|
-
// Synchronous path.
|
|
705
|
-
// background path correctly suppresses lifecycle frames and tags
|
|
706
|
-
// data frames with the agent identity; the sync path was missed
|
|
707
|
-
// — child `turn:start`/`turn:end`/`usage:turn` flowed onto the
|
|
708
|
-
// shared bus untagged, which (a) would flip TUI `busy` state
|
|
709
|
-
// for any subscriber that toggles on the bare event, and (b)
|
|
710
|
-
// would corrupt the 3.6 OTel exporter's `Map<turnId, Span>` by
|
|
711
|
-
// opening a second `invoke_agent` span inside the parent turn.
|
|
712
|
-
// Same shape as background: suppress lifecycle, tag data frames
|
|
713
|
-
// with `{agentName}` (no agentId — sync subagents complete before
|
|
714
|
-
// the parent's `tool:call`/`tool:result` boundary closes, so the
|
|
715
|
-
// parent already labels them at that level).
|
|
716
|
-
const syncChildPtyAgentId = createId("sub");
|
|
717
|
-
if (deps.ptySessions && deps.nativeCreatePtySession) {
|
|
718
|
-
childTools.register(createPtyTool({
|
|
719
|
-
registry: deps.ptySessions,
|
|
720
|
-
agentId: syncChildPtyAgentId,
|
|
721
|
-
agentName,
|
|
722
|
-
workspaceRoot: deps.workspaceRoot,
|
|
723
|
-
// (5.1 codex Round-2 MUST-FIX #3) Same worktree-cwd default
|
|
724
|
-
// override as the background branch above.
|
|
725
|
-
defaultCwd: worktreeCwd ?? deps.workspaceRoot,
|
|
726
|
-
worktreeCwd,
|
|
727
|
-
createHandle: deps.nativeCreatePtySession,
|
|
728
|
-
}));
|
|
729
|
-
}
|
|
730
|
-
const childSession = forkedChildSession ?? SessionManager.create("subagent");
|
|
731
|
-
const child = new AgentEngine({
|
|
732
|
-
bus: deps.bus,
|
|
733
|
-
router: deps.router,
|
|
734
|
-
tools: childTools,
|
|
735
|
-
gate: deps.gate,
|
|
736
|
-
getApprovalMode: deps.getApprovalMode,
|
|
737
|
-
// (5.4 D12) inherit the parent's MCP mention resolver.
|
|
738
|
-
resolveMcpMentions: deps.resolveMcpMentions,
|
|
739
|
-
session: childSession,
|
|
740
|
-
memory: deps.memory,
|
|
741
|
-
workspaceRoot: deps.workspaceRoot,
|
|
742
|
-
maxTurns: childTurnsBudget,
|
|
743
|
-
// (7.7 §6) Non-interactive subagent → fail on the ceiling, never pause.
|
|
744
|
-
pauseOnMaxTurns: false,
|
|
745
|
-
suppressTerminalErrors: true,
|
|
746
|
-
suppressLifecycleEvents: true,
|
|
747
|
-
agentContext: { agentName },
|
|
748
|
-
systemBaseOverride: agent?.systemPrompt,
|
|
749
|
-
selection: effectiveSelection,
|
|
750
|
-
// (4.1) Worktree isolation — same wiring as the background
|
|
751
|
-
// branch above.
|
|
752
|
-
worktreeCwd,
|
|
753
|
-
});
|
|
630
|
+
// Synchronous path.
|
|
754
631
|
const childToken = tokenFromSignal(ctx.signal);
|
|
755
632
|
try {
|
|
756
|
-
const result = await
|
|
633
|
+
const result = await engine.runTurn(firstPrompt, childToken, { expandMentions: false });
|
|
757
634
|
const body = result.text.trim();
|
|
758
635
|
if (!body) {
|
|
759
636
|
if (worktreeHandle)
|
|
@@ -774,12 +651,7 @@ export function createTaskTool(deps) {
|
|
|
774
651
|
return { ok: false, output: `Subagent failed: ${e.message || String(e)}` };
|
|
775
652
|
}
|
|
776
653
|
finally {
|
|
777
|
-
|
|
778
|
-
// Same drain shape as the background branch — adopted
|
|
779
|
-
// sessions re-bound to the parent are no-ops here. Fires
|
|
780
|
-
// before this `execute` returns so the parent's next turn
|
|
781
|
-
// never sees `pty.list()` results for a defunct sync child.
|
|
782
|
-
await drainChildPty(syncChildPtyAgentId);
|
|
654
|
+
await drainChildPty();
|
|
783
655
|
}
|
|
784
656
|
},
|
|
785
657
|
};
|
|
@@ -888,6 +760,90 @@ function appendMcpFilterNotice(body, n) {
|
|
|
888
760
|
return body;
|
|
889
761
|
return `${body}\n\n[notice: ${n} mutating MCP tool(s) were hidden from this subagent due to active isolation]`;
|
|
890
762
|
}
|
|
763
|
+
/** (5.1 / 7.8 §3.2) PTY drain closure bound to a child's PTY agentId. Extracted
|
|
764
|
+
* from the prior inline `drainChildPty` closure so the one-shot `task` paths AND
|
|
765
|
+
* the persistent worker's `drainPty` share it. Kills sessions still owned by
|
|
766
|
+
* `childAgentId` (adopted-to-parent sessions are no-ops); failure is logged once
|
|
767
|
+
* on the bus, never blocks the caller; 2s deadline matches `killAll`. */
|
|
768
|
+
function makeDrainChildPty(deps, childAgentId) {
|
|
769
|
+
return async () => {
|
|
770
|
+
if (!deps.ptySessions)
|
|
771
|
+
return;
|
|
772
|
+
try {
|
|
773
|
+
const { survivors } = await deps.ptySessions.drainOwnedBy(childAgentId, 2_000);
|
|
774
|
+
if (survivors.length > 0) {
|
|
775
|
+
deps.bus.emit({
|
|
776
|
+
type: "log",
|
|
777
|
+
level: "warn",
|
|
778
|
+
message: `subagent ${childAgentId}: ${survivors.length} pty session(s) past 2s drain deadline: ${survivors.join(", ")}`,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch (e) {
|
|
783
|
+
deps.bus.emit({
|
|
784
|
+
type: "log",
|
|
785
|
+
level: "warn",
|
|
786
|
+
message: `pty drainOwnedBy(${childAgentId}) failed: ${e.message ?? e}`,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/** (7.8 §3.2) The worktree-retention decision for a PERSISTENT worker — same
|
|
792
|
+
* three-probe policy as {@link finalizeWorktree} (diff/status/unmerged-commits,
|
|
793
|
+
* fail-closed on probe error), but returns just the retention BANNER (or the
|
|
794
|
+
* mcp-filter notice) to surface, with NO body to wrap (the worker delivered its
|
|
795
|
+
* results per-message already). `undefined` ⇒ nothing kept, nothing to say. */
|
|
796
|
+
async function finalizeWorktreeBanner(handle, parentWorkspace, mcpToolsFiltered) {
|
|
797
|
+
const mcpNotice = mcpToolsFiltered > 0
|
|
798
|
+
? `[notice: ${mcpToolsFiltered} mutating MCP tool(s) were hidden from this worker due to active isolation]`
|
|
799
|
+
: undefined;
|
|
800
|
+
if (!handle)
|
|
801
|
+
return mcpNotice;
|
|
802
|
+
let shortstat = "";
|
|
803
|
+
let statusEmpty = true;
|
|
804
|
+
let unmergedCommits = 0;
|
|
805
|
+
let probeFailed = false;
|
|
806
|
+
try {
|
|
807
|
+
shortstat = await gitDiffShortstat(handle.path);
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
probeFailed = true;
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
statusEmpty = await gitStatusEmpty(handle.path);
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
probeFailed = true;
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
unmergedCommits = await gitUnmergedCommitCount(handle.canonicalRoot, handle.branch);
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
probeFailed = true;
|
|
823
|
+
}
|
|
824
|
+
const hasChanges = shortstat.length > 0 || !statusEmpty || unmergedCommits > 0;
|
|
825
|
+
if (!hasChanges && !probeFailed) {
|
|
826
|
+
await handle.cleanup();
|
|
827
|
+
return mcpNotice;
|
|
828
|
+
}
|
|
829
|
+
const relPath = relative(parentWorkspace, handle.path) || handle.path;
|
|
830
|
+
const reasons = [];
|
|
831
|
+
if (shortstat.length > 0)
|
|
832
|
+
reasons.push(shortstat);
|
|
833
|
+
if (!statusEmpty)
|
|
834
|
+
reasons.push("untracked or staged files present");
|
|
835
|
+
if (unmergedCommits > 0)
|
|
836
|
+
reasons.push(`${unmergedCommits} commit(s) on ${handle.branch} not on HEAD`);
|
|
837
|
+
if (probeFailed && reasons.length === 0)
|
|
838
|
+
reasons.push("probe error during retention check — worktree kept defensively");
|
|
839
|
+
return [
|
|
840
|
+
`[worktree retained: ${relPath} on branch ${handle.branch}]`,
|
|
841
|
+
`[diff: ${reasons.join("; ")}]`,
|
|
842
|
+
mcpNotice,
|
|
843
|
+
]
|
|
844
|
+
.filter(Boolean)
|
|
845
|
+
.join("\n");
|
|
846
|
+
}
|
|
891
847
|
/** Boolean arg reader. Returns true only when the value is literal `true`;
|
|
892
848
|
* everything else (false, missing, wrong type) returns false. We avoid
|
|
893
849
|
* coercion (truthy strings etc.) because schema-level safety matters here. */
|