pi-pier 0.3.0 → 0.4.1

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.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -0,0 +1,766 @@
1
+ /**
2
+ * subagent — master-only plugin entry: tool registration, action dispatch, the resume / send / interrupt /
3
+ * output / role / list actions, and port binding. Inbound deps arrive via `pi-herdr.subagent-deps`; outbound
4
+ * pipe/settle queries bind atomically onto `port.current`. Settlement reconcile stays in index session state
5
+ * and is consumed here.
6
+ */
7
+ import { Context } from '@deepseek-ai/cordis';
8
+ import { Type } from 'typebox';
9
+ import { basename, dirname, join } from 'node:path';
10
+ import { cordisGet } from '../cordis-services.ts';
11
+ import type { HerdrClientLike } from '../herdr-client.ts';
12
+ import {
13
+ Semaphore,
14
+ SUBAGENT_OUTPUT_SCHEMA,
15
+ agoText,
16
+ attachSubagentStructuredContent,
17
+ buildAliveNotice,
18
+ idParam,
19
+ flattenToolParams,
20
+ isAlive,
21
+ newestPerTaskId,
22
+ rekeySub,
23
+ resolveTaskIdPrefix,
24
+ taskIdResolutionError,
25
+ tabNameForTask,
26
+ subRef,
27
+ type AliveProbe,
28
+ type SubEntry,
29
+ type SubagentPort,
30
+ type SubagentPortBox,
31
+ } from '../subagent-core.ts';
32
+ import {
33
+ appendHistory,
34
+ applyReportedSessionFile,
35
+ inheritOutcome,
36
+ latestGeneration,
37
+ preferredHistoryFile,
38
+ readHistory,
39
+ readHistoryTree,
40
+ type HistoryEntry,
41
+ } from '../history-store.ts';
42
+ import { platformPaths } from '../platform-paths.ts';
43
+ import { toolError, ToolError } from '../tool-error.ts';
44
+ import { pipeNameFor, pipeRequestTo, type PipeRequest, type PipeResponse } from '../pipe-channel.ts';
45
+ import type { TerminalStateSlot } from './terminal.ts';
46
+ import { bareSessionId, resolveSessionFileValue } from '../session-tail.ts';
47
+ import { DELEGATION_ENV, type DelegationContext } from '../delegation-core.ts';
48
+ import type { JevRuntime } from '../jev-client.ts';
49
+ import { createGitIo, createSpawnAction, createSpawner } from '../subagent-spawn.ts';
50
+ import { createPoller } from '../subagent-poller.ts';
51
+ import { createGcController, createSubagentRegistry } from '../subagent-gc.ts';
52
+ import { createSessionIo, executeSubagentOutput, type SubagentOutputCursor } from '../subagent-session.ts';
53
+ import type { RoutingTelemetryRecord } from '../routing-telemetry.ts';
54
+
55
+ type ToolParams = Record<string, unknown> | undefined;
56
+
57
+ interface SubagentEnv {
58
+ paneId: string;
59
+ tabId: string;
60
+ workspaceId: string;
61
+ }
62
+
63
+ export interface SubagentDeps {
64
+ client: HerdrClientLike;
65
+ env: SubagentEnv | null;
66
+ /** Extension entry (index.ts) path — launchLine `-e`. */
67
+ extPath: string;
68
+ /** Master tree root (scope mount). */
69
+ sessionRoot: Context;
70
+ port: SubagentPortBox;
71
+ getSessionId: () => string;
72
+ reconcileOnSettlement: (description: string, outcome: 'settled' | 'failed') => string[];
73
+ withReconcileNotes: (base: string, notes: readonly string[]) => string;
74
+ claimSettleNotice: (key: string) => boolean;
75
+ /** Settlement notice injector from index: buffer while busy, flush at turn_end. */
76
+ deliverNotice?: (content: string, paneId?: string) => Promise<void>;
77
+ /** Panes whose settlement notice is still buffered (GC exemption). */
78
+ noticePending?: () => ReadonlySet<string>;
79
+ /** Terminal-family GC exemption (live terminal panes are not collected). */
80
+ terminalState: TerminalStateSlot;
81
+ /** Optional jev seam (settle attribution check); absent → fail-open legacy behavior. */
82
+ jev?: { ask: JevRuntime['ask']; getMinConfidence: () => number };
83
+ /** Phase 0 routing telemetry (RFC rfc-jev-role-routing §8): spawn profile append; absent in tests. */
84
+ logRouting?: (row: RoutingTelemetryRecord) => void;
85
+ }
86
+
87
+ const SUBAGENT_DESCRIPTION = [
88
+ '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.',
89
+ '`action` (optional, default "spawn"): spawn | resume | list | send | interrupt | output | role.',
90
+ '[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.',
91
+ '[spawn] The subagent shares this workspace and works independently; the result is its final text answer.',
92
+ '[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.',
93
+ '[spawn] `run_in_background` (default false): when true, the call returns immediately with an agentId; the subagent keeps running in its pane. Use action list to see its state, action send to give it follow-up work, action interrupt to stop it. When it settles, you receive a notification message with its closing output.',
94
+ '[spawn] `tab` (optional): name of a task tab to place the subagent into (join if a tab with this name exists, otherwise create it). Overrides the default placement. Default placement groups by git worktree: a subagent working in your checkout shares your tab; one working in a separate worktree (pass its path via `cwd`; create worktrees with `git worktree add`) gets its own tab named after the worktree directory.',
95
+ '[spawn] `role` + `allowed_tools`: when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest — baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names remain display labels only.',
96
+ '[spawn] `isolate` (default false): creates a FRESH git worktree for this subagent and runs it there (branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/). Three-way choice: heavy independent writing in parallel with your own edits or other workers, or work needing its own clean reviewable diff → isolate; read-mostly or sequential helper work → omit (shared checkout, writes guarded by the write-lock); targeting an existing directory/worktree → cwd. In isolate mode the subagent\'s writes cannot conflict with your checkout; its panes group into a tab named after the worktree; its prompt is prefixed with commit discipline (commit to its own branch, NEVER push); when it settles you get a diff summary (commits since base, files changed, uncommitted count). Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd.',
97
+ '[resume] `taskId`: revive a finished (collected) subagent from the delegation ledger — opens its saved conversation in a new pane (pi --session), then use action send to give it new work. The ledger is an append-only JSONL file, one row per status change (same taskId rows = generations, latest row is current): fields taskId, description, status (running|settled|consumed|closed), outcome (closing text), paneId, sessionFile, launchCommand, createdAt. Ledgers are per-checkout at ~/.pi/agent/herdr-pi/history/<flattened-cwd>/history.jsonl, but resume searches every checkout this master has spawned into, not only the current cwd. A still-running task is not resumed — use action send or output. Use action list for live panes.',
98
+ '[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.',
99
+ '[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, and a truncated flag. When the delta cannot be computed the full text is returned with a "buffer scrolled or reset" note — that is normal for fullscreen TUI panes, not a crash indicator. Use this to observe subagent progress before settlement.',
100
+ '[interrupt] `agentId`: abort the current turn (fire-and-return). The pane stays; you can send again.',
101
+ '[role] `agentId` + `role`: switch that worker\'s role profile mid-session (pi 0.86 transcript tool delta — the new toolset applies on its next request and survives resume). Same role resolution as spawn; the reply reports the tool diff. Use when a task outgrew its delegation (needs more tools) or should be narrowed.',
102
+ '[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.',
103
+ ].join(' ');
104
+
105
+ const SUBAGENT_CONCURRENCY = 4;
106
+
107
+ function defaultAgentSessionsDir(): string {
108
+ const base = process.env.PI_CODING_AGENT_DIR || platformPaths.agentDataDir;
109
+ return join(base, 'sessions');
110
+ }
111
+
112
+ export default function subagentPlugin(ctx: Context): void {
113
+ const surface = cordisGet(ctx, 'pi-herdr.surface');
114
+ const d = cordisGet(ctx, 'pi-herdr.subagent-deps');
115
+ const { client, env, sessionRoot, port, terminalState } = d;
116
+ const pi = surface.raw as {
117
+ appendEntry?: (customType: string, data: unknown) => void;
118
+ /** Fire-and-forget in pi: the method returns void, not a promise. */
119
+ sendUserMessage?: (content: string, opts?: { deliverAs?: string }) => void;
120
+ };
121
+ const scoped = surface.forModule(import.meta.url);
122
+
123
+ const runtime = { nodePath: process.execPath, cliPath: process.argv[1] ?? '', extPath: d.extPath };
124
+ const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
125
+ /** B4: timestamps machine injection so grace-period working events are not mistaken for takeover. */
126
+ const lastMachineInjectAt = new Map<string, number>();
127
+ /* Registry persisted as custom branch state so a parent restart can rebuild it. */
128
+ const subs = new Map<string, SubEntry>();
129
+ const outputCursors = new Map<string, SubagentOutputCursor>();
130
+ /** D98: excludes branches during the worktree-add-to-registry race from orphan collection. */
131
+ const pendingIsolateBranches = new Set<string>();
132
+ const lastRequestIdByPane = new Map<string, string>();
133
+ /** Deduplicates gate notices while blocked but permits a later, distinct human question. */
134
+ const blockedGateNotified = new Set<string>();
135
+
136
+ /** B5: latest non-empty outcome per task, so closed generations do not hide settled results. */
137
+ const lastOutcomeByTask = new Map<string, string>();
138
+
139
+ function histFile(cwd: string): string {
140
+ return preferredHistoryFile(dirname(defaultAgentSessionsDir()), cwd);
141
+ }
142
+
143
+ function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
144
+ return {
145
+ taskId: e.taskId,
146
+ kind: e.kind,
147
+ paneId: e.paneId,
148
+ ...(e.slug ? { slug: e.slug } : {}),
149
+ tabId: e.tabId,
150
+ tabName: e.tabName,
151
+ workspaceId: env?.workspaceId ?? '',
152
+ cwd: e.cwd,
153
+ description: e.description,
154
+ sessionFile: e.sessionFile,
155
+ launchCommand: e.launchCommand,
156
+ status: e.status,
157
+ outcome,
158
+ createdAt: e.createdAt,
159
+ consumedAt: e.consumedAt ?? null,
160
+ closedAt: null,
161
+ revivedFrom: e.revivedFrom ?? null,
162
+ };
163
+ }
164
+
165
+ function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void {
166
+ const outcome = inheritOutcome(lastOutcomeByTask.get(e.taskId), patch?.outcome);
167
+ if (typeof outcome === 'string' && outcome.length > 0) lastOutcomeByTask.set(e.taskId, outcome);
168
+ appendHistory(histFile(e.cwd), { ...toHistory(e, outcome), ...(patch ?? {}), ...(via ? { via } : {}) });
169
+ }
170
+
171
+ const registry = createSubagentRegistry({ pi, client, subs, writeHistory });
172
+ const { persist: persistSubs } = registry;
173
+ const git = createGitIo();
174
+
175
+ const boundPort: SubagentPort = {
176
+ applyReplySession(paneId, sessionFile) {
177
+ const entry = subs.get(paneId);
178
+ if (!entry) return;
179
+ // Workers self-report a BARE session id over the pipe; the .jsonl-only guard in applyReportedSessionFile
180
+ // would discard it and freeze a mis-attributed sessionFile forever, so map the id to its transcript path.
181
+ const reported = resolveSessionFileValue(entry.cwd, defaultAgentSessionsDir(), sessionFile);
182
+ const next = applyReportedSessionFile(entry.sessionFile, reported);
183
+ if (next === entry.sessionFile) return;
184
+ entry.sessionFile = next;
185
+ persistSubs();
186
+ writeHistory(entry, undefined, 'session-report');
187
+ },
188
+ reconcileOnReply(paneId) {
189
+ const entry = subs.get(paneId);
190
+ return entry ? d.reconcileOnSettlement(entry.description, 'settled') : [];
191
+ },
192
+ listRunningSubs() {
193
+ return [...subs.values()]
194
+ .filter((s) => s.background && s.status === 'running')
195
+ .map((s) => ({ paneId: s.paneId, slug: s.slug, description: s.description, kind: s.kind }));
196
+ },
197
+ listRows() {
198
+ return [...subs.values()]
199
+ .filter((s) => s.status !== 'closed')
200
+ .map((s) => ({
201
+ paneId: s.paneId,
202
+ slug: s.slug,
203
+ description: s.description,
204
+ kind: s.kind,
205
+ status: s.status,
206
+ settledAt: s.consumedAt ?? null,
207
+ }));
208
+ },
209
+ async settleStatLine(paneId) {
210
+ const entry = subs.get(paneId);
211
+ return entry ? await git.worktreeStatLine(entry) : null;
212
+ },
213
+ consumeReply(paneId, outcome) {
214
+ const entry = subs.get(paneId);
215
+ if (!entry || entry.status === 'consumed' || entry.status === 'closed') return;
216
+ entry.status = 'consumed';
217
+ entry.consumedAt = Date.now();
218
+ persistSubs();
219
+ writeHistory(entry, { outcome: outcome ?? null, status: 'consumed' }, 'pipe-reply');
220
+ },
221
+ };
222
+ port.current = boundPort;
223
+ ctx.effect(() => () => {
224
+ if (port.current === boundPort) port.current = null;
225
+ }, 'subagent-port');
226
+
227
+ const injectNotice = (content: string): Promise<void> => {
228
+ if (d.deliverNotice) return d.deliverNotice(content);
229
+ pi.sendUserMessage?.(content, { deliverAs: 'followUp' });
230
+ return Promise.resolve();
231
+ };
232
+
233
+ const session = createSessionIo({ client, getSessionId: d.getSessionId, sessionsDir: defaultAgentSessionsDir });
234
+ const spawn = createSpawner({ client, env, runtime, git });
235
+ const poller = createPoller({
236
+ client,
237
+ sessionRoot,
238
+ subs,
239
+ persistSubs,
240
+ writeHistory,
241
+ blockedGateNotified,
242
+ lastMachineInjectAt,
243
+ session,
244
+ git,
245
+ injectNotice,
246
+ reconcileOnSettlement: d.reconcileOnSettlement,
247
+ withReconcileNotes: d.withReconcileNotes,
248
+ claimSettleNotice: d.claimSettleNotice,
249
+ ...(d.jev ? { jev: d.jev } : {}),
250
+ });
251
+ const { startPoller, pollers } = poller;
252
+ const gc = createGcController({
253
+ client,
254
+ env,
255
+ subs,
256
+ persistSubs,
257
+ writeHistory,
258
+ terminalState,
259
+ noticePending: d.noticePending,
260
+ pendingIsolateBranches,
261
+ git,
262
+ injectNotice,
263
+ });
264
+ gc.startTicker(ctx);
265
+ scoped.on('turn_start', () => gc.onTurnStart());
266
+ const { readAskFlag, probeAlive } = session;
267
+ const { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, findExistingPane } = spawn;
268
+
269
+ /** P0-2: re-arm pollers for live running panes after a master restart, or settle notices stop arriving
270
+ * and ledger rows stay 'running'. The `recover-*` requestId is never echoed by a worker, so this claim wins. */
271
+ async function recoverRunningPollers(via: string): Promise<void> {
272
+ if (!client.available) return;
273
+ const running = [...subs.values()].filter((s) => s.background && s.status === 'running' && !pollers.has(s.paneId));
274
+ if (running.length === 0) return;
275
+ let livePaneIds: ReadonlySet<string>;
276
+ try {
277
+ livePaneIds = new Set((await client.listPanes()).map((p) => p.paneId));
278
+ } catch {
279
+ return; // liveness lookup failed → do not guess
280
+ }
281
+ const recovered: string[] = [];
282
+ for (const entry of running) {
283
+ if (!livePaneIds.has(entry.paneId)) continue; // zombie sweep already closed those
284
+ try {
285
+ await startPoller(entry.paneId, entry.cwd, entry.createdAt, entry.description, `recover-${via}-${entry.paneId}`);
286
+ recovered.push(`${entry.paneId} (${entry.description})`);
287
+ } catch {
288
+ }
289
+ }
290
+ if (recovered.length > 0) {
291
+ try {
292
+ await injectNotice(
293
+ `Session recovery (${via}): ${recovered.length} background subagent(s) still running — settlement watch re-armed for: ${recovered.join('; ')}.`,
294
+ );
295
+ } catch { /* non-fatal */ }
296
+ }
297
+ }
298
+
299
+ scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
300
+ registry.rebuild(eventCtx);
301
+ await registry.sweepZombieRunning();
302
+ await recoverRunningPollers('session_start');
303
+ });
304
+ scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
305
+ registry.rebuild(eventCtx);
306
+ await registry.sweepZombieRunning();
307
+ await recoverRunningPollers('session_tree');
308
+ });
309
+
310
+ /** B1: probe liveness before a "no output" subagent error enters model context, so the model
311
+ * stops seizing work from a healthy subagent. */
312
+ scoped.on('tool_result', async (rawEvent: unknown) => {
313
+ const event = (rawEvent ?? {}) as { toolName?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> };
314
+ if (event.toolName !== 'subagent' || !event.isError) return;
315
+ // Extract only pane ids carried by our own errors; leave unrelated errors untouched.
316
+ const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
317
+ const paneId = [...subs.keys()].find((id) => errText.includes(id))
318
+ ?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
319
+ if (!paneId) return;
320
+ const entry = subs.get(paneId);
321
+ if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
322
+ const probe = await probeAlive(paneId, entry.cwd);
323
+ if (!isAlive(probe, Date.now())) return; // keep the original error only when the agent is truly dead
324
+ // Move a live agent to the poller so its eventual result replaces the false error.
325
+ if (!pollers.has(paneId)) {
326
+ entry.background = true;
327
+ startPoller(paneId, entry.cwd, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
328
+ persistSubs();
329
+ }
330
+ return {
331
+ content: [{ type: 'text', text: buildAliveNotice({ paneId, description: entry.description, scenario: 'error-alive', probe }, Date.now()) }],
332
+ };
333
+ });
334
+
335
+ async function reviveEntry(entry: SubEntry): Promise<SubEntry> {
336
+ // D98: never revive a released isolate worktree — its directory is gone and cwd is invalid.
337
+ if (entry.isolate?.releasedAt != null) {
338
+ throw new Error(`isolate worktree ${entry.isolate.branch} was released (merged) — delegate a new subagent instead`);
339
+ }
340
+ const latest = latestGeneration(readHistory(histFile(entry.cwd)), entry.taskId) ?? entry;
341
+ // Never relaunch the master's own transcript in a worker pane (two pi processes competing on
342
+ // one jsonl): a mis-attributed ledger entry degrades to a fresh conversation instead.
343
+ const resumeFile = latest.sessionFile && /\.jsonl$/.test(latest.sessionFile)
344
+ && bareSessionId(latest.sessionFile) !== bareSessionId(d.getSessionId())
345
+ ? latest.sessionFile : null;
346
+ // D86 trust matches spawn; revive has no tool context, so use the process cwd.
347
+ const approve = await approveFor(entry.cwd, process.cwd());
348
+ const spawned = await spawnPaneInTaskTab(
349
+ { desiredTab: entry.tabName || latest.tabName || null, description: entry.description },
350
+ entry.cwd,
351
+ {
352
+ PI_HERDR_SUBAGENT: '1',
353
+ [DELEGATION_ENV]: JSON.stringify({
354
+ parentPaneId: env?.paneId ?? '',
355
+ parentSessionId: bareSessionId(d.getSessionId()),
356
+ // Phase 2: report_progress needs the master's pipe; kind 'task' maps to the composed default.
357
+ parentPipe: pipeNameFor(process.cwd(), env?.paneId ?? ''),
358
+ taskId: entry.taskId,
359
+ description: entry.description,
360
+ role: entry.kind === 'task' ? 'worker-default' : entry.kind,
361
+ } satisfies DelegationContext),
362
+ },
363
+ launchLine(resumeFile, null, approve),
364
+ );
365
+ const ready = await waitSubReady(entry.cwd, spawned.paneId);
366
+ if (!ready.ok) throw new Error(ready.message);
367
+ Object.assign(entry, {
368
+ paneId: spawned.paneId,
369
+ tabId: spawned.tabId,
370
+ tabName: spawned.tabName,
371
+ sessionFile: resumeFile,
372
+ status: 'running' as const,
373
+ consumedAt: null,
374
+ revivedFrom: latest.paneId,
375
+ launchCommand: [launchLine(resumeFile, null, approve)],
376
+ createdAt: Date.now(),
377
+ });
378
+ writeHistory(entry, undefined, 'revive');
379
+ return entry;
380
+ }
381
+
382
+ /** Rebuild a registry row from its latest ledger row; `over` carries the caller's own fields
383
+ * (status, pane identity, timestamps) so the ledger projection cannot drift between callers. */
384
+ function entryFromHistory(h: HistoryEntry, over: Partial<SubEntry>): SubEntry {
385
+ return {
386
+ taskId: h.taskId,
387
+ kind: h.kind,
388
+ paneId: h.paneId,
389
+ ...(h.slug ? { slug: h.slug } : {}),
390
+ tabId: h.tabId,
391
+ tabName: h.tabName ?? '',
392
+ cwd: h.cwd,
393
+ description: h.description,
394
+ background: true,
395
+ status: 'closed',
396
+ sessionFile: h.sessionFile,
397
+ launchCommand: h.launchCommand,
398
+ createdAt: h.createdAt,
399
+ consumedAt: h.consumedAt ?? null,
400
+ revivedFrom: h.paneId,
401
+ ...over,
402
+ };
403
+ }
404
+
405
+ function resolveSubEntry(rawId: string, cwd?: string): { entry: SubEntry } | { error: string } {
406
+ if (!rawId) return { error: 'Error: unknown subagent id "" (see action list)' };
407
+ const direct = subs.get(rawId);
408
+ if (direct) return { entry: direct };
409
+ // Phase 2 slug reference: exact match against live rows (paneId stays first for back-compat).
410
+ // `slug@paneId` accepts both parts; a revived row changed its paneId, so the slug part wins then.
411
+ const at = rawId.indexOf('@');
412
+ const slugPart = at > 0 ? rawId.slice(0, at) : rawId;
413
+ const panePart = at > 0 ? rawId.slice(at + 1) : null;
414
+ const combined = panePart !== null
415
+ ? (subs.get(panePart) ?? [...subs.values()].find((s) => s.slug === slugPart && s.paneId === panePart) ?? null)
416
+ : null;
417
+ if (combined) return { entry: combined };
418
+ const bySlug = [...subs.values()].find((s) => s.slug === slugPart);
419
+ if (bySlug) return { entry: bySlug };
420
+
421
+ const byTaskId = newestPerTaskId(subs.values());
422
+ const res = resolveTaskIdPrefix(rawId, byTaskId.keys());
423
+ if (res.kind === 'resolved') return { entry: byTaskId.get(res.taskId)! };
424
+ const resError = taskIdResolutionError(res, 'subagent id', rawId);
425
+ if (resError) return { error: resError };
426
+
427
+ if (cwd) {
428
+ const hist = readHistory(histFile(cwd));
429
+ const histRes = resolveTaskIdPrefix(rawId, Array.from(new Set(hist.map((e) => e.taskId))));
430
+ if (histRes.kind === 'resolved') {
431
+ const latest = latestGeneration(hist, histRes.taskId);
432
+ if (latest) {
433
+ const entry = entryFromHistory(latest, {});
434
+ subs.set(entry.paneId, entry);
435
+ return { entry };
436
+ }
437
+ }
438
+ const histError = taskIdResolutionError(histRes, 'subagent id', rawId);
439
+ if (histError) return { error: histError };
440
+ }
441
+ return { error: `Error: unknown subagent id "${rawId}" (see action list)` };
442
+ }
443
+
444
+ function targetOf(params: ToolParams, toolCtx: unknown): { entry: SubEntry; cwd: string } {
445
+ const cwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
446
+ const resolved = resolveSubEntry(idParam(params, 'agentId', 'taskId'), cwd);
447
+ if ('error' in resolved) toolError(resolved.error);
448
+ return { entry: resolved.entry, cwd };
449
+ }
450
+
451
+ async function ensureLive(entry: SubEntry): Promise<void> {
452
+ if (entry.status !== 'closed') return;
453
+ const previousPaneId = entry.paneId;
454
+ await reviveEntry(entry);
455
+ rekeySub(subs, previousPaneId, entry);
456
+ persistSubs();
457
+ }
458
+
459
+ async function executeSubagentResume(params: ToolParams) {
460
+ if (!client.available) return toolError('requires a herdr-managed pane.');
461
+ const rawTaskId = idParam(params, 'taskId', 'agentId');
462
+ if (!rawTaskId) return toolError('missing taskId for resume (see action list or delegation ledger).');
463
+ const running = newestPerTaskId([...subs.values()].filter((s) => s.background && s.status === 'running'));
464
+ const live = resolveTaskIdPrefix(rawTaskId, running.keys());
465
+ const liveError = taskIdResolutionError(live, 'task id', rawTaskId);
466
+ if (liveError) return toolError(liveError);
467
+ if (live.kind === 'resolved') {
468
+ const entry = running.get(live.taskId)!;
469
+ return {
470
+ content: [{ type: 'text', text: `task ${entry.taskId} is already running as pane ${entry.paneId}. Use action send or output; resume is only for a collected task whose pane is gone.` }],
471
+ details: { paneId: entry.paneId, taskId: entry.taskId, alreadyRunning: true },
472
+ };
473
+ }
474
+ const history = readHistoryTree(dirname(defaultAgentSessionsDir()));
475
+ const resolution = resolveTaskIdPrefix(rawTaskId, history.map((e) => e.taskId));
476
+ if (resolution.kind !== 'resolved') {
477
+ return toolError(taskIdResolutionError(resolution, 'task id', rawTaskId)
478
+ ?? `Error: no history for task "${rawTaskId}" in this workspace or its spawned checkouts.`);
479
+ }
480
+ const taskId = resolution.taskId;
481
+ const latest = latestGeneration(history, taskId);
482
+ if (!latest) return toolError(`Error: no history for task "${taskId}" in this workspace or its spawned checkouts.`);
483
+
484
+ const release = await subSemaphore.acquire();
485
+ try {
486
+ // D94: reuse an existing pane for the same session to avoid competing pi processes.
487
+ const existing = await findExistingPane(latest.sessionFile);
488
+ // A ledger sessionFile mis-attributed to the master's own transcript would match the MASTER pane here,
489
+ // registering the master as its own subagent (later consumed, then closed by GC mid-run) — never adopt self.
490
+ if (existing?.paneId === env?.paneId) {
491
+ return toolError(
492
+ `Error: task ${taskId} is attributed to the master's own session (mis-recorded sessionFile in the ledger); it cannot be resumed here — spawn a fresh subagent for this work instead.`,
493
+ );
494
+ }
495
+ const entry = entryFromHistory(latest, {
496
+ paneId: existing?.paneId ?? '',
497
+ tabId: existing?.tabId ?? '',
498
+ tabName: latest.tabName ?? tabNameForTask(latest.description),
499
+ status: 'running',
500
+ createdAt: Date.now(),
501
+ consumedAt: null,
502
+ });
503
+ if (existing) {
504
+ subs.set(entry.paneId, entry);
505
+ persistSubs();
506
+ writeHistory(entry, undefined, 'resume');
507
+ } else {
508
+ await reviveEntry(entry);
509
+ subs.set(entry.paneId, entry);
510
+ persistSubs();
511
+ }
512
+ const how = existing
513
+ ? 'reused existing pane with same session; pi still running there'
514
+ : entry.sessionFile ? 'session restored' : 'session file missing; fresh conversation';
515
+ return {
516
+ content: [{ type: 'text', text: `resumed subagent ${subRef(entry)} from task ${taskId} (${how}).` }],
517
+ details: { paneId: entry.paneId, taskId },
518
+ };
519
+ } catch (err) {
520
+ if (err instanceof ToolError) throw err;
521
+ return toolError(`Error: failed to resume task "${taskId}": ${(err as Error).message}`);
522
+ } finally {
523
+ release();
524
+ }
525
+ }
526
+
527
+ /** The shared spine of every id-carrying action: revive a closed row, wait for its pipe, send one request,
528
+ * fold failures into the error wording. `interrupt` passes `ready: false` (a closed target is a no-op). */
529
+ async function pipeAction(
530
+ entry: SubEntry,
531
+ opts: {
532
+ /** One request from the closed pipe protocol; its `type`/`id` drive the reply and settle keys. */
533
+ build(): PipeRequest;
534
+ ok(res: Extract<PipeResponse, { type: 'ok' }>, reqId: string): unknown;
535
+ fail?(paneId: string): string;
536
+ ready?: false;
537
+ },
538
+ ): Promise<unknown> {
539
+ try {
540
+ if (opts.ready !== false) {
541
+ await ensureLive(entry);
542
+ const ready = await waitSubReady(entry.cwd, entry.paneId);
543
+ if (!ready.ok) throw new Error(ready.message);
544
+ }
545
+ const payload = opts.build();
546
+ const res = await pipeRequestTo(entry.cwd, entry.paneId, payload);
547
+ if (res.type !== 'ok') {
548
+ throw new Error(`pipe ${payload.type} rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
549
+ }
550
+ return opts.ok(res, payload.id);
551
+ } catch (err) {
552
+ const what = opts.fail?.(entry.paneId) ?? `failed to reach subagent ${entry.paneId}`;
553
+ return toolError(`Error: ${what}: ${(err as Error).message}`);
554
+ }
555
+ }
556
+
557
+ /** Follow_up goes through the extension pipe with steering so supplemental instructions reach a
558
+ * long-running worker within seconds during a tool-call gap; a plain queue waits for the run. */
559
+ function executeSubagentSend(params: ToolParams, toolCtx: unknown) {
560
+ const { entry, cwd } = targetOf(params, toolCtx);
561
+ return pipeAction(entry, {
562
+ build: () => ({
563
+ type: 'follow_up',
564
+ id: `fu-${Date.now()}`,
565
+ text: String(params?.message ?? ''),
566
+ // The reply pipe is the MASTER's (bound at session_start with this session's cwd);
567
+ // entry.cwd would name a dead pipe for cross-repo workers.
568
+ from: pipeNameFor(cwd, env?.paneId ?? ''),
569
+ push: true,
570
+ steer: true,
571
+ }),
572
+ ok: (_res, reqId) => {
573
+ lastMachineInjectAt.set(entry.paneId, Date.now()); // B4: attribute working state during the observation window
574
+ // An exception between status='running' and startPoller would leave a ghost running entry
575
+ // with no poller (never settles, skipped by GC): roll back the status on failure.
576
+ const prevStatus = entry.status;
577
+ entry.status = 'running';
578
+ persistSubs();
579
+ try {
580
+ lastRequestIdByPane.set(entry.paneId, reqId);
581
+ startPoller(entry.paneId, entry.cwd, Date.now(), entry.description, reqId);
582
+ } catch (inner) {
583
+ entry.status = prevStatus;
584
+ persistSubs();
585
+ throw inner;
586
+ }
587
+ return { content: [{ type: 'text', text: `Message sent to subagent ${subRef(entry)}.` }], details: { paneId: entry.paneId } };
588
+ },
589
+ });
590
+ }
591
+
592
+ async function executeSubagentInterrupt(params: ToolParams, toolCtx: unknown) {
593
+ const { entry } = targetOf(params, toolCtx);
594
+ if (entry.status === 'closed') {
595
+ return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${subRef(entry)} (already idle/closed; no-op).` }], details: {} };
596
+ }
597
+ return pipeAction(entry, {
598
+ ready: false,
599
+ build: () => ({ type: 'interrupt', id: `int-${Date.now()}` }),
600
+ ok: () => {
601
+ // No settlement notice for an interrupted turn: the requester already knows.
602
+ const lastId = lastRequestIdByPane.get(entry.paneId);
603
+ if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
604
+ return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${subRef(entry)} (fire-and-return).` }], details: { paneId: entry.paneId } };
605
+ },
606
+ });
607
+ }
608
+
609
+ async function executeSubagentRole(params: ToolParams, toolCtx: unknown) {
610
+ const roleName = String(params?.role ?? '').trim();
611
+ if (!roleName) return toolError('Error: action "role" requires a target profile in `role` (same resolution as spawn)');
612
+ const { entry } = targetOf(params, toolCtx);
613
+ return pipeAction(entry, {
614
+ fail: (id) => `failed to switch subagent ${id} to role ${roleName}`,
615
+ build: () => ({ type: 'role', id: `role-${Date.now()}`, role: roleName }),
616
+ ok: (res) => ({
617
+ content: [{ type: 'text', text: `Subagent ${entry.paneId}: ${res.detail ?? `switched to ${roleName}`}` }],
618
+ details: { paneId: entry.paneId, role: roleName },
619
+ }),
620
+ });
621
+ }
622
+
623
+ /** Live background registry, newest row per task; closed rows stay visible so send can revive them. */
624
+ async function executeSubagentList() {
625
+ const listed = [...subs.values()].filter((sub) => sub.background);
626
+ if (listed.length === 0) {
627
+ return { content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }], details: {} };
628
+ }
629
+ const byTask = newestPerTaskId(listed);
630
+ const probes = new Map<string, AliveProbe>();
631
+ await Promise.all([...byTask.values()].map(async (sub) => {
632
+ if (sub.status === 'closed') return;
633
+ try {
634
+ probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd));
635
+ } catch { /* best effort for display */ }
636
+ }));
637
+ const now = Date.now();
638
+ const lines: string[] = [];
639
+ for (const sub of byTask.values()) {
640
+ const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
641
+ const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
642
+ if (sub.status === 'closed') {
643
+ lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; action send revives)${tabTag}${wtTag} ${sub.description}`);
644
+ continue;
645
+ }
646
+ if (sub.status === 'consumed' || sub.status === 'settled') {
647
+ lines.push(`${subRef(sub)} [idle] (${sub.kind}, settled; action send revives)${tabTag}${wtTag} ${sub.description}`);
648
+ continue;
649
+ }
650
+ const state = 'running';
651
+ const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
652
+ const probe = probes.get(sub.paneId);
653
+ const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
654
+ const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, now)}` : '';
655
+ let gateTag = '';
656
+ if (probe?.agentStatus === 'blocked') {
657
+ const question = await readAskFlag(sub.paneId);
658
+ gateTag = question ? ` — AWAITING HUMAN: "${question}"` : ' — AWAITING HUMAN decision';
659
+ }
660
+ const cwdTag = probe?.foregroundCwd && probe.foregroundCwd !== sub.cwd
661
+ ? ` [cwd: ${basename(probe.foregroundCwd) || probe.foregroundCwd}]`
662
+ : '';
663
+ lines.push(`${subRef(sub)} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag}${cwdTag} ${sub.description}`);
664
+ }
665
+ return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
666
+ }
667
+
668
+ const executeSubagentSpawn = createSpawnAction({
669
+ client,
670
+ env,
671
+ subSemaphore,
672
+ subs,
673
+ persistSubs,
674
+ writeHistory,
675
+ pendingIsolateBranches,
676
+ lastMachineInjectAt,
677
+ lastRequestIdByPane,
678
+ git,
679
+ session,
680
+ spawn,
681
+ poller,
682
+ getSessionId: d.getSessionId,
683
+ ...(d.logRouting ? { logRouting: d.logRouting } : {}),
684
+ });
685
+
686
+ const outputActionDeps = {
687
+ client,
688
+ resolveEntry: (rawId: string, cwd: string) => resolveSubEntry(rawId, cwd),
689
+ outputCursors,
690
+ getCwd: (toolCtx: unknown): string => (toolCtx as { cwd?: string })?.cwd ?? process.cwd(),
691
+ // Transcript fallback: small worker panes show only the opaque status overlay, so the pane delta can never
692
+ // carry the final report. No preferred file — start from herdr's per-pane report, not a stale registry value.
693
+ readFinalReport: async (paneId: string, entryCwd: string) =>
694
+ (await session.subSessionState(paneId, entryCwd, 0)).text,
695
+ };
696
+
697
+ const ACTIONS: Record<string, (params: ToolParams, toolCtx: unknown, onUpdate?: (u: unknown) => void) => Promise<unknown>> = {
698
+ spawn: (params, toolCtx, onUpdate) => executeSubagentSpawn(params, toolCtx, onUpdate),
699
+ resume: (params) => executeSubagentResume(params),
700
+ list: () => executeSubagentList(),
701
+ send: (params, toolCtx) => executeSubagentSend(params, toolCtx),
702
+ interrupt: (params, toolCtx) => executeSubagentInterrupt(params, toolCtx),
703
+ output: (params, toolCtx) => executeSubagentOutput(params, toolCtx, outputActionDeps),
704
+ role: (params, toolCtx) => executeSubagentRole(params, toolCtx),
705
+ };
706
+
707
+ scoped.registerTool({
708
+ name: 'subagent',
709
+ label: 'Subagent',
710
+ // B4: the model picks tools from the system prompt's snippet/guideline surface; delegation
711
+ // decisions are made here, so state them rather than hoping for recall.
712
+ promptSnippet: 'subagent: delegate a self-contained task to a pi worker pane (spawn/isolate/background/send/output/interrupt/resume/list/role).',
713
+ promptGuidelines: [
714
+ '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.',
715
+ '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.',
716
+ '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).',
717
+ '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.',
718
+ '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.',
719
+ 'Send follow-up instructions with action: "send" (delivered as a steer between tool calls) instead of spawning a second worker for the same task.',
720
+ 'Pass agentId, taskId, message, and max_chars as top-level arguments. Do not wrap them in a parameters object.',
721
+ ],
722
+ description: SUBAGENT_DESCRIPTION,
723
+ parameters: Type.Object({
724
+ action: Type.Optional(Type.Union([
725
+ Type.Literal('spawn'),
726
+ Type.Literal('resume'),
727
+ Type.Literal('list'),
728
+ Type.Literal('send'),
729
+ Type.Literal('interrupt'),
730
+ Type.Literal('output'),
731
+ Type.Literal('role'),
732
+ ], { description: 'Operation to perform (default: spawn)' })),
733
+ description: Type.Optional(Type.String({ description: '[spawn] Short label for this subtask (pane title)' })),
734
+ prompt: Type.Optional(Type.String({ description: '[spawn] The complete self-contained task for the subagent' })),
735
+ run_in_background: Type.Optional(Type.Boolean({ description: '[spawn] Return immediately with an agentId; the subagent keeps running in its own pane (default false)' })),
736
+ cwd: Type.Optional(Type.String({ description: '[spawn] Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add. Use isolate:true instead when you want a FRESH worktree created for this task rather than targeting an existing one' })),
737
+ isolate: Type.Optional(Type.Boolean({ description: '[spawn] Create a fresh git worktree and run the subagent there. Use when the task writes files heavily and independently — in parallel with your own edits or other workers\' — or needs its own clean, reviewable diff. For read-mostly or sequential helper work omit it (shared checkout, writes guarded by the write-lock); use `cwd` only to target an existing directory/worktree (e.g. a retained pier worktree). Mechanics: branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/; its writes cannot conflict with your checkout; panes group into a tab named after the worktree; the prompt is prefixed with commit discipline (commit to its branch, never push); on settle you get a diff summary. Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd' })),
738
+ role: Type.Optional(Type.String({ description: '[spawn|role] Role label or profile name. [spawn] When role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest. Unknown role names remain display labels only. [role] Target profile for the mid-session switch' })),
739
+ 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.' })),
740
+ allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Additional tools for role composition (union with role baseline)' })),
741
+ taskId: Type.Optional(Type.String({ description: '[resume] The task id to revive from the delegation ledger' })),
742
+ agentId: Type.Optional(Type.String({ description: '[send|interrupt|output|role] The subagent reference: task slug (e.g. "fix-auth"), herdr pane id, or "slug@paneId" as printed by spawn/list' })),
743
+ message: Type.Optional(Type.String({ description: '[send] The follow-up message' })),
744
+ max_chars: Type.Optional(Type.Integer({ description: '[output] Maximum characters of output delta to return (default 6000, 100-16000)' })),
745
+ }),
746
+ outputSchema: SUBAGENT_OUTPUT_SCHEMA,
747
+ // B2: `action` is optional in the schema; normalize it so the validated arguments and the
748
+ // code path never disagree and `spawn` is explicit in logs.
749
+ prepareArguments: (args: unknown) => {
750
+ const rec = flattenToolParams(args && typeof args === 'object' ? args as Record<string, unknown> : {}) ?? {};
751
+ const hasAction = typeof rec.action === 'string' && rec.action.trim() !== '';
752
+ return hasAction ? rec : { ...rec, action: 'spawn' };
753
+ },
754
+ async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, toolCtx: unknown) {
755
+ void toolCallId;
756
+ void signal;
757
+ const flat = flattenToolParams(params);
758
+ const action = typeof flat?.action === 'string' && flat.action.trim() ? flat.action.trim() : 'spawn';
759
+ const handler = ACTIONS[action];
760
+ if (!handler) {
761
+ return toolError(`Error: unknown action "${action}" (valid: ${Object.keys(ACTIONS).join(', ')})`);
762
+ }
763
+ return attachSubagentStructuredContent(action, await handler(flat, toolCtx, onUpdate));
764
+ },
765
+ });
766
+ }