pi-pier 0.2.7 → 0.3.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/README.md +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +12 -0
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +60 -10
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +121 -47
- package/src/core/todo.ts +25 -11
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -1
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +3 -1
- package/src/index.ts +330 -34
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +4 -2
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +82 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
package/src/core/subagent.ts
CHANGED
|
@@ -7,35 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import { Type } from 'typebox';
|
|
10
|
-
import {
|
|
11
|
-
import { basename, join, dirname } from 'node:path';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
12
11
|
import type { PiSurface } from '../pi-surface.ts';
|
|
13
12
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
14
|
-
import { Semaphore,
|
|
13
|
+
import { Semaphore, buildAliveNotice, isAlive, tabNameForTask, type SubEntry } from '../subagent-core.ts';
|
|
15
14
|
import { applyReportedSessionFile, appendHistory, preferredHistoryFile, inheritOutcome, latestGeneration, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
16
|
-
import { runtimePolicy } from '../runtime-policy.ts';
|
|
17
15
|
import { platformPaths } from '../platform-paths.ts';
|
|
18
16
|
import type { SubagentPort, SubagentPortBox } from '../subagent-port.ts';
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
planLaunchValidation,
|
|
24
|
-
planPatienceExpiry,
|
|
25
|
-
} from '../subagent-launch.ts';
|
|
26
|
-
import { mkdirSync, stat as statCb } from 'node:fs';
|
|
27
|
-
import { resolve as pathResolve } from 'node:path';
|
|
28
|
-
import { promisify } from 'node:util';
|
|
29
|
-
const statAsync = promisify(statCb);
|
|
17
|
+
import { toolError } from '../tool-error.ts'
|
|
18
|
+
|
|
19
|
+
/** Raw tool arguments: every field is validated inside the action handlers. */
|
|
20
|
+
type ToolParams = Record<string, unknown> | undefined;;
|
|
30
21
|
import { pipeNameFor, pipeRequestTo } from '../pipe-channel.ts';
|
|
31
|
-
import { composeForRole } from '../manifest-compose.ts';
|
|
32
22
|
import type { TerminalStateSlot } from './terminal.ts';
|
|
33
23
|
import { createGitIo } from '../subagent-git-io.ts';
|
|
34
24
|
import { createSessionIo } from '../subagent-session-io.ts';
|
|
35
25
|
import { createSpawner } from '../subagent-spawn.ts';
|
|
36
26
|
import { createPoller } from '../subagent-poll-loop.ts';
|
|
37
27
|
import { createGcController } from '../subagent-gc.ts';
|
|
38
|
-
|
|
28
|
+
import { createSubagentRegistry } from '../subagent-registry.ts';
|
|
29
|
+
import { createSpawnAction } from '../subagent-execute.ts';
|
|
30
|
+
import type { SubagentOutputCursor } from '../subagent-output-core.ts';
|
|
31
|
+
import { executeSubagentList } from '../subagent-list-action.ts';
|
|
32
|
+
import { executeSubagentOutput } from '../subagent-output-action.ts';
|
|
33
|
+
import { resolveTaskIdPrefix } from '../subagent-resolution.ts';
|
|
39
34
|
|
|
40
35
|
interface SubagentEnv {
|
|
41
36
|
paneId: string;
|
|
@@ -68,7 +63,7 @@ interface SubagentDeps {
|
|
|
68
63
|
|
|
69
64
|
const SUBAGENT_DESCRIPTION = [
|
|
70
65
|
'Delegate a self-contained subtask to an isolated subagent that runs in its own herdr pane as an interactive pi session (separate context window; it does NOT see this conversation). A human can also open that pane and talk to the subagent directly.',
|
|
71
|
-
'`action` (optional, default "spawn"): spawn | resume | list | send | interrupt.',
|
|
66
|
+
'`action` (optional, default "spawn"): spawn | resume | list | send | interrupt | output.',
|
|
72
67
|
'[spawn] `description`: short display label for the pane; `prompt`: the COMPLETE task — include all needed context, since only the prompt reaches the subagent. The description also doubles as the todo-reconcile key: when delegating a todo entry, use the entry content WITHOUT its ` <sub>` marker as the description, and the entry is auto-completed when this subagent settles.',
|
|
73
68
|
'[spawn] The subagent shares this workspace and works independently; the result is its final text answer.',
|
|
74
69
|
'[spawn] Concurrent delegation is supported: several spawn calls in one message run in parallel (at most 4 at once). Use this for well-scoped, independent subtasks; do not delegate the current step itself.',
|
|
@@ -80,12 +75,11 @@ const SUBAGENT_DESCRIPTION = [
|
|
|
80
75
|
'[list] no extra parameters: list background subagents with live state (running / idle), pane ids, last activity, role, and descriptions. Foreground one-shot panes are not listed.',
|
|
81
76
|
'[send] `agentId` + `message`: follow-up to a background subagent. If working, delivered at next tool-call gap (steer, seconds); if idle, wakes a new turn. After settle, send to wake it; do not spawn a duplicate.',
|
|
82
77
|
'[interrupt] `agentId`: abort the current turn (fire-and-return). The pane stays; you can send again.',
|
|
78
|
+
'[output] `agentId` (required, the subagent pane id): view incremental output of a running or background subagent since the last output call. Text returned to the model is bounded (default 6000 chars) with status metadata (running/idle/blocked/settled), revision, truncated flag, and whether the buffer reset/scrolled (restart: true). Use this to observe subagent progress before settlement.',
|
|
83
79
|
].join(' ');
|
|
84
80
|
|
|
85
81
|
/** Subagent concurrency limit (max parallel delegations) */
|
|
86
82
|
const SUBAGENT_CONCURRENCY = 4;
|
|
87
|
-
const SUBAGENT_TIMEOUT_MS = runtimePolicy.subagentTimeoutMs;
|
|
88
|
-
const SUB_READY_TIMEOUT_MS = runtimePolicy.readinessTimeoutMs;
|
|
89
83
|
|
|
90
84
|
function defaultAgentSessionsDir(): string {
|
|
91
85
|
const base = process.env.PI_CODING_AGENT_DIR || platformPaths.agentDataDir;
|
|
@@ -96,6 +90,8 @@ function agentRootDir(): string {
|
|
|
96
90
|
return dirname(defaultAgentSessionsDir());
|
|
97
91
|
}
|
|
98
92
|
|
|
93
|
+
|
|
94
|
+
|
|
99
95
|
export default function subagentPlugin(ctx: Context): void {
|
|
100
96
|
const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
|
|
101
97
|
const d = ctx.get('pi-herdr.subagent-deps') as SubagentDeps;
|
|
@@ -116,25 +112,15 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
116
112
|
const lastMachineInjectAt = new Map<string, number>();
|
|
117
113
|
/* Persist the subagent registry as custom branch state so parent restarts can rebuild it. */
|
|
118
114
|
const subs = new Map<string, SubEntry>();
|
|
115
|
+
/** In-memory cursor for incremental subagent output observation. */
|
|
116
|
+
const outputCursors = new Map<string, SubagentOutputCursor>();
|
|
119
117
|
/** D98 excludes branches during worktree-add-to-registry races from orphan collection. */
|
|
120
118
|
const pendingIsolateBranches = new Set<string>();
|
|
121
119
|
/** D50 tracks the latest machine request per pane for interrupt claims and poll deduplication. */
|
|
122
120
|
const lastRequestIdByPane = new Map<string, string>();
|
|
123
121
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
function persistSubs(): void {
|
|
128
|
-
try {
|
|
129
|
-
const reg = makeRegistry([...subs.values()]);
|
|
130
|
-
const snap = JSON.stringify(reg);
|
|
131
|
-
if (snap === lastSubsSnapshot) return; // Avoid duplicate writes from the 1s/5s observation loops.
|
|
132
|
-
lastSubsSnapshot = snap;
|
|
133
|
-
pi.appendEntry?.(SUBS_CUSTOM_TYPE, reg);
|
|
134
|
-
} catch {
|
|
135
|
-
/* Persistence is best-effort so session logging cannot break delegation. */
|
|
136
|
-
}
|
|
137
|
-
}
|
|
122
|
+
const registry = createSubagentRegistry({ pi, client, subs, writeHistory });
|
|
123
|
+
const { persist: persistSubs } = registry;
|
|
138
124
|
/** E2 deduplicates gate notices while blocked but permits a later, distinct human question. */
|
|
139
125
|
const blockedGateNotified = new Set<string>();
|
|
140
126
|
const git = createGitIo();
|
|
@@ -167,53 +153,22 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
167
153
|
ctx.effect(() => () => {
|
|
168
154
|
if (port.current === boundPort) port.current = null;
|
|
169
155
|
}, 'subagent-port');
|
|
170
|
-
function rebuildSubs(eventCtx: unknown): void {
|
|
171
|
-
try {
|
|
172
|
-
const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
173
|
-
?.sessionManager?.getBranch?.() ?? [];
|
|
174
|
-
const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
175
|
-
for (const sub of reg.subs) subs.set(sub.paneId, sub);
|
|
176
|
-
lastSubsSnapshot = ''; // Force persistence because branch replay replaced live state.
|
|
177
|
-
} catch {
|
|
178
|
-
/* Registry recovery failure must not block the live session. */
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** B6 closes crash-stale running rows only after herdr confirms their pane is gone; 01a03c0d
|
|
183
|
-
* showed p6/p7 otherwise remained permanently running. */
|
|
184
|
-
async function sweepZombieRunning(): Promise<void> {
|
|
185
|
-
if (!client.available || subs.size === 0) return;
|
|
186
|
-
let livePaneIds: ReadonlySet<string>;
|
|
187
|
-
try {
|
|
188
|
-
livePaneIds = new Set((await client.listPanes()).map((p) => p.paneId));
|
|
189
|
-
} catch {
|
|
190
|
-
return; // Do not close agents when liveness lookup itself failed.
|
|
191
|
-
}
|
|
192
|
-
let changed = false;
|
|
193
|
-
for (const [paneId, e] of subs) {
|
|
194
|
-
if (e.status !== 'running' || livePaneIds.has(paneId)) continue;
|
|
195
|
-
e.status = 'closed';
|
|
196
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
|
|
197
|
-
changed = true;
|
|
198
|
-
}
|
|
199
|
-
if (changed) persistSubs();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
156
|
scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
|
|
203
|
-
|
|
204
|
-
await sweepZombieRunning();
|
|
157
|
+
registry.rebuild(eventCtx);
|
|
158
|
+
await registry.sweepZombieRunning();
|
|
205
159
|
});
|
|
206
160
|
scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
|
|
207
|
-
|
|
208
|
-
await sweepZombieRunning();
|
|
161
|
+
registry.rebuild(eventCtx);
|
|
162
|
+
await registry.sweepZombieRunning();
|
|
209
163
|
});
|
|
210
164
|
|
|
211
165
|
/* ── B1 liveness rewrite for subagent errors ─────────────────────
|
|
212
166
|
* A false no-output result caused the model to seize work from a healthy subagent. Probe
|
|
213
167
|
* agent.list and session mtime before the tool result enters model context; if alive, emit the
|
|
214
168
|
* same notice as A2 backgrounding. This hook enforces what prompting alone could not. */
|
|
215
|
-
scoped.on('tool_result', async (
|
|
216
|
-
|
|
169
|
+
scoped.on('tool_result', async (rawEvent: unknown) => {
|
|
170
|
+
const event = (rawEvent ?? {}) as { toolName?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> };
|
|
171
|
+
if (event.toolName !== 'subagent' || !event.isError) return;
|
|
217
172
|
// Extract only pane ids carried by our own errors; leave unrelated errors untouched.
|
|
218
173
|
const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
|
|
219
174
|
const paneId = [...subs.keys()].find((id) => errText.includes(id))
|
|
@@ -311,11 +266,9 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
311
266
|
gc.startTicker(ctx);
|
|
312
267
|
scoped.on('turn_start', () => gc.onTurnStart());
|
|
313
268
|
|
|
314
|
-
const {
|
|
269
|
+
const { readAskFlag, probeAlive } = session;
|
|
315
270
|
const { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, findExistingPane } = spawn;
|
|
316
271
|
const { startPoller, pollers } = poller;
|
|
317
|
-
const runGit = (...args: Parameters<typeof git.runGit>) => git.runGit(...args);
|
|
318
|
-
const listWorktrees = (...args: Parameters<typeof git.listWorktrees>) => git.listWorktrees(...args);
|
|
319
272
|
|
|
320
273
|
/** Revive a closed task; resume and automatic send_message revival share this path. */
|
|
321
274
|
async function reviveEntry(entry: SubEntry): Promise<SubEntry> {
|
|
@@ -334,7 +287,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
334
287
|
launchLine(resumeFile, null, approve),
|
|
335
288
|
);
|
|
336
289
|
const ready = await waitSubReady(entry.cwd, spawned.paneId);
|
|
337
|
-
if (!ready) throw new Error(
|
|
290
|
+
if (!ready.ok) throw new Error(ready.message);
|
|
338
291
|
entry.paneId = spawned.paneId;
|
|
339
292
|
entry.tabId = spawned.tabId;
|
|
340
293
|
entry.tabName = spawned.tabName;
|
|
@@ -349,21 +302,32 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
349
302
|
}
|
|
350
303
|
|
|
351
304
|
|
|
352
|
-
async function executeSubagentResume(params, toolCtx) {
|
|
305
|
+
async function executeSubagentResume(params: Record<string, unknown> | undefined, toolCtx: unknown) {
|
|
353
306
|
if (!client.available) {
|
|
354
|
-
return
|
|
355
|
-
content: [{ type: 'text', text: 'Error: requires a herdr-managed pane.' }],
|
|
356
|
-
details: {},
|
|
357
|
-
};
|
|
307
|
+
return toolError('requires a herdr-managed pane.');
|
|
358
308
|
}
|
|
359
309
|
const cwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
360
|
-
const
|
|
361
|
-
|
|
310
|
+
const rawTaskId = String(params?.taskId ?? params?.agentId ?? '').trim();
|
|
311
|
+
if (!rawTaskId) {
|
|
312
|
+
return toolError('missing taskId for resume (see action list or delegation ledger).');
|
|
313
|
+
}
|
|
314
|
+
const history = readHistory(histFile(cwd));
|
|
315
|
+
const resolution = resolveTaskIdPrefix(rawTaskId, history.map((e) => e.taskId));
|
|
316
|
+
if (resolution.kind === 'too_short') {
|
|
317
|
+
return toolError(`Error: task id prefix "${rawTaskId}" is too short (minimum 4 characters).`);
|
|
318
|
+
}
|
|
319
|
+
if (resolution.kind === 'ambiguous') {
|
|
320
|
+
const list = resolution.candidates.slice(0, 5).join(', ');
|
|
321
|
+
const more = resolution.candidates.length > 5 ? `, ... (+${resolution.candidates.length - 5} more)` : '';
|
|
322
|
+
return toolError(`Error: ambiguous task id "${rawTaskId}" matches ${resolution.candidates.length} tasks: ${list}${more}`);
|
|
323
|
+
}
|
|
324
|
+
if (resolution.kind === 'not_found') {
|
|
325
|
+
return toolError(`Error: no history for task "${rawTaskId}" in this workspace.`);
|
|
326
|
+
}
|
|
327
|
+
const taskId = resolution.taskId;
|
|
328
|
+
const latest = latestGeneration(history, taskId);
|
|
362
329
|
if (!latest) {
|
|
363
|
-
return {
|
|
364
|
-
content: [{ type: 'text', text: `Error: no history for task "${taskId}" in this workspace.` }],
|
|
365
|
-
details: {},
|
|
366
|
-
};
|
|
330
|
+
return toolError(`Error: no history for task "${taskId}" in this workspace.`);
|
|
367
331
|
}
|
|
368
332
|
const release = await subSemaphore.acquire();
|
|
369
333
|
try {
|
|
@@ -380,7 +344,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
380
344
|
description: latest.description,
|
|
381
345
|
background: true,
|
|
382
346
|
status: 'running',
|
|
383
|
-
sessionFile: latest.sessionFile,
|
|
347
|
+
sessionFile: latest.sessionFile ?? null,
|
|
384
348
|
launchCommand: latest.launchCommand,
|
|
385
349
|
createdAt: Date.now(),
|
|
386
350
|
revivedFrom: latest.paneId,
|
|
@@ -427,64 +391,90 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
427
391
|
details: { paneId: entry.paneId, taskId },
|
|
428
392
|
};
|
|
429
393
|
} catch (err) {
|
|
430
|
-
return {
|
|
431
|
-
content: [{ type: 'text', text: `Error: failed to resume task "${taskId}": ${(err as Error).message}` }],
|
|
432
|
-
details: {},
|
|
433
|
-
};
|
|
394
|
+
return toolError(`Error: failed to resume task "${taskId}": ${(err as Error).message}`);
|
|
434
395
|
} finally {
|
|
435
396
|
release();
|
|
436
397
|
}
|
|
437
398
|
}
|
|
438
399
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
400
|
+
const listActionDeps = {
|
|
401
|
+
subs: () => subs.values(),
|
|
402
|
+
probeAlive,
|
|
403
|
+
readAskFlag,
|
|
404
|
+
};
|
|
405
|
+
function resolveSubEntry(
|
|
406
|
+
rawId: string,
|
|
407
|
+
cwd?: string,
|
|
408
|
+
): { entry: SubEntry } | { error: string } {
|
|
409
|
+
if (!rawId) {
|
|
410
|
+
return { error: 'Error: unknown subagent id "" (see action list)' };
|
|
411
|
+
}
|
|
412
|
+
const direct = subs.get(rawId);
|
|
413
|
+
if (direct) return { entry: direct };
|
|
414
|
+
|
|
415
|
+
const byTaskId = new Map<string, SubEntry>();
|
|
416
|
+
for (const sub of subs.values()) {
|
|
417
|
+
const prev = byTaskId.get(sub.taskId);
|
|
418
|
+
if (!prev || sub.createdAt >= prev.createdAt) byTaskId.set(sub.taskId, sub);
|
|
419
|
+
}
|
|
420
|
+
const res = resolveTaskIdPrefix(rawId, byTaskId.keys());
|
|
421
|
+
if (res.kind === 'resolved') {
|
|
422
|
+
return { entry: byTaskId.get(res.taskId)! };
|
|
423
|
+
}
|
|
424
|
+
if (res.kind === 'ambiguous') {
|
|
425
|
+
const list = res.candidates.slice(0, 5).join(', ');
|
|
426
|
+
const more = res.candidates.length > 5 ? `, ... (+${res.candidates.length - 5} more)` : '';
|
|
427
|
+
return { error: `Error: ambiguous subagent id "${rawId}" matches ${res.candidates.length} tasks: ${list}${more}` };
|
|
428
|
+
}
|
|
429
|
+
if (res.kind === 'too_short') {
|
|
430
|
+
return { error: `Error: subagent id prefix "${rawId}" is too short (minimum 4 characters).` };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (cwd) {
|
|
434
|
+
const hist = readHistory(histFile(cwd));
|
|
435
|
+
const histTaskIds = Array.from(new Set(hist.map((e) => e.taskId)));
|
|
436
|
+
const histRes = resolveTaskIdPrefix(rawId, histTaskIds);
|
|
437
|
+
if (histRes.kind === 'resolved') {
|
|
438
|
+
const latest = latestGeneration(hist, histRes.taskId);
|
|
439
|
+
if (latest) {
|
|
440
|
+
const entry: SubEntry = {
|
|
441
|
+
taskId: latest.taskId,
|
|
442
|
+
kind: latest.kind,
|
|
443
|
+
paneId: latest.paneId,
|
|
444
|
+
tabId: latest.tabId,
|
|
445
|
+
tabName: latest.tabName ?? '',
|
|
446
|
+
cwd: latest.cwd,
|
|
447
|
+
description: latest.description,
|
|
448
|
+
background: true,
|
|
449
|
+
status: 'closed',
|
|
450
|
+
sessionFile: latest.sessionFile,
|
|
451
|
+
launchCommand: latest.launchCommand,
|
|
452
|
+
createdAt: latest.createdAt,
|
|
453
|
+
consumedAt: latest.consumedAt ?? null,
|
|
454
|
+
revivedFrom: latest.paneId,
|
|
455
|
+
};
|
|
456
|
+
subs.set(entry.paneId, entry);
|
|
457
|
+
return { entry };
|
|
477
458
|
}
|
|
478
|
-
lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag} ${sub.description}`);
|
|
479
459
|
}
|
|
480
|
-
|
|
460
|
+
if (histRes.kind === 'ambiguous') {
|
|
461
|
+
const list = histRes.candidates.slice(0, 5).join(', ');
|
|
462
|
+
const more = histRes.candidates.length > 5 ? `, ... (+${histRes.candidates.length - 5} more)` : '';
|
|
463
|
+
return { error: `Error: ambiguous subagent id "${rawId}" matches ${histRes.candidates.length} tasks: ${list}${more}` };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return { error: `Error: unknown subagent id "${rawId}" (see action list)` };
|
|
481
468
|
}
|
|
482
469
|
|
|
483
|
-
async function executeSubagentSend(params) {
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
470
|
+
async function executeSubagentSend(params: Record<string, unknown> | undefined, toolCtx?: unknown) {
|
|
471
|
+
const rawId = String(params?.agentId ?? params?.taskId ?? '').trim();
|
|
472
|
+
const cwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
|
|
473
|
+
const resolved = resolveSubEntry(rawId, cwd);
|
|
474
|
+
if ('error' in resolved) {
|
|
475
|
+
return toolError(resolved.error);
|
|
487
476
|
}
|
|
477
|
+
const entry = resolved.entry;
|
|
488
478
|
const spawnedAt = Date.now();
|
|
489
479
|
try {
|
|
490
480
|
// A closed task is revived automatically because the pane is only a temporary host.
|
|
@@ -496,7 +486,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
496
486
|
// M11 (D46): follow_up uses the extension pipe. B3 adds steering so supplemental instructions reach a long-running worker
|
|
497
487
|
// within seconds during a tool-call gap; the old followUp queue waited for the entire run and caused 20-minute rework (01a03c0d).
|
|
498
488
|
const ready = await waitSubReady(entry.cwd, entry.paneId);
|
|
499
|
-
if (!ready) throw new Error(
|
|
489
|
+
if (!ready.ok) throw new Error(ready.message);
|
|
500
490
|
const fuId = `fu-${Date.now()}`;
|
|
501
491
|
const res = await pipeRequestTo(entry.cwd, entry.paneId, {
|
|
502
492
|
type: 'follow_up',
|
|
@@ -525,15 +515,18 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
525
515
|
}
|
|
526
516
|
return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
|
|
527
517
|
} catch (err) {
|
|
528
|
-
return
|
|
518
|
+
return toolError(`Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}`);
|
|
529
519
|
}
|
|
530
520
|
}
|
|
531
521
|
|
|
532
|
-
async function executeSubagentInterrupt(params) {
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
522
|
+
async function executeSubagentInterrupt(params: Record<string, unknown> | undefined, toolCtx?: unknown) {
|
|
523
|
+
const rawId = String(params?.agentId ?? params?.taskId ?? '').trim();
|
|
524
|
+
const cwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
|
|
525
|
+
const resolved = resolveSubEntry(rawId, cwd);
|
|
526
|
+
if ('error' in resolved) {
|
|
527
|
+
return toolError(resolved.error);
|
|
536
528
|
}
|
|
529
|
+
const entry = resolved.entry;
|
|
537
530
|
if (entry.status === 'closed') {
|
|
538
531
|
// DSH alignment: an idle or finished target is an idempotent no-op.
|
|
539
532
|
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
|
|
@@ -551,13 +544,47 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
551
544
|
if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
|
|
552
545
|
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };
|
|
553
546
|
} catch (err) {
|
|
554
|
-
return
|
|
547
|
+
return toolError(`Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}`);
|
|
555
548
|
}
|
|
556
549
|
}
|
|
550
|
+
const outputActionDeps = {
|
|
551
|
+
client,
|
|
552
|
+
resolveEntry: (rawId: string, cwd: string) => resolveSubEntry(rawId, cwd),
|
|
553
|
+
readAskFlag,
|
|
554
|
+
outputCursors,
|
|
555
|
+
getCwd: (toolCtx: unknown): string => (toolCtx as { cwd?: string })?.cwd ?? process.cwd(),
|
|
556
|
+
};
|
|
557
|
+
const executeSubagentSpawn = createSpawnAction({
|
|
558
|
+
client,
|
|
559
|
+
env,
|
|
560
|
+
subSemaphore,
|
|
561
|
+
subs,
|
|
562
|
+
persistSubs,
|
|
563
|
+
writeHistory,
|
|
564
|
+
pendingIsolateBranches,
|
|
565
|
+
lastMachineInjectAt,
|
|
566
|
+
lastRequestIdByPane,
|
|
567
|
+
git,
|
|
568
|
+
session,
|
|
569
|
+
spawn,
|
|
570
|
+
poller,
|
|
571
|
+
});
|
|
572
|
+
|
|
557
573
|
|
|
558
574
|
scoped.registerTool({
|
|
559
575
|
name: 'subagent',
|
|
560
576
|
label: 'Subagent',
|
|
577
|
+
// B4: the model picks tools from the system prompt's snippet/guideline surface; this tool is the
|
|
578
|
+
// one place where delegation decisions are made, so state them here rather than hoping for recall.
|
|
579
|
+
promptSnippet: 'subagent: delegate a self-contained task to a pi worker pane (spawn/isolate/background/send/output/interrupt/resume/list).',
|
|
580
|
+
promptGuidelines: [
|
|
581
|
+
'Use subagent when the work is self-contained and the result is what matters: pipelines that would flood this context, long builds/test runs, or independent parts of a bigger change that can proceed in parallel.',
|
|
582
|
+
'Prefer run_in_background: true for work that will not finish in a few tool calls, then collect with action: "output" — a foreground subagent blocks this turn.',
|
|
583
|
+
'Use isolate: true when the worker will write files for a task you (or another worker) are editing at the same time, or when you want its own reviewable branch; use cwd to send work into an existing worktree (create it with git worktree add first).',
|
|
584
|
+
'Keep the prompt complete and self-contained: the worker does not see this conversation, so include paths, acceptance criteria, the test command, and what to report back.',
|
|
585
|
+
'Do not poll with action: "output" in a tight loop: the pane pushes a settlement notice when it finishes. Check once, then continue other work.',
|
|
586
|
+
'Send follow-up instructions with action: "send" (delivered as a steer between tool calls) instead of spawning a second worker for the same task.',
|
|
587
|
+
],
|
|
561
588
|
description: SUBAGENT_DESCRIPTION,
|
|
562
589
|
parameters: Type.Object({
|
|
563
590
|
action: Type.Optional(Type.Union([
|
|
@@ -566,6 +593,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
566
593
|
Type.Literal('list'),
|
|
567
594
|
Type.Literal('send'),
|
|
568
595
|
Type.Literal('interrupt'),
|
|
596
|
+
Type.Literal('output'),
|
|
569
597
|
], { description: 'Operation to perform (default: spawn)' })),
|
|
570
598
|
description: Type.Optional(Type.String({ description: '[spawn] Short label for this subtask (pane title)' })),
|
|
571
599
|
prompt: Type.Optional(Type.String({ description: '[spawn] The complete self-contained task for the subagent' })),
|
|
@@ -576,264 +604,30 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
576
604
|
tab: Type.Optional(Type.String({ description: '[spawn] Name of a task tab to place the subagent into (join if exists, otherwise create). Default placement groups by git worktree.' })),
|
|
577
605
|
allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Additional tools for role composition (union with role baseline)' })),
|
|
578
606
|
taskId: Type.Optional(Type.String({ description: '[resume] The task id to revive from the delegation ledger' })),
|
|
579
|
-
agentId: Type.Optional(Type.String({ description: '[send|interrupt] The subagent id (herdr pane id)' })),
|
|
607
|
+
agentId: Type.Optional(Type.String({ description: '[send|interrupt|output] The subagent id (herdr pane id)' })),
|
|
580
608
|
message: Type.Optional(Type.String({ description: '[send] The follow-up message' })),
|
|
609
|
+
max_chars: Type.Optional(Type.Integer({ description: '[output] Maximum characters of output delta to return (default 6000, 100-16000)' })),
|
|
581
610
|
}),
|
|
582
|
-
|
|
611
|
+
// B2: 23/38 real spawns omitted `action` even though it is optional; normalize so the schema the
|
|
612
|
+
// model is validated against and the code path never disagree, and so `spawn` is explicit in logs.
|
|
613
|
+
prepareArguments: (args: unknown) => {
|
|
614
|
+
const rec = (args && typeof args === 'object' ? args : {}) as Record<string, unknown>;
|
|
615
|
+
const hasAction = typeof rec.action === 'string' && rec.action.trim() !== '';
|
|
616
|
+
return hasAction ? rec : { ...rec, action: 'spawn' };
|
|
617
|
+
},
|
|
618
|
+
async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, toolCtx: unknown) {
|
|
583
619
|
void toolCallId;
|
|
584
620
|
void signal;
|
|
585
621
|
const action = typeof params?.action === 'string' && params.action.trim() ? params.action.trim() : 'spawn';
|
|
586
622
|
if (action === 'resume') return executeSubagentResume(params, toolCtx);
|
|
587
|
-
if (action === 'list') return executeSubagentList();
|
|
588
|
-
if (action === 'send') return executeSubagentSend(params);
|
|
589
|
-
if (action === 'interrupt') return executeSubagentInterrupt(params);
|
|
623
|
+
if (action === 'list') return executeSubagentList(listActionDeps);
|
|
624
|
+
if (action === 'send') return executeSubagentSend(params, toolCtx);
|
|
625
|
+
if (action === 'interrupt') return executeSubagentInterrupt(params, toolCtx);
|
|
626
|
+
if (action === 'output') return executeSubagentOutput(params, toolCtx, outputActionDeps);
|
|
590
627
|
if (action !== 'spawn') {
|
|
591
|
-
return
|
|
592
|
-
}
|
|
593
|
-
const launch = planLaunchValidation(params, client.available);
|
|
594
|
-
if (launch.kind === 'error') {
|
|
595
|
-
return { content: [{ type: 'text', text: launch.text }], details: {} };
|
|
596
|
-
}
|
|
597
|
-
const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
|
|
598
|
-
const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
599
|
-
// Move taskId generation above the original spawn section because isolate planning needs taskHex; this is behavior-neutral.
|
|
600
|
-
const taskId = randomUUID();
|
|
601
|
-
// D98 2c: Create the isolate through pier's execFile git path (decision 1), not herdr's
|
|
602
|
-
// socket worktree.create, which has bootstrap races, missing root-pane env, worker/master
|
|
603
|
-
// workspace separation, and linked_worktree_source rejection. The model makes the decision; do not infer it.
|
|
604
|
-
let isolateMeta: SubEntry['isolate'] | null = null;
|
|
605
|
-
let cwd = masterCwd;
|
|
606
|
-
if (isolate) {
|
|
607
|
-
const baseSha = await runGit(masterCwd, ['rev-parse', 'HEAD']);
|
|
608
|
-
const isoGuard = planIsolateRepoGuard(baseSha);
|
|
609
|
-
if (isoGuard.kind === 'error') {
|
|
610
|
-
return { content: [{ type: 'text', text: isoGuard.text }], details: {} };
|
|
611
|
-
}
|
|
612
|
-
const sha = isoGuard.sha.trim();
|
|
613
|
-
const pierBranches = new Set(
|
|
614
|
-
((await runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
|
|
615
|
-
.split('\n').filter(Boolean),
|
|
616
|
-
);
|
|
617
|
-
const worktrees = await listWorktrees(masterCwd);
|
|
618
|
-
const repoName = basename(worktrees[0] ?? masterCwd);
|
|
619
|
-
const plan = planIsolateWorktree({
|
|
620
|
-
description: spec.description,
|
|
621
|
-
taskHex: taskId.slice(0, 6),
|
|
622
|
-
existingPierBranches: pierBranches,
|
|
623
|
-
});
|
|
624
|
-
const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
|
|
625
|
-
try {
|
|
626
|
-
mkdirSync(dirname(wtPath), { recursive: true }); // git worktree add does not create its parent directory, so a first run would fail.
|
|
627
|
-
} catch { /* Existing directory or permissions failure is handled by worktree add. */ }
|
|
628
|
-
// Keep the branch pending between worktree add and subs.set: the 2–3s readiness wait lets the ticker
|
|
629
|
-
// scan, and without this guard it could reclaim a new clean =HEAD-ancestor worktree as an orphan.
|
|
630
|
-
pendingIsolateBranches.add(plan.branch);
|
|
631
|
-
const added = await runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
|
|
632
|
-
if (added === null) {
|
|
633
|
-
pendingIsolateBranches.delete(plan.branch);
|
|
634
|
-
return {
|
|
635
|
-
content: [{ type: 'text', text: `Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries` }],
|
|
636
|
-
details: {},
|
|
637
|
-
};
|
|
638
|
-
}
|
|
639
|
-
git.invalidateWorktreesCache(); // Invalidate the 5s cache so the zone classifier sees the new worktree immediately.
|
|
640
|
-
cwd = wtPath;
|
|
641
|
-
isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
|
|
642
|
-
spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
|
|
643
|
-
} else if (cwdParam) {
|
|
644
|
-
cwd = pathResolve(masterCwd, cwdParam);
|
|
645
|
-
try {
|
|
646
|
-
const st = await statAsync(cwd);
|
|
647
|
-
if (!st.isDirectory()) throw new Error('not a directory');
|
|
648
|
-
} catch {
|
|
649
|
-
return {
|
|
650
|
-
content: [{ type: 'text', text: `Error: \`cwd\` is not an existing directory: ${cwdParam}` }],
|
|
651
|
-
details: {},
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
// D86 R1: Group by the git worktree containing cwd (main checkout → main tab; other worktree → directory-named tab).
|
|
656
|
-
const zone = classifyWorktreeZone({
|
|
657
|
-
cwd,
|
|
658
|
-
masterCwd,
|
|
659
|
-
worktrees: await listWorktrees(masterCwd),
|
|
660
|
-
});
|
|
661
|
-
// D86 trust: pass -a only for the master's checkout/worktrees; external directories remain behind pi's Trust dialog.
|
|
662
|
-
const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
|
|
663
|
-
let roleManifestEnv: Record<string, string> = {};
|
|
664
|
-
let roleModel: string | null = null;
|
|
665
|
-
try {
|
|
666
|
-
const { role, manifest } = composeForRole(manifestRole, suggested, { loadRoleOpts: { baseDir: masterCwd } });
|
|
667
|
-
roleManifestEnv = {
|
|
668
|
-
PI_HERDR_ROLE_MANIFEST: JSON.stringify({
|
|
669
|
-
role: role.role,
|
|
670
|
-
version: role.version,
|
|
671
|
-
tools: manifest.tools,
|
|
672
|
-
permissions: manifest.permissions,
|
|
673
|
-
unknownTools: manifest.unknownTools,
|
|
674
|
-
services: role.services ?? {},
|
|
675
|
-
}),
|
|
676
|
-
};
|
|
677
|
-
// WS-D10: Route the model by role; omission intentionally uses the process default.
|
|
678
|
-
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
679
|
-
} catch (err) {
|
|
680
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
681
|
-
return {
|
|
682
|
-
content: [{ type: 'text', text: `Error: role "${manifestRole}" manifest invalid — ${msg}` }],
|
|
683
|
-
details: {},
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
const release = await subSemaphore.acquire();
|
|
687
|
-
let paneId = '';
|
|
688
|
-
try {
|
|
689
|
-
const spawnedAt = Date.now();
|
|
690
|
-
const spawned = await spawnPaneInTaskTab(
|
|
691
|
-
{ desiredTab: tab, description: spec.description, zone },
|
|
692
|
-
cwd,
|
|
693
|
-
{ PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
|
|
694
|
-
launchLine(null, roleModel, approve),
|
|
695
|
-
);
|
|
696
|
-
paneId = spawned.paneId;
|
|
697
|
-
const entry: SubEntry = {
|
|
698
|
-
taskId,
|
|
699
|
-
kind,
|
|
700
|
-
paneId,
|
|
701
|
-
tabId: spawned.tabId,
|
|
702
|
-
tabName: spawned.tabName,
|
|
703
|
-
cwd,
|
|
704
|
-
description: spec.description,
|
|
705
|
-
background,
|
|
706
|
-
status: 'running',
|
|
707
|
-
sessionFile: null,
|
|
708
|
-
launchCommand: [launchLine(null, roleModel, approve)],
|
|
709
|
-
createdAt: Date.now(),
|
|
710
|
-
revivedFrom: null,
|
|
711
|
-
...(isolateMeta ? { isolate: isolateMeta } : {}),
|
|
712
|
-
};
|
|
713
|
-
const ready = await waitSubReady(cwd, paneId);
|
|
714
|
-
if (!ready) throw new Error(`subagent pane ${paneId} pipe not ready within ${SUB_READY_TIMEOUT_MS}ms`);
|
|
715
|
-
entry.sessionFile = await resolveSessionFile(paneId, cwd);
|
|
716
|
-
subs.set(paneId, entry);
|
|
717
|
-
persistSubs();
|
|
718
|
-
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
719
|
-
// M11 (D45/D46): Inject through the extension pipe to the child extension's sendUserMessage(followUp);
|
|
720
|
-
// reserve the PTY keyboard channel entirely for the person, with no soft-lock window or mixed input.
|
|
721
|
-
// Regression evidence (session 01a03bf0): ecc0bc4's foreground-wait rewrite once removed this block,
|
|
722
|
-
// leaving the prompt uninjected and injectTs undefined (ReferenceError → spawn-failed, with a ghost
|
|
723
|
-
// running ledger entry). Keep injection and injectTs together in future refactors.
|
|
724
|
-
const injectTs = Date.now();
|
|
725
|
-
const injected = await pipeRequestTo(cwd, paneId, {
|
|
726
|
-
type: 'prompt',
|
|
727
|
-
id: `prompt-${taskId}`,
|
|
728
|
-
text: spec.prompt,
|
|
729
|
-
from: pipeNameFor(cwd, env?.paneId ?? ''),
|
|
730
|
-
push: background,
|
|
731
|
-
});
|
|
732
|
-
if (injected.type !== 'ok') {
|
|
733
|
-
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
734
|
-
}
|
|
735
|
-
lastMachineInjectAt.set(paneId, injectTs); // B4: attribute working state during the observation window.
|
|
736
|
-
// A1+A2 (user-verified fix): foreground waiting uses a content gate plus a patience threshold before backgrounding.
|
|
737
|
-
// Treating idle as settled with a hard 90s window misclassified real 4–6 minute working periods as no-output;
|
|
738
|
-
// three healthy subagents were observed becoming consumed at 101s while producing results four minutes later.
|
|
739
|
-
const PATIENCE_MS = runtimePolicy.foregroundPatienceMs;
|
|
740
|
-
const patienceDeadline = Date.now() + PATIENCE_MS;
|
|
741
|
-
let text: string | null = null;
|
|
742
|
-
let settledKind: 'settled' | 'timeout' = 'timeout';
|
|
743
|
-
while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
|
|
744
|
-
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
745
|
-
const session = (state === 'idle' || state === 'done')
|
|
746
|
-
? await subSessionState(paneId, cwd, injectTs)
|
|
747
|
-
: { text: null, pendingTool: false, activity: false };
|
|
748
|
-
const tick = planForegroundTick({ state, session });
|
|
749
|
-
if (tick.kind === 'blocked') {
|
|
750
|
-
const question = await readAskFlag(paneId);
|
|
751
|
-
entry.background = true;
|
|
752
|
-
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
753
|
-
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
754
|
-
persistSubs();
|
|
755
|
-
writeHistory(entry, undefined, 'to-background');
|
|
756
|
-
return {
|
|
757
|
-
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
758
|
-
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
if (tick.kind === 'settled') {
|
|
762
|
-
text = tick.text;
|
|
763
|
-
settledKind = 'settled';
|
|
764
|
-
break;
|
|
765
|
-
}
|
|
766
|
-
if (tick.kind === 'wait') {
|
|
767
|
-
await new Promise((r) => setTimeout(r, tick.delayMs));
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
if (tick.kind === 'collect-final') {
|
|
771
|
-
text = await collectFinalText(paneId, cwd, injectTs, 6);
|
|
772
|
-
if (text) { settledKind = 'settled'; break; }
|
|
773
|
-
await new Promise((r) => setTimeout(r, FOREGROUND_POLL_MS));
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
if (!text && settledKind !== 'blocked') {
|
|
778
|
-
const probe = await probeAlive(paneId, cwd);
|
|
779
|
-
if (planPatienceExpiry(isAlive(probe, Date.now())) === 'move-to-background') {
|
|
780
|
-
entry.background = true;
|
|
781
|
-
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
782
|
-
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
783
|
-
persistSubs();
|
|
784
|
-
const notice = buildAliveNotice(
|
|
785
|
-
{ paneId, description: spec.description, scenario: 'moved-to-bg', probe },
|
|
786
|
-
Date.now(),
|
|
787
|
-
);
|
|
788
|
-
return {
|
|
789
|
-
content: [{ type: 'text', text: notice }],
|
|
790
|
-
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
791
|
-
};
|
|
792
|
-
}
|
|
793
|
-
settledKind = 'timeout';
|
|
794
|
-
}
|
|
795
|
-
const outcome =
|
|
796
|
-
settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
797
|
-
: text ? { kind: 'completed' as const, text }
|
|
798
|
-
: { kind: 'no-output' as const, text: '' };
|
|
799
|
-
entry.status = 'consumed';
|
|
800
|
-
entry.consumedAt = Date.now();
|
|
801
|
-
persistSubs();
|
|
802
|
-
writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
803
|
-
return {
|
|
804
|
-
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
805
|
-
details: { paneId, taskId, background, role: kind },
|
|
806
|
-
};
|
|
807
|
-
} catch (err) {
|
|
808
|
-
// Regression fix (session 01a03bf0): a mid-spawn failure must remove the ledger entry and close its pane,
|
|
809
|
-
// otherwise a ghost running entry never settles, causing D96 reminder storms and send_message calls
|
|
810
|
-
// to land in an empty session with no task context. Pane closure is best effort; GC covers board exceptions.
|
|
811
|
-
if (paneId) {
|
|
812
|
-
subs.delete(paneId);
|
|
813
|
-
persistSubs();
|
|
814
|
-
void client.closePane(paneId).catch(() => { /* Best effort only. */ });
|
|
815
|
-
}
|
|
816
|
-
// D98 2d: If isolate startup never becomes ready, best-effort remove the new worktree and branch (no work
|
|
817
|
-
// exists to preserve); failure stays silent because the branch is harmless and remains visible to git worktree list.
|
|
818
|
-
if (isolateMeta) {
|
|
819
|
-
void runGit(masterCwd, ['worktree', 'remove', '--force', isolateMeta.worktreePath])
|
|
820
|
-
.then(() => runGit(masterCwd, ['branch', '-D', isolateMeta!.branch]))
|
|
821
|
-
.catch(() => { /* Best effort only. */ });
|
|
822
|
-
}
|
|
823
|
-
return {
|
|
824
|
-
content: [{
|
|
825
|
-
type: 'text',
|
|
826
|
-
text: formatSubagentResult(
|
|
827
|
-
{ kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
|
|
828
|
-
spec.description,
|
|
829
|
-
),
|
|
830
|
-
}],
|
|
831
|
-
details: {},
|
|
832
|
-
};
|
|
833
|
-
} finally {
|
|
834
|
-
release();
|
|
835
|
-
if (isolateMeta) pendingIsolateBranches.delete(isolateMeta.branch); // D98: release the creation-window guard on every success/failure path.
|
|
628
|
+
return toolError(`Error: unknown action "${action}" (valid: spawn, resume, list, send, interrupt, output)`);
|
|
836
629
|
}
|
|
630
|
+
return executeSubagentSpawn(params, toolCtx, onUpdate);
|
|
837
631
|
},
|
|
838
632
|
});
|
|
839
633
|
}
|