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.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +12 -0
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +60 -10
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +121 -47
  19. package/src/core/todo.ts +25 -11
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -1
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +3 -1
  33. package/src/index.ts +330 -34
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +4 -2
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +82 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -7,35 +7,30 @@
7
7
  */
8
8
  import { Context } from '@deepseek-ai/cordis';
9
9
  import { Type } from 'typebox';
10
- import { randomUUID } from 'node:crypto';
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, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildIsolatePreamble, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isAlive, isPathUnder, makeProgressUpdate, makeRegistry, planIsolateWorktree, tabNameForTask, type AliveProbe, type SubEntry } from '../subagent-core.ts';
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
- FOREGROUND_POLL_MS,
21
- planForegroundTick,
22
- planIsolateRepoGuard,
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
- /** O1 suppresses duplicate snapshots because 01a03c0d found 48% of session rows were heartbeat repeats. */
125
- let lastSubsSnapshot = '';
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
- rebuildSubs(eventCtx);
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
- rebuildSubs(eventCtx);
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 (event: { toolName?: string; toolCallId?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> }) => {
216
- if (event?.toolName !== 'subagent' || !event.isError) return;
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 { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = session;
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(`revived pane ${spawned.paneId} pipe not ready`);
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 taskId = String(params?.taskId ?? '');
361
- const latest = latestGeneration(readHistory(histFile(cwd)), taskId);
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
- async function executeSubagentList() {
440
- const listed = [...subs.values()].filter((s) => s.background);
441
- if (listed.length === 0) {
442
- return { content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }], details: {} };
443
- }
444
- // Deduplicate by taskId so only the newest generation appears after revival; old paneIds do not repeat.
445
- const byTask = new Map<string, SubEntry>();
446
- for (const sub of listed) {
447
- const prev = byTask.get(sub.taskId);
448
- if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
449
- }
450
- // C1: Probe liveness in real time (pane status and session activity) so the master can distinguish work from a true hang.
451
- const probes = new Map<string, AliveProbe>();
452
- await Promise.all([...byTask.values()].map(async (sub) => {
453
- if (sub.status === 'closed') return;
454
- try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* Best effort for display. */ }
455
- }));
456
- const lines: string[] = [];
457
- for (const sub of byTask.values()) {
458
- const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
459
- // D98: Include the worktree branch on isolate entries, including closed ones; settlement stats already report merge state.
460
- const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
461
- if (sub.status === 'closed') {
462
- lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; action send revives)${tabTag}${wtTag} ${sub.description}`);
463
- continue;
464
- }
465
- // The registry is authoritative: settled is idle, everything else is running, including blocked human gates (DSH mapping).
466
- const state = sub.status === 'settled' ? 'idle' : 'running';
467
- // D94: Mark when the user has taken over.
468
- const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
469
- const probe = probes.get(sub.paneId);
470
- const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
471
- const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
472
- // E: For blocked agents, include the gate-question summary so the master sends the user to that pane instead of taking over.
473
- let gateTag = '';
474
- if (probe?.agentStatus === 'blocked') {
475
- const q = await readAskFlag(sub.paneId);
476
- gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
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
- return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
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 entry = subs.get(String(params?.agentId ?? ''));
485
- if (!entry) {
486
- return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see action list)` }], details: {} };
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(`subagent pane ${entry.paneId} pipe not ready`);
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 { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
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 entry = subs.get(String(params?.agentId ?? ''));
534
- if (!entry) {
535
- return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see action list)` }], details: {} };
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 { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
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
- async execute(toolCallId, params, signal, onUpdate, toolCtx) {
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 { content: [{ type: 'text', text: `Error: unknown action "${action}" (valid: spawn, resume, list, send, interrupt)` }], details: {} };
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
  }