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
@@ -1,633 +0,0 @@
1
- /**
2
- * core/subagent — master-only loader entry (tools, registry, poller, GC).
3
- *
4
- * Inbound deps via `pi-herdr.subagent-deps`. Outbound pipe/settle queries bind
5
- * atomically onto `port.current` (see subagent-port.ts). Settlement reconcile
6
- * stays in index session state; this plugin consumes it through the port.
7
- */
8
- import { Context } from '@deepseek-ai/cordis';
9
- import { Type } from 'typebox';
10
- import { join, dirname } from 'node:path';
11
- import type { PiSurface } from '../pi-surface.ts';
12
- import type { HerdrClientLike } from '../herdr-client.ts';
13
- import { Semaphore, buildAliveNotice, isAlive, tabNameForTask, type SubEntry } from '../subagent-core.ts';
14
- import { applyReportedSessionFile, appendHistory, preferredHistoryFile, inheritOutcome, latestGeneration, readHistory, type HistoryEntry } from '../history-store.ts';
15
- import { platformPaths } from '../platform-paths.ts';
16
- import type { SubagentPort, SubagentPortBox } from '../subagent-port.ts';
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;;
21
- import { pipeNameFor, pipeRequestTo } from '../pipe-channel.ts';
22
- import type { TerminalStateSlot } from './terminal.ts';
23
- import { createGitIo } from '../subagent-git-io.ts';
24
- import { createSessionIo } from '../subagent-session-io.ts';
25
- import { createSpawner } from '../subagent-spawn.ts';
26
- import { createPoller } from '../subagent-poll-loop.ts';
27
- import { createGcController } from '../subagent-gc.ts';
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';
34
-
35
- interface SubagentEnv {
36
- paneId: string;
37
- tabId: string;
38
- workspaceId: string;
39
- }
40
-
41
- interface SubagentDeps {
42
- client: HerdrClientLike;
43
- env: SubagentEnv | null;
44
- /** Extension entry (index.ts) path — launchLine `-e`. */
45
- extPath: string;
46
- /** Master tree root (scope mount). */
47
- sessionRoot: Context;
48
- port: SubagentPortBox;
49
- getSessionId: () => string;
50
- reconcileOnSettlement: (description: string, outcome: 'settled' | 'failed') => string[];
51
- withReconcileNotes: (base: string, notes: readonly string[]) => string;
52
- claimSettleNotice: (key: string) => boolean;
53
- /**
54
- * Settlement notice injector from index: buffer while busy, flush at turn_end.
55
- * Tests may omit it and fall back to pi.sendUserMessage(followUp).
56
- */
57
- deliverNotice?: (content: string, paneId?: string) => Promise<void>;
58
- /** Panes whose settlement notice is still buffered (GC exemption). */
59
- noticePending?: () => ReadonlySet<string>;
60
- /** Terminal-family GC exemption (live terminal panes are not collected). */
61
- terminalState: TerminalStateSlot;
62
- }
63
-
64
- const SUBAGENT_DESCRIPTION = [
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.',
66
- '`action` (optional, default "spawn"): spawn | resume | list | send | interrupt | output.',
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.',
68
- '[spawn] The subagent shares this workspace and works independently; the result is its final text answer.',
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.',
70
- '[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.',
71
- '[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.',
72
- '[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.',
73
- '[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.',
74
- '[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. It is per-checkout at ~/.pi/agent/herdr-pi/history/<flattened-cwd>/history.jsonl. Use action list for live panes from this session; for earlier sessions or closed panes, grep the ledger for the taskId.',
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.',
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.',
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.',
79
- ].join(' ');
80
-
81
- /** Subagent concurrency limit (max parallel delegations) */
82
- const SUBAGENT_CONCURRENCY = 4;
83
-
84
- function defaultAgentSessionsDir(): string {
85
- const base = process.env.PI_CODING_AGENT_DIR || platformPaths.agentDataDir;
86
- return join(base, 'sessions');
87
- }
88
-
89
- function agentRootDir(): string {
90
- return dirname(defaultAgentSessionsDir());
91
- }
92
-
93
-
94
-
95
- export default function subagentPlugin(ctx: Context): void {
96
- const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
97
- const d = ctx.get('pi-herdr.subagent-deps') as SubagentDeps;
98
- const { client, env, sessionRoot, port, terminalState } = d;
99
- const pi = surface.raw as {
100
- appendEntry?: (customType: string, data: unknown) => void;
101
- sendUserMessage?: (content: string, opts?: { deliverAs?: string }) => Promise<void>;
102
- };
103
- const scoped = surface.forModule(import.meta.url);
104
-
105
- const runtime = {
106
- nodePath: process.execPath,
107
- cliPath: process.argv[1] ?? '',
108
- extPath: d.extPath,
109
- };
110
- const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
111
- /** B4 timestamps machine injection so grace-period working events are not mistaken for takeover. */
112
- const lastMachineInjectAt = new Map<string, number>();
113
- /* Persist the subagent registry as custom branch state so parent restarts can rebuild it. */
114
- const subs = new Map<string, SubEntry>();
115
- /** In-memory cursor for incremental subagent output observation. */
116
- const outputCursors = new Map<string, SubagentOutputCursor>();
117
- /** D98 excludes branches during worktree-add-to-registry races from orphan collection. */
118
- const pendingIsolateBranches = new Set<string>();
119
- /** D50 tracks the latest machine request per pane for interrupt claims and poll deduplication. */
120
- const lastRequestIdByPane = new Map<string, string>();
121
-
122
- const registry = createSubagentRegistry({ pi, client, subs, writeHistory });
123
- const { persist: persistSubs } = registry;
124
- /** E2 deduplicates gate notices while blocked but permits a later, distinct human question. */
125
- const blockedGateNotified = new Set<string>();
126
- const git = createGitIo();
127
-
128
- const boundPort: SubagentPort = {
129
- applyReplySession(paneId, sessionFile) {
130
- const entry = subs.get(paneId);
131
- if (!entry) return;
132
- const next = applyReportedSessionFile(entry.sessionFile, sessionFile);
133
- if (next === entry.sessionFile) return;
134
- entry.sessionFile = next;
135
- persistSubs();
136
- writeHistory(entry, undefined, 'session-report');
137
- },
138
- reconcileOnReply(paneId) {
139
- const entry = subs.get(paneId);
140
- return entry ? d.reconcileOnSettlement(entry.description, 'settled') : [];
141
- },
142
- listRunningSubs() {
143
- return [...subs.values()]
144
- .filter((s) => s.background && s.status === 'running')
145
- .map((s) => ({ paneId: s.paneId, description: s.description }));
146
- },
147
- async settleStatLine(paneId) {
148
- const entry = subs.get(paneId);
149
- return entry ? await git.worktreeStatLine(entry) : null;
150
- },
151
- };
152
- port.current = boundPort;
153
- ctx.effect(() => () => {
154
- if (port.current === boundPort) port.current = null;
155
- }, 'subagent-port');
156
- scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
157
- registry.rebuild(eventCtx);
158
- await registry.sweepZombieRunning();
159
- });
160
- scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
161
- registry.rebuild(eventCtx);
162
- await registry.sweepZombieRunning();
163
- });
164
-
165
- /* ── B1 liveness rewrite for subagent errors ─────────────────────
166
- * A false no-output result caused the model to seize work from a healthy subagent. Probe
167
- * agent.list and session mtime before the tool result enters model context; if alive, emit the
168
- * same notice as A2 backgrounding. This hook enforces what prompting alone could not. */
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;
172
- // Extract only pane ids carried by our own errors; leave unrelated errors untouched.
173
- const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
174
- const paneId = [...subs.keys()].find((id) => errText.includes(id))
175
- ?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
176
- if (!paneId) return;
177
- const entry = subs.get(paneId);
178
- if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
179
- const probe = await probeAlive(paneId, entry.cwd);
180
- if (!isAlive(probe, Date.now())) return; // Preserve the original error only when the agent is truly dead.
181
- // Move a live agent to the poller so its eventual result can replace the false error.
182
- if (!pollers.has(paneId)) {
183
- entry.background = true;
184
- startPoller(paneId, entry.cwd, entry.createdAt, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
185
- persistSubs();
186
- }
187
- const notice = buildAliveNotice(
188
- { paneId, description: entry.description, scenario: 'error-alive', probe },
189
- Date.now(),
190
- );
191
- return { content: [{ type: 'text', text: notice }] };
192
- });
193
-
194
- function histFile(cwd: string): string {
195
- return preferredHistoryFile(agentRootDir(), cwd);
196
- }
197
-
198
- function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
199
- return {
200
- taskId: e.taskId,
201
- kind: e.kind,
202
- paneId: e.paneId,
203
- tabId: e.tabId,
204
- tabName: e.tabName,
205
- workspaceId: env?.workspaceId ?? '',
206
- cwd: e.cwd,
207
- description: e.description,
208
- sessionFile: e.sessionFile,
209
- launchCommand: e.launchCommand,
210
- status: e.status,
211
- outcome,
212
- createdAt: e.createdAt,
213
- consumedAt: e.consumedAt ?? null,
214
- closedAt: null,
215
- revivedFrom: e.revivedFrom ?? null,
216
- };
217
- }
218
-
219
- /** B5 retains the latest non-empty outcome because closed generations otherwise hid settled
220
- * results behind outcome:null in latest-generation views. */
221
- const lastOutcomeByTask = new Map<string, string>();
222
-
223
- function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void {
224
- const outcome = inheritOutcome(lastOutcomeByTask.get(e.taskId), patch?.outcome);
225
- if (typeof outcome === 'string' && outcome.length > 0) lastOutcomeByTask.set(e.taskId, outcome);
226
- appendHistory(histFile(e.cwd), { ...toHistory(e, outcome), ...(patch ?? {}), ...(via ? { via } : {}) });
227
- }
228
-
229
- const injectNotice = (content: string): Promise<void> =>
230
- d.deliverNotice ? d.deliverNotice(content)
231
- : (pi.sendUserMessage?.(content, { deliverAs: 'followUp' }) ?? Promise.resolve());
232
-
233
- const session = createSessionIo({
234
- client,
235
- getSessionId: d.getSessionId,
236
- sessionsDir: defaultAgentSessionsDir,
237
- });
238
- const spawn = createSpawner({ client, env, runtime, git });
239
- const poller = createPoller({
240
- client,
241
- sessionRoot,
242
- subs,
243
- persistSubs,
244
- writeHistory,
245
- blockedGateNotified,
246
- lastMachineInjectAt,
247
- session,
248
- git,
249
- injectNotice,
250
- reconcileOnSettlement: d.reconcileOnSettlement,
251
- withReconcileNotes: d.withReconcileNotes,
252
- claimSettleNotice: d.claimSettleNotice,
253
- });
254
- const gc = createGcController({
255
- client,
256
- env,
257
- subs,
258
- persistSubs,
259
- writeHistory,
260
- terminalState,
261
- noticePending: d.noticePending,
262
- pendingIsolateBranches,
263
- git,
264
- injectNotice,
265
- });
266
- gc.startTicker(ctx);
267
- scoped.on('turn_start', () => gc.onTurnStart());
268
-
269
- const { readAskFlag, probeAlive } = session;
270
- const { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, findExistingPane } = spawn;
271
- const { startPoller, pollers } = poller;
272
-
273
- /** Revive a closed task; resume and automatic send_message revival share this path. */
274
- async function reviveEntry(entry: SubEntry): Promise<SubEntry> {
275
- // D98: Do not revive a released isolate worktree because its directory is gone and cwd is invalid.
276
- if (entry.isolate?.releasedAt != null) {
277
- throw new Error(`isolate worktree ${entry.isolate.branch} was released (merged) — delegate a new subagent instead`);
278
- }
279
- const latest = latestGeneration(readHistory(histFile(entry.cwd)), entry.taskId) ?? entry;
280
- const resumeFile = latest.sessionFile && /\.jsonl$/.test(latest.sessionFile) ? latest.sessionFile : null;
281
- // D86 trust matches spawn: pass -a only for the master's checkout/worktrees; revive has no tool context, so use process cwd.
282
- const approve = await approveFor(entry.cwd, process.cwd());
283
- const spawned = await spawnPaneInTaskTab(
284
- { desiredTab: entry.tabName || latest.tabName || null, description: entry.description },
285
- entry.cwd,
286
- { PI_HERDR_SUBAGENT: '1' },
287
- launchLine(resumeFile, null, approve),
288
- );
289
- const ready = await waitSubReady(entry.cwd, spawned.paneId);
290
- if (!ready.ok) throw new Error(ready.message);
291
- entry.paneId = spawned.paneId;
292
- entry.tabId = spawned.tabId;
293
- entry.tabName = spawned.tabName;
294
- entry.sessionFile = resumeFile;
295
- entry.status = 'running';
296
- entry.consumedAt = null;
297
- entry.revivedFrom = latest.paneId;
298
- entry.launchCommand = [launchLine(resumeFile, null, approve)];
299
- entry.createdAt = Date.now();
300
- writeHistory(entry, undefined, 'revive');
301
- return entry;
302
- }
303
-
304
-
305
- async function executeSubagentResume(params: Record<string, unknown> | undefined, toolCtx: unknown) {
306
- if (!client.available) {
307
- return toolError('requires a herdr-managed pane.');
308
- }
309
- const cwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
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);
329
- if (!latest) {
330
- return toolError(`Error: no history for task "${taskId}" in this workspace.`);
331
- }
332
- const release = await subSemaphore.acquire();
333
- try {
334
- // D94: Reuse an existing pane for the same session to avoid competing pi processes.
335
- const existing = await findExistingPane(latest.sessionFile);
336
- if (existing) {
337
- const entry: SubEntry = {
338
- taskId,
339
- kind: latest.kind,
340
- paneId: existing.paneId,
341
- tabId: existing.tabId,
342
- tabName: latest.tabName ?? tabNameForTask(latest.description),
343
- cwd,
344
- description: latest.description,
345
- background: true,
346
- status: 'running',
347
- sessionFile: latest.sessionFile ?? null,
348
- launchCommand: latest.launchCommand,
349
- createdAt: Date.now(),
350
- revivedFrom: latest.paneId,
351
- consumedAt: null,
352
- };
353
- subs.set(entry.paneId, entry);
354
- persistSubs();
355
- writeHistory(entry, undefined, 'resume');
356
- return {
357
- content: [{
358
- type: 'text',
359
- text: `resumed subagent ${entry.paneId} from task ${taskId} (reused existing pane with same session; pi still running there).`,
360
- }],
361
- details: { paneId: entry.paneId, taskId },
362
- };
363
- }
364
- // Create a new pane only when no existing one can be reused.
365
- const entry: SubEntry = {
366
- taskId,
367
- kind: latest.kind,
368
- paneId: '',
369
- tabId: '',
370
- tabName: latest.tabName ?? tabNameForTask(latest.description),
371
- cwd,
372
- description: latest.description,
373
- background: true,
374
- status: 'running',
375
- sessionFile: latest.sessionFile,
376
- launchCommand: latest.launchCommand,
377
- createdAt: Date.now(),
378
- revivedFrom: latest.paneId,
379
- consumedAt: null,
380
- };
381
- await reviveEntry(entry);
382
- subs.set(entry.paneId, entry);
383
- persistSubs();
384
- return {
385
- content: [{
386
- type: 'text',
387
- text: entry.sessionFile
388
- ? `resumed subagent ${entry.paneId} from task ${taskId} (session restored).`
389
- : `resumed subagent ${entry.paneId} from task ${taskId} (session file missing; fresh conversation).`,
390
- }],
391
- details: { paneId: entry.paneId, taskId },
392
- };
393
- } catch (err) {
394
- return toolError(`Error: failed to resume task "${taskId}": ${(err as Error).message}`);
395
- } finally {
396
- release();
397
- }
398
- }
399
-
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 };
458
- }
459
- }
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)` };
468
- }
469
-
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);
476
- }
477
- const entry = resolved.entry;
478
- const spawnedAt = Date.now();
479
- try {
480
- // A closed task is revived automatically because the pane is only a temporary host.
481
- if (entry.status === 'closed') {
482
- await reviveEntry(entry);
483
- subs.set(entry.paneId, entry);
484
- persistSubs();
485
- }
486
- // M11 (D46): follow_up uses the extension pipe. B3 adds steering so supplemental instructions reach a long-running worker
487
- // within seconds during a tool-call gap; the old followUp queue waited for the entire run and caused 20-minute rework (01a03c0d).
488
- const ready = await waitSubReady(entry.cwd, entry.paneId);
489
- if (!ready.ok) throw new Error(ready.message);
490
- const fuId = `fu-${Date.now()}`;
491
- const res = await pipeRequestTo(entry.cwd, entry.paneId, {
492
- type: 'follow_up',
493
- id: fuId,
494
- text: String(params?.message ?? ''),
495
- from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
496
- push: true,
497
- steer: true,
498
- });
499
- if (res.type !== 'ok') {
500
- throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
501
- }
502
- lastMachineInjectAt.set(entry.paneId, Date.now()); // B4: attribute working state during the observation window.
503
- // Regression hardening (D98 liveness evidence): an exception between status='running' and startPoller could leave a ghost
504
- // running entry without a poller, never settling and skipped by GC; roll back to the previous status on failure.
505
- const prevStatus = entry.status;
506
- entry.status = 'running';
507
- persistSubs();
508
- try {
509
- lastRequestIdByPane.set(entry.paneId, fuId);
510
- startPoller(entry.paneId, entry.cwd, spawnedAt, Date.now(), entry.description, fuId);
511
- } catch (inner) {
512
- entry.status = prevStatus;
513
- persistSubs();
514
- throw inner;
515
- }
516
- return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
517
- } catch (err) {
518
- return toolError(`Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}`);
519
- }
520
- }
521
-
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);
528
- }
529
- const entry = resolved.entry;
530
- if (entry.status === 'closed') {
531
- // DSH alignment: an idle or finished target is an idempotent no-op.
532
- return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
533
- }
534
- try {
535
- const res = await pipeRequestTo(entry.cwd, entry.paneId, {
536
- type: 'interrupt',
537
- id: `int-${Date.now()}`,
538
- });
539
- if (res.type !== 'ok') {
540
- throw new Error(`pipe interrupt rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
541
- }
542
- // Do not send a settlement notice for an interrupted turn because the requester already knows.
543
- const lastId = lastRequestIdByPane.get(entry.paneId);
544
- if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
545
- return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };
546
- } catch (err) {
547
- return toolError(`Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}`);
548
- }
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
-
573
-
574
- scoped.registerTool({
575
- name: 'subagent',
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
- ],
588
- description: SUBAGENT_DESCRIPTION,
589
- parameters: Type.Object({
590
- action: Type.Optional(Type.Union([
591
- Type.Literal('spawn'),
592
- Type.Literal('resume'),
593
- Type.Literal('list'),
594
- Type.Literal('send'),
595
- Type.Literal('interrupt'),
596
- Type.Literal('output'),
597
- ], { description: 'Operation to perform (default: spawn)' })),
598
- description: Type.Optional(Type.String({ description: '[spawn] Short label for this subtask (pane title)' })),
599
- prompt: Type.Optional(Type.String({ description: '[spawn] The complete self-contained task for the subagent' })),
600
- run_in_background: Type.Optional(Type.Boolean({ description: '[spawn] Return immediately with an agentId; the subagent keeps running in its own pane (default false)' })),
601
- 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' })),
602
- 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' })),
603
- role: Type.Optional(Type.String({ description: '[spawn] Role label or profile name. 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.' })),
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.' })),
605
- allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Additional tools for role composition (union with role baseline)' })),
606
- taskId: Type.Optional(Type.String({ description: '[resume] The task id to revive from the delegation ledger' })),
607
- agentId: Type.Optional(Type.String({ description: '[send|interrupt|output] The subagent id (herdr pane id)' })),
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)' })),
610
- }),
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) {
619
- void toolCallId;
620
- void signal;
621
- const action = typeof params?.action === 'string' && params.action.trim() ? params.action.trim() : 'spawn';
622
- if (action === 'resume') return executeSubagentResume(params, toolCtx);
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);
627
- if (action !== 'spawn') {
628
- return toolError(`Error: unknown action "${action}" (valid: spawn, resume, list, send, interrupt, output)`);
629
- }
630
- return executeSubagentSpawn(params, toolCtx, onUpdate);
631
- },
632
- });
633
- }