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,30 +1,138 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D10). */
1
+ /**
2
+ * Subagent domain: registry rows, launch lines, liveness notices, tab/worktree planning, isolate
3
+ * decisions, task-id resolution, and the outbound port types. Pure: planners decide, adapters do I/O.
4
+ */
2
5
  import { normalizeEntryKind } from './history-store.ts';
6
+ import { Type } from 'typebox';
7
+
8
+ export function sleep(ms: number): Promise<void> {
9
+ const { promise, resolve } = Promise.withResolvers<void>();
10
+ setTimeout(resolve, ms);
11
+ return promise;
12
+ }
3
13
 
4
14
  export interface SubagentSpec {
5
15
  description: string;
6
16
  prompt: string;
7
17
  }
8
18
 
9
- /** Why: Preserve the established compatibility and safety behavior. */
10
- export function psQuote(s: string): string {
11
- return `'${String(s).replace(/'/g, "''")}'`;
12
- }
13
-
14
- /** Why: Preserve the established compatibility and safety behavior. */
15
- export function shQuote(s: string): string {
16
- return `'${String(s).replace(/'/g, `'\\''`)}'`;
17
- }
18
-
19
- /** Why: Preserve the established compatibility and safety behavior. */
19
+ /** POSIX single-quote escape; win32 PowerShell doubles the quote instead. */
20
20
  export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platform = process.platform): string {
21
- const quoted = platform === 'win32'
22
- ? parts.map((s) => psQuote(s))
23
- : parts.map((s) => shQuote(s));
24
- return (platform === 'win32' ? '& ' : '') + quoted.join(' ');
25
- }
26
-
27
- /** Why: Preserve the established compatibility and safety behavior. */
21
+ const win = platform === 'win32';
22
+ const quoted = parts.map((s) => (win
23
+ ? `'${String(s).replace(/'/g, "''")}'`
24
+ : `'${String(s).replace(/'/g, `'\\''`)}'`));
25
+ return (win ? '& ' : '') + quoted.join(' ');
26
+ }
27
+
28
+ /** Shells that mean a freshly split pane is at its prompt and can accept a launch line. */
29
+ const SHELL_PROCESS_NAMES: ReadonlySet<string> = new Set([
30
+ 'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'mksh', 'tcsh', 'csh', 'nu', 'login',
31
+ 'pwsh', 'powershell', 'cmd',
32
+ ]);
33
+
34
+ /** Normalize `name` / `argv0` (`/bin/zsh`, `C:\\Windows\\System32\\cmd.exe`, `-zsh`) to a shell key. */
35
+ export function shellProcessName(value: string | null | undefined): string {
36
+ const base = String(value ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '';
37
+ const stripped = base.replace(/^-/, '').replace(/\.exe$/, '');
38
+ return stripped;
39
+ }
40
+
41
+ /**
42
+ * Decide whether a pane's foreground set means its shell is ready for input. `null`/`undefined`
43
+ * means the probe was unavailable: return ready so an older herdr never adds spawn latency.
44
+ * An empty set is not proof (the shell may still be starting), so callers wait for the timeout.
45
+ */
46
+ export function planShellReady(
47
+ foreground: ReadonlyArray<{ name?: string | null; argv0?: string | null }> | null | undefined,
48
+ ): 'ready' | 'wait' {
49
+ if (foreground == null) return 'ready';
50
+ if (foreground.length === 0) return 'wait';
51
+ return foreground.some((p) => SHELL_PROCESS_NAMES.has(shellProcessName(p.name)) || SHELL_PROCESS_NAMES.has(shellProcessName(p.argv0)))
52
+ ? 'ready'
53
+ : 'wait';
54
+ }
55
+
56
+ /** Flags pier passes at spawn (buildLaunchParts) that a herdr 0.9.2+ resume command must replay. */
57
+ const RESUME_FLAG_ARITY: Record<string, number> = {
58
+ '-a': 0,
59
+ '-e': 1,
60
+ '--extension': 1,
61
+ '--tui-mode': 1,
62
+ '--provider': 1,
63
+ '--model': 1,
64
+ '--session': 1,
65
+ };
66
+
67
+ const RESUME_MAX_ARGS = 64;
68
+ const RESUME_MAX_BYTES = 8 * 1024;
69
+
70
+ /**
71
+ * herdr 0.9.2 self-report (docs "Add Herdr support to your agent"): rebuild this worker's launch
72
+ * line as a `resume_argv` herdr runs after a server restart. The worker's own process.argv is the
73
+ * source of truth — interpreter and cli.js paths are dropped, whitelisted spawn flags are mirrored
74
+ * (`--flag=value` splits), argv[0] becomes a plain PATH command name. herdr rules: argv[0] plain,
75
+ * no apostrophes/control characters, ≤64 args, ≤8 KiB; violations (or a missing `-e`, which could
76
+ * not restore the extension) return null and the caller skips the report — restore stays best-effort.
77
+ */
78
+ export function buildResumeArgv(workerArgv: readonly string[], piBin = 'pi'): string[] | null {
79
+ if (!/^[A-Za-z0-9._-]+$/.test(piBin)) return null;
80
+ const collected: string[] = [];
81
+ let index = 0;
82
+ // Skip interpreter + cli script (everything before the first flag).
83
+ while (index < workerArgv.length && !workerArgv[index]!.startsWith('-')) index++;
84
+ for (; index < workerArgv.length; index++) {
85
+ const raw = workerArgv[index]!;
86
+ if (!raw.startsWith('-')) continue; // positional (unknown flag's value) — not pier spawn shape
87
+ const eq = raw.indexOf('=');
88
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
89
+ const inlineValue = eq > 0 ? raw.slice(eq + 1) : null;
90
+ const arity = RESUME_FLAG_ARITY[flag];
91
+ if (arity === undefined) continue; // pi-internal flags are not replayed
92
+ if (arity === 0) {
93
+ collected.push(flag);
94
+ } else if (inlineValue !== null) {
95
+ collected.push(flag, inlineValue);
96
+ } else if (index + 1 < workerArgv.length) {
97
+ collected.push(flag, workerArgv[++index]!);
98
+ }
99
+ }
100
+ if (!collected.some((f, i) => (f === '-e' || f === '--extension') && i + 1 < collected.length)) return null;
101
+ const argv = [piBin, ...collected];
102
+ if (argv.length > RESUME_MAX_ARGS) return null;
103
+ if (argv.some((a) => /['\u0000-\u001f\u007f]/.test(a))) return null;
104
+ if (argv.reduce((sum, a) => sum + a.length + 1, 0) > RESUME_MAX_BYTES) return null;
105
+ return argv;
106
+ }
107
+
108
+ /**
109
+ * pi ≥ 0.99 outputSchema for the subagent tool: the `details` each action already produces, stamped
110
+ * with the normalized action. Codemode scripts (and any structured consumer) read this instead of
111
+ * parsing the prose reply; the text content stays authoritative for the transcript. Hard failures
112
+ * throw (toolError), so every returned value is a success and always carries structuredContent.
113
+ */
114
+ export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
115
+ action: Type.Union([
116
+ Type.Literal('spawn'), Type.Literal('resume'), Type.Literal('list'),
117
+ Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'),
118
+ ]),
119
+ paneId: Type.Optional(Type.String()),
120
+ taskId: Type.Optional(Type.String()),
121
+ role: Type.Optional(Type.String()),
122
+ background: Type.Optional(Type.Boolean()),
123
+ alreadyRunning: Type.Optional(Type.Boolean()),
124
+ }, { additionalProperties: true });
125
+
126
+ export function attachSubagentStructuredContent(action: string, result: unknown): unknown {
127
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) return result;
128
+ const record = result as Record<string, unknown>;
129
+ const details = record.details !== null && typeof record.details === 'object' && !Array.isArray(record.details)
130
+ ? record.details as Record<string, unknown>
131
+ : {};
132
+ return { ...record, structuredContent: { action, ...details } };
133
+ }
134
+
135
+ /** Bounds concurrent delegation spawns (SUBAGENT_CONCURRENCY). */
28
136
  export class Semaphore {
29
137
  private active = 0;
30
138
  private queue: Array<() => void> = [];
@@ -44,12 +152,12 @@ export class Semaphore {
44
152
  this.active++;
45
153
  return Promise.resolve(this.release.bind(this));
46
154
  }
47
- return new Promise((resolve) => {
48
- this.queue.push(() => {
49
- this.active++;
50
- resolve(this.release.bind(this));
51
- });
155
+ const { promise, resolve } = Promise.withResolvers<() => void>();
156
+ this.queue.push(() => {
157
+ this.active++;
158
+ resolve(this.release.bind(this));
52
159
  });
160
+ return promise;
53
161
  }
54
162
 
55
163
  private release(): void {
@@ -59,19 +167,15 @@ export class Semaphore {
59
167
  }
60
168
  }
61
169
 
62
- export interface SubagentOutcome {
170
+ interface SubagentOutcome {
63
171
  kind: 'completed' | 'timeout' | 'no-output' | 'blocked' | 'spawn-failed';
64
- /** Why: Preserve the established compatibility and safety behavior. */
65
172
  text: string;
66
173
  }
67
174
 
68
- /** Why: Preserve the established compatibility and safety behavior. */
69
175
  export function formatSubagentResult(outcome: SubagentOutcome, description: string): string {
70
176
  switch (outcome.kind) {
71
- case 'completed': {
72
- const text = outcome.text;
73
- return text ? text : 'Subagent finished but produced no output.';
74
- }
177
+ case 'completed':
178
+ return outcome.text ? outcome.text : 'Subagent finished but produced no output.';
75
179
  case 'timeout':
76
180
  return `Error: subagent "${description}" timed out. Partial output:\n${outcome.text || '(none)'}`;
77
181
  case 'blocked':
@@ -83,27 +187,25 @@ export function formatSubagentResult(outcome: SubagentOutcome, description: stri
83
187
  }
84
188
  }
85
189
 
86
- /** Why: Preserve the established compatibility and safety behavior (A2, B1). */
87
-
88
- /** Why: Preserve the established compatibility and safety behavior. */
89
190
  export interface AliveProbe {
90
191
  paneExists: boolean;
91
192
  agentStatus: string | null;
92
- /** Why: Preserve the established compatibility and safety behavior. */
93
193
  lastActivityMs: number | null;
94
194
  /** Herdr 0.9.1: foreground working directory of the PTY process. */
95
195
  foregroundCwd?: string | null;
96
196
  }
97
197
 
98
- /** Why: Preserve the established compatibility and safety behavior. */
198
+ /**
199
+ * A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
200
+ * age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
201
+ * the caller's to report, not guessed here.
202
+ */
99
203
  export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
100
204
  if (!probe.paneExists) return false;
101
205
  if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
102
- // Why: Preserve the established compatibility and safety behavior.
103
206
  return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
104
207
  }
105
208
 
106
- /** Why: Preserve the established compatibility and safety behavior. */
107
209
  export function agoText(ms: number, nowMs: number): string {
108
210
  const s = Math.max(0, Math.round((nowMs - ms) / 1000));
109
211
  if (s < 60) return `${s}s ago`;
@@ -111,7 +213,6 @@ export function agoText(ms: number, nowMs: number): string {
111
213
  return `${Math.floor(s / 3600)}h ago`;
112
214
  }
113
215
 
114
- /** Why: Preserve the established compatibility and safety behavior (A2, B1). */
115
216
  export function buildAliveNotice(
116
217
  opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
117
218
  nowMs: number,
@@ -134,7 +235,7 @@ export function buildAliveNotice(
134
235
  ].join(' ');
135
236
  }
136
237
 
137
- /** Why: Preserve the established compatibility and safety behavior (E1, E2). */
238
+ /** A pane blocked on a human decision keeps running: the master must not take the work over. */
138
239
  export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
139
240
  const { paneId, description, question } = opts;
140
241
  const q = question ? ` (question: "${question}")` : '';
@@ -147,35 +248,33 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
147
248
  ].join(' ');
148
249
  }
149
250
 
150
- /** Why: Preserve the established compatibility and safety behavior. */
151
-
152
251
  export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
153
252
 
154
253
  export interface SubEntry {
155
254
  taskId: string;
156
- /** Why: Preserve the established compatibility and safety behavior. */
255
+ /** 'task' or a role name; only a display label for GC. */
157
256
  kind: string;
158
257
  paneId: string;
258
+ /** Phase 2 task slug (kebab-case of description; RFC rfc-delegation-identity §3.1): tool args
259
+ * accept `slug@paneId` or the bare slug; allocated once at spawn and stable across revives. */
260
+ slug?: string;
159
261
  tabId: string;
160
- /** Why: Preserve the established compatibility and safety behavior (D25, D26). */
161
262
  tabName: string;
162
263
  cwd: string;
163
264
  description: string;
164
265
  background: boolean;
165
266
  status: 'running' | 'settled' | 'consumed' | 'closed';
166
- /** Why: Preserve the established compatibility and safety behavior. */
267
+ /** GC grace basis (durable); closed rows keep the original value. */
167
268
  consumedAt?: number | null;
168
269
  sessionFile: string | null;
169
270
  launchCommand: string[];
170
271
  createdAt: number;
171
272
  revivedFrom?: string | null;
172
- /** Why: Preserve the established compatibility and safety behavior (D94). */
273
+ /** D94: the human took the pane over; observation window judges when to hand control back. */
173
274
  userTakeover?: boolean;
174
- /** Why: Preserve the established compatibility and safety behavior (D94). */
175
275
  observationStartedAt?: number | null;
176
- /** Why: Preserve the established compatibility and safety behavior (D94). */
177
276
  lastAgentStatus?: string | null;
178
- /** Why: Preserve the established compatibility and safety behavior (D98). */
277
+ /** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
179
278
  isolate?: {
180
279
  worktreePath: string;
181
280
  branch: string;
@@ -185,6 +284,41 @@ export interface SubEntry {
185
284
  };
186
285
  }
187
286
 
287
+ /* ── task slugs (RFC rfc-delegation-identity §3.1) ───────────────── */
288
+
289
+ /** Slug cap: short enough for tool args and list rows, long enough to stay distinctive. */
290
+ const SLUG_MAX = 24;
291
+
292
+ /** Kebab-case a task description; non-ASCII (CJK descriptions) sanitizes to '' — the allocator
293
+ * then falls back to a taskId-derived ascii slug, because tool args must stay typeable. */
294
+ export function slugifyTask(description: string): string {
295
+ return description
296
+ .toLowerCase()
297
+ .replace(/[''']/g, '')
298
+ .replace(/[^a-z0-9]+/g, '-')
299
+ .replace(/^-+|-+$/g, '')
300
+ .slice(0, SLUG_MAX)
301
+ .replace(/-+$/g, '');
302
+ }
303
+
304
+ /** Allocate a unique slug among live rows: base, then base-2, base-3, …; a description that
305
+ * sanitizes to nothing (CJK, emoji-only) falls back to `task-<taskId prefix>`. */
306
+ export function allocateSubSlug(description: string, taskId: string, taken: ReadonlySet<string>): string {
307
+ const base = slugifyTask(description) || `task-${taskId.slice(0, 6)}`;
308
+ if (!taken.has(base)) return base;
309
+ for (let n = 2; ; n++) {
310
+ const next = `${base}-${n}`;
311
+ if (!taken.has(next)) return next;
312
+ }
313
+ }
314
+
315
+ /** Stable display/argument form for a subagent row: `slug@paneId` when a slug exists. */
316
+ export function subRef(sub: { paneId: string; slug?: string }): string {
317
+ return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
318
+ }
319
+
320
+ export const PROGRESS_CUSTOM_TYPE = 'pi-herdr.progress';
321
+
188
322
  export interface SubsRegistry {
189
323
  version: 2;
190
324
  subs: SubEntry[];
@@ -194,14 +328,15 @@ export function makeRegistry(subs: SubEntry[] = []): SubsRegistry {
194
328
  return { version: 2, subs };
195
329
  }
196
330
 
197
- /** Why: Preserve the established compatibility and safety behavior. */
198
- export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegistry {
331
+ const SUB_STATUSES: Record<string, true> = { running: true, settled: true, consumed: true, closed: true };
332
+
333
+ /** Replay `pi-herdr.subs` custom branch entries into a registry; last snapshot wins. Tolerant of
334
+ * v1 rows (missing taskId/kind/tabName) and unknown future fields. */
335
+ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customType?: string; data?: unknown }>): SubsRegistry {
199
336
  let found = makeRegistry();
200
337
  for (const entry of entries) {
201
338
  if (entry.type !== 'custom' || entry.customType !== SUBS_CUSTOM_TYPE) continue;
202
- const data = entry.data as {
203
- subs?: Array<Partial<SubEntry> & { paneId: string }>;
204
- } | undefined;
339
+ const data = entry.data as { subs?: Array<Partial<SubEntry> & { paneId: string }> } | undefined;
205
340
  if (!data || !Array.isArray(data.subs)) continue;
206
341
  const subs: SubEntry[] = data.subs
207
342
  .filter((s) => typeof s?.paneId === 'string')
@@ -209,12 +344,15 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
209
344
  taskId: typeof s.taskId === 'string' ? s.taskId : s.paneId,
210
345
  kind: normalizeEntryKind(typeof s.kind === 'string' ? s.kind : undefined),
211
346
  paneId: s.paneId,
347
+ slug: typeof s.slug === 'string' && s.slug ? s.slug : undefined,
212
348
  tabId: typeof s.tabId === 'string' ? s.tabId : '',
213
349
  tabName: typeof s.tabName === 'string' ? s.tabName : '',
214
350
  cwd: typeof s.cwd === 'string' ? s.cwd : '',
215
351
  description: typeof s.description === 'string' ? s.description : '',
216
352
  background: s.background === true,
217
- status: s.status === 'closed' ? 'closed' : s.status === 'consumed' ? 'consumed' : s.status === 'settled' ? 'settled' : 'running',
353
+ status: typeof s.status === 'string' && SUB_STATUSES[s.status] === true
354
+ ? (s.status as SubEntry['status'])
355
+ : 'running',
218
356
  consumedAt: typeof s.consumedAt === 'number' ? s.consumedAt : null,
219
357
  sessionFile: typeof s.sessionFile === 'string' ? s.sessionFile : null,
220
358
  launchCommand: Array.isArray(s.launchCommand) ? s.launchCommand : [],
@@ -240,36 +378,75 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
240
378
  return found;
241
379
  }
242
380
 
243
- export interface BranchEntryLike2 {
244
- type?: string;
245
- customType?: string;
246
- data?: unknown;
381
+ /** Latest row per taskId (same taskId rows are generations); newest createdAt wins. */
382
+ export function newestPerTaskId(entries: Iterable<SubEntry>): Map<string, SubEntry> {
383
+ const byTask = new Map<string, SubEntry>();
384
+ for (const sub of entries) {
385
+ const prev = byTask.get(sub.taskId);
386
+ if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
387
+ }
388
+ return byTask;
389
+ }
390
+
391
+ /** Re-key a revived entry under its new pane id; the dead id must go, or a later zombie sweep closes the live row. */
392
+ export function rekeySub(subs: Map<string, SubEntry>, previousPaneId: string, entry: SubEntry): void {
393
+ if (previousPaneId !== entry.paneId && subs.get(previousPaneId) === entry) subs.delete(previousPaneId);
394
+ subs.set(entry.paneId, entry);
395
+ }
396
+
397
+ /** Split `provider/model` on the first '/' only: model ids such as `moonshotai/kimi-k2` carry their own. */
398
+ export function splitRoleModel(roleModel: string): { provider: string | null; model: string } {
399
+ const slash = roleModel.indexOf('/');
400
+ if (slash <= 0 || slash === roleModel.length - 1) return { provider: null, model: roleModel };
401
+ return { provider: roleModel.slice(0, slash), model: roleModel.slice(slash + 1) };
247
402
  }
248
403
 
249
- /** Why: Preserve the established compatibility and safety behavior. */
404
+ /** pi's AgentToolResult shape — a bare string crashes the interactive TUI's getTextOutput. */
405
+ export function makeProgressUpdate(msg: string) {
406
+ return { content: [{ type: 'text' as const, text: msg }], details: {} as Record<string, never> };
407
+ }
250
408
 
251
- export interface ProgressUpdate {
252
- content: Array<{ type: 'text'; text: string }>;
253
- details: Record<string, never>;
409
+ /**
410
+ * Models sometimes wrap fields in a `parameters` object (JSON-schema habit). Top-level wins;
411
+ * only missing keys are filled from the nested bag so a real field is never overwritten.
412
+ */
413
+ export function flattenToolParams(params: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
414
+ if (!params) return params;
415
+ const nested = params.parameters;
416
+ if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) return params;
417
+ const out: Record<string, unknown> = { ...params };
418
+ for (const [key, value] of Object.entries(nested as Record<string, unknown>)) {
419
+ if (key === 'parameters') continue;
420
+ const current = out[key];
421
+ if (current == null || current === '') out[key] = value;
422
+ }
423
+ return out;
254
424
  }
255
425
 
256
- /** Why: Preserve the established compatibility and safety behavior. */
257
- export function makeProgressUpdate(msg: string): ProgressUpdate {
258
- return { content: [{ type: 'text', text: msg }], details: {} };
426
+
427
+ export function idParam(params: Record<string, unknown> | undefined, ...keys: string[]): string {
428
+ const flat = flattenToolParams(params);
429
+ for (const key of keys) {
430
+ const v = flat?.[key];
431
+ if (v != null && String(v).trim() !== '') return String(v).trim();
432
+ }
433
+ return '';
259
434
  }
260
435
 
261
- /** Why: Preserve the established compatibility and safety behavior (D25, D26). */
436
+ export function ambiguousIdError(label: 'task id' | 'subagent id', query: string, candidates: readonly string[]): string {
437
+ const head = candidates.slice(0, 5).join(', ');
438
+ const more = candidates.length > 5 ? `, ... (+${candidates.length - 5} more)` : '';
439
+ return `Error: ambiguous ${label} "${query}" matches ${candidates.length} tasks: ${head}${more}`;
440
+ }
262
441
 
263
442
  export const TAB_NAME_MAX = 20;
264
443
 
265
- /** Why: Preserve the established compatibility and safety behavior. */
266
444
  export function tabNameForTask(description: string): string {
267
445
  const cleaned = String(description ?? '').replace(/\s+/g, ' ').trim();
268
446
  const truncated = cleaned.slice(0, TAB_NAME_MAX).trim();
269
447
  return truncated || 'task';
270
448
  }
271
449
 
272
- /** Why: Preserve the established compatibility and safety behavior (D26). */
273
450
  export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>): string {
274
451
  let name = base;
275
452
  let n = 2;
@@ -282,16 +459,12 @@ export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>
282
459
  }
283
460
 
284
461
  export interface TabPlacementPlan {
285
- /** Why: Preserve the established compatibility and safety behavior. */
286
462
  mode: 'append' | 'new';
287
463
  tabName: string;
288
- /** Why: Preserve the established compatibility and safety behavior. */
289
464
  tabId: string | null;
290
465
  }
291
466
 
292
- /** Why: Preserve the established compatibility and safety behavior (D86). */
293
-
294
- /** Why: Preserve the established compatibility and safety behavior (D86). */
467
+ /** Case/slash-insensitive containment; `/repo-x` is NOT under `/repo`. */
295
468
  export function isPathUnder(cwd: string, wt: string): boolean {
296
469
  const n = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
297
470
  const a = n(cwd);
@@ -300,13 +473,11 @@ export function isPathUnder(cwd: string, wt: string): boolean {
300
473
  }
301
474
 
302
475
  export interface WorktreeZone {
303
- /** Why: Preserve the established compatibility and safety behavior. */
304
476
  zone: 'main' | 'worktree';
305
- /** Why: Preserve the established compatibility and safety behavior. */
306
477
  tabName: string | null;
307
478
  }
308
479
 
309
- /** Why: Preserve the established compatibility and safety behavior (D86). */
480
+ /** D86: subagents working in the master's checkout share its tab; another worktree gets its own. */
310
481
  export function classifyWorktreeZone(opts: {
311
482
  cwd: string;
312
483
  masterCwd: string;
@@ -314,21 +485,19 @@ export function classifyWorktreeZone(opts: {
314
485
  }): WorktreeZone {
315
486
  for (const wt of opts.worktrees) {
316
487
  if (!isPathUnder(opts.cwd, wt)) continue;
317
- if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null }; // Why: Preserve the established compatibility and safety behavior.
488
+ if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null };
318
489
  const base = wt.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'worktree';
319
490
  return { zone: 'worktree', tabName: base };
320
491
  }
321
492
  return { zone: 'main', tabName: null };
322
493
  }
323
494
 
324
- /** Why: Preserve the established compatibility and safety behavior (D26, D86). */
495
+ /** Explicit `tab` wins; otherwise D86 zone decides, and an unknown zone falls back to the description. */
325
496
  export function planTabPlacement(opts: {
326
497
  desiredTab?: string | null;
327
498
  description: string;
328
499
  knownTabs: ReadonlyArray<{ tabName: string; tabId: string }>;
329
- /** Why: Preserve the established compatibility and safety behavior (D86). */
330
500
  zone?: WorktreeZone;
331
- /** Why: Preserve the established compatibility and safety behavior (D86). */
332
501
  mainTabId?: string | null;
333
502
  }): TabPlacementPlan {
334
503
  const known = new Map<string, string>();
@@ -340,35 +509,21 @@ export function planTabPlacement(opts: {
340
509
  }
341
510
  const zone = opts.zone;
342
511
  if (zone?.zone === 'main' && opts.mainTabId) {
343
- // Why: Preserve the established compatibility and safety behavior (D86, R1).
344
512
  return { mode: 'append', tabName: 'main', tabId: opts.mainTabId };
345
513
  }
346
514
  if (zone?.zone === 'worktree' && zone.tabName) {
347
- // Why: Preserve the established compatibility and safety behavior (D86, R1).
348
515
  const base = zone.tabName;
349
516
  const existing = [...known.keys()].find((k) => k.toLowerCase() === base.toLowerCase());
350
517
  if (existing) return { mode: 'append', tabName: existing, tabId: known.get(existing)! };
351
- const tabName = nextTaskTabName(base, new Set(known.keys()));
352
- return { mode: 'new', tabName, tabId: null };
518
+ return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
353
519
  }
354
- // Why: Preserve the established compatibility and safety behavior.
355
520
  const base = tabNameForTask(opts.description);
356
- const tabName = nextTaskTabName(base, new Set(known.keys()));
357
- return { mode: 'new', tabName, tabId: null };
521
+ return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
358
522
  }
359
523
 
360
- /** Why: Preserve the established compatibility and safety behavior (D97). */
361
-
362
- /** Why: Preserve the established compatibility and safety behavior. */
363
- export interface LaunchRuntime {
364
- nodePath: string;
365
- cliPath: string;
366
- extPath: string;
367
- }
368
-
369
- /** Why: Preserve the established compatibility and safety behavior (D97). */
524
+ /** D97: fullscreen is what makes the pane a static frame the master can read. */
370
525
  export function buildLaunchParts(
371
- runtime: LaunchRuntime,
526
+ runtime: { nodePath: string; cliPath: string; extPath: string },
372
527
  opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
373
528
  env: { PI_HERDR_TUI?: string | undefined } = process.env,
374
529
  ): string[] {
@@ -376,29 +531,28 @@ export function buildLaunchParts(
376
531
  if (opts.approve) parts.push('-a');
377
532
  parts.push('-e', runtime.extPath);
378
533
  if (env.PI_HERDR_TUI !== 'regular') parts.push('--tui-mode', 'fullscreen');
379
- if (opts.roleModel) parts.push('--provider', opts.roleModel.split('/')[0], '--model', opts.roleModel.split('/')[1] ?? opts.roleModel);
534
+ if (opts.roleModel) {
535
+ const { provider, model } = splitRoleModel(opts.roleModel);
536
+ if (provider) parts.push('--provider', provider);
537
+ parts.push('--model', model);
538
+ }
380
539
  if (opts.resumeFile) parts.push('--session', opts.resumeFile);
381
540
  return parts;
382
541
  }
383
542
 
384
- /** Why: Preserve the established compatibility and safety behavior (D98). */
543
+ const ISOLATE_SLUG_MAX = 40;
385
544
 
386
- /** Why: Preserve the established compatibility and safety behavior. */
387
- export interface IsolatePlan {
388
- branch: string;
389
- slug: string;
390
- worktreeDirName: string;
391
- }
392
-
393
- /** Why: Preserve the established compatibility and safety behavior. */
394
- export const ISOLATE_SLUG_MAX = 40;
395
-
396
- /** Why: Preserve the established compatibility and safety behavior (D98). */
545
+ /**
546
+ * D-3 (verified 2026-10-20): herdr 0.9.5 `worktree create --workspace <parent>` still opens a new
547
+ * top-level workspace, not a tab inside the parent workspace, so it cannot replace pier's isolate
548
+ * placement. Keep `git worktree add` + the master-workspace tab; native worktree management remains
549
+ * an option only for workflows that accept a separate workspace.
550
+ */
397
551
  export function planIsolateWorktree(opts: {
398
552
  description: string;
399
553
  taskHex: string;
400
554
  existingPierBranches: ReadonlySet<string>;
401
- }): IsolatePlan {
555
+ }) {
402
556
  const folded = String(opts.description ?? '')
403
557
  .toLowerCase()
404
558
  .replace(/[^a-z0-9]+/g, '-')
@@ -415,7 +569,7 @@ export function planIsolateWorktree(opts: {
415
569
  return { branch: `pier/${name}`, slug: name, worktreeDirName: `pier-${name}` };
416
570
  }
417
571
 
418
- /** Why: Preserve the established compatibility and safety behavior (D98). */
572
+ /** Commit discipline is injected into the prompt: the worktree has no other protection. */
419
573
  export function buildIsolatePreamble(opts: { worktreePath: string; branch: string; baseShort: string }): string {
420
574
  return [
421
575
  `You are working in an isolated git worktree: ${opts.worktreePath} (branch ${opts.branch}, base ${opts.baseShort}).`,
@@ -426,7 +580,6 @@ export function buildIsolatePreamble(opts: { worktreePath: string; branch: strin
426
580
  ].join('\n');
427
581
  }
428
582
 
429
- /** Why: Preserve the established compatibility and safety behavior (D98). */
430
583
  export function formatWorktreeStat(opts: {
431
584
  branch: string | null;
432
585
  commits: number | null;
@@ -445,17 +598,16 @@ export function formatWorktreeStat(opts: {
445
598
  return `git: ${opts.statLine}; uncommitted: ${opts.dirtyCount} file(s)`;
446
599
  }
447
600
 
448
- /** Why: Preserve the established compatibility and safety behavior (D98). */
449
- export type ReleaseDecision = { action: 'release' } | { action: 'retain'; reason: 'unmerged' | 'dirty' | 'unknown' };
450
-
451
- export function evaluateRelease(opts: { merged: boolean | null; dirtyCount: number | null }): ReleaseDecision {
601
+ /** D98: only a merged, clean, known worktree may be removed automatically. */
602
+ export function evaluateRelease(opts: { merged: boolean | null; dirtyCount: number | null }):
603
+ { action: 'release' } | { action: 'retain'; reason: 'unmerged' | 'dirty' | 'unknown' } {
452
604
  if (opts.merged === null || opts.dirtyCount === null) return { action: 'retain', reason: 'unknown' };
453
605
  if (!opts.merged) return { action: 'retain', reason: 'unmerged' };
454
606
  if (opts.dirtyCount > 0) return { action: 'retain', reason: 'dirty' };
455
607
  return { action: 'release' };
456
608
  }
457
609
 
458
- /** Why: Preserve the established compatibility and safety behavior (D98). */
610
+ /** `git worktree list --porcelain` → branch (refs/heads/ stripped) → path. */
459
611
  export function parseWorktreePorcelain(out: string): Map<string, string> {
460
612
  const byBranch = new Map<string, string>();
461
613
  let curPath: string | null = null;
@@ -467,3 +619,154 @@ export function parseWorktreePorcelain(out: string): Map<string, string> {
467
619
  }
468
620
  return byBranch;
469
621
  }
622
+
623
+ /* ── execute planners ───────────────────────────────────────────── */
624
+
625
+ export const FOREGROUND_POLL_MS = 2_000;
626
+
627
+ type LaunchValidation =
628
+ | { kind: 'error'; text: string }
629
+ | {
630
+ kind: 'ok';
631
+ spec: SubagentSpec;
632
+ background: boolean;
633
+ isolate: boolean;
634
+ cwdParam: string | null;
635
+ roleKind: string;
636
+ suggested: string[];
637
+ manifestRole: string | null;
638
+ tab: string | null;
639
+ };
640
+
641
+ /** Validate spawn parameters. `role` doubles as the pane label and (when it names a profile) the
642
+ * manifest to compose; unknown names stay labels. `isolate` and `cwd` are mutually exclusive. */
643
+ export function planLaunchValidation(
644
+ params: {
645
+ description?: unknown;
646
+ prompt?: unknown;
647
+ run_in_background?: unknown;
648
+ cwd?: unknown;
649
+ isolate?: unknown;
650
+ role?: unknown;
651
+ allowed_tools?: unknown;
652
+ tab?: unknown;
653
+ } | null | undefined,
654
+ herdrAvailable: boolean,
655
+ ): LaunchValidation {
656
+ if (!herdrAvailable) {
657
+ return { kind: 'error', text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).' };
658
+ }
659
+ const spec: SubagentSpec = {
660
+ description: String(params?.description ?? 'subagent'),
661
+ prompt: String(params?.prompt ?? ''),
662
+ };
663
+ if (!spec.prompt.trim()) return { kind: 'error', text: 'Error: `prompt` must be a non-empty string' };
664
+ const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
665
+ const isolate = params?.isolate === true;
666
+ if (isolate && cwdParam) {
667
+ return {
668
+ kind: 'error',
669
+ text: 'Error: `isolate` and `cwd` are mutually exclusive — isolate creates a new worktree, cwd delegates into an existing one',
670
+ };
671
+ }
672
+ const role = typeof params?.role === 'string' ? params.role.trim() : undefined;
673
+ return {
674
+ kind: 'ok',
675
+ spec,
676
+ background: params?.run_in_background === true,
677
+ isolate,
678
+ cwdParam,
679
+ roleKind: normalizeEntryKind(role),
680
+ suggested: Array.isArray(params?.allowed_tools)
681
+ ? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
682
+ : [],
683
+ manifestRole: role || 'worker-default',
684
+ tab: typeof params?.tab === 'string' ? params.tab : null,
685
+ };
686
+ }
687
+
688
+ export type ForegroundTickPlan =
689
+ | { kind: 'blocked' }
690
+ | { kind: 'settled'; text: string }
691
+ | { kind: 'wait'; delayMs: number }
692
+ | { kind: 'collect-final' }
693
+ | { kind: 'continue' };
694
+
695
+ /** One foreground-wait iteration: only a human gate or finalized text ends the wait. */
696
+ export function planForegroundTick(input: {
697
+ state: string | null;
698
+ session: { text: string | null; pendingTool: boolean; activity: boolean };
699
+ }): ForegroundTickPlan {
700
+ if (input.state === 'blocked') return { kind: 'blocked' };
701
+ if (input.state !== 'idle' && input.state !== 'done') return { kind: 'continue' };
702
+ if (input.session.text) return { kind: 'settled', text: input.session.text };
703
+ if (input.session.pendingTool || !input.session.activity) return { kind: 'wait', delayMs: FOREGROUND_POLL_MS };
704
+ return { kind: 'collect-final' };
705
+ }
706
+
707
+ /* ── task-id resolution ─────────────────────────────────────────── */
708
+
709
+ export type TaskIdResolutionResult =
710
+ | { kind: 'resolved'; taskId: string }
711
+ | { kind: 'ambiguous'; query: string; candidates: string[] }
712
+ | { kind: 'too_short'; query: string }
713
+ | { kind: 'not_found'; query: string };
714
+
715
+ /** Resolve a full or short task ID: exact matches at any length, prefix matches need four characters. */
716
+ export function resolveTaskIdPrefix(
717
+ query: string,
718
+ candidates: Iterable<string>,
719
+ ): TaskIdResolutionResult {
720
+ const trimmed = query.trim();
721
+ if (!trimmed) return { kind: 'not_found', query: trimmed };
722
+
723
+ const unique = Array.from(new Set(candidates));
724
+ const exact = unique.find((candidate) => candidate === trimmed);
725
+ if (exact) return { kind: 'resolved', taskId: exact };
726
+
727
+ if (trimmed.length < 4) return { kind: 'too_short', query: trimmed };
728
+
729
+ const lower = trimmed.toLowerCase();
730
+ const matches = unique.filter((candidate) => candidate.toLowerCase().startsWith(lower));
731
+ if (matches.length === 1) return { kind: 'resolved', taskId: matches[0]! };
732
+ if (matches.length > 1) {
733
+ matches.sort();
734
+ return { kind: 'ambiguous', query: trimmed, candidates: matches };
735
+ }
736
+ return { kind: 'not_found', query: trimmed };
737
+ }
738
+
739
+ /** Error text for a non-resolved `resolveTaskIdPrefix` outcome; null for `resolved` (each caller owns
740
+ * its success path) and `not_found` (each caller words its own unknown-id hint). `rawQuery` keeps
741
+ * the caller's original string in the message even though matching works on the trimmed form. */
742
+ export function taskIdResolutionError(
743
+ res: TaskIdResolutionResult,
744
+ label: 'task id' | 'subagent id',
745
+ rawQuery: string,
746
+ ): string | null {
747
+ if (res.kind === 'too_short') return `Error: ${label} prefix "${rawQuery}" is too short (minimum 4 characters).`;
748
+ if (res.kind === 'ambiguous') return ambiguousIdError(label, rawQuery, res.candidates);
749
+ return null;
750
+ }
751
+
752
+ /* ── outbound port (composition root → plugin) ──────────────────── */
753
+
754
+ /** Bound atomically at session_start: consumers read `current` and must tolerate null. */
755
+ export interface SubagentPort {
756
+ applyReplySession(paneId: string, sessionFile: string | null): void;
757
+ reconcileOnReply(paneId: string): string[];
758
+ listRunningSubs(): Array<{ paneId: string; slug?: string; description: string; kind: string }>;
759
+ /** D113: non-closed rows for the /pier-tree view (settled nodes carry their settle time). */
760
+ listRows(): Array<{ paneId: string; slug?: string; description: string; kind: string; status: SubEntry['status']; settledAt: number | null }>;
761
+ settleStatLine(paneId: string): Promise<string | null>;
762
+ /** Pipe settlement: drop the row out of listRunningSubs before the finish notice wakes the master. */
763
+ consumeReply(paneId: string, outcome: string | null): void;
764
+ }
765
+
766
+ export interface SubagentPortBox {
767
+ current: SubagentPort | null;
768
+ }
769
+
770
+ export function emptySubagentPortBox(): SubagentPortBox {
771
+ return { current: null };
772
+ }