pi-pier 0.3.0 → 0.3.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 (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  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 +8 -12
  9. package/src/compact-coordinator.ts +278 -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/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
@@ -1,30 +1,30 @@
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';
3
6
 
7
+ export function sleep(ms: number): Promise<void> {
8
+ const { promise, resolve } = Promise.withResolvers<void>();
9
+ setTimeout(resolve, ms);
10
+ return promise;
11
+ }
12
+
4
13
  export interface SubagentSpec {
5
14
  description: string;
6
15
  prompt: string;
7
16
  }
8
17
 
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. */
18
+ /** POSIX single-quote escape; win32 PowerShell doubles the quote instead. */
20
19
  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(' ');
20
+ const win = platform === 'win32';
21
+ const quoted = parts.map((s) => (win
22
+ ? `'${String(s).replace(/'/g, "''")}'`
23
+ : `'${String(s).replace(/'/g, `'\\''`)}'`));
24
+ return (win ? '& ' : '') + quoted.join(' ');
25
25
  }
26
26
 
27
- /** Why: Preserve the established compatibility and safety behavior. */
27
+ /** Bounds concurrent delegation spawns (SUBAGENT_CONCURRENCY). */
28
28
  export class Semaphore {
29
29
  private active = 0;
30
30
  private queue: Array<() => void> = [];
@@ -44,12 +44,12 @@ export class Semaphore {
44
44
  this.active++;
45
45
  return Promise.resolve(this.release.bind(this));
46
46
  }
47
- return new Promise((resolve) => {
48
- this.queue.push(() => {
49
- this.active++;
50
- resolve(this.release.bind(this));
51
- });
47
+ const { promise, resolve } = Promise.withResolvers<() => void>();
48
+ this.queue.push(() => {
49
+ this.active++;
50
+ resolve(this.release.bind(this));
52
51
  });
52
+ return promise;
53
53
  }
54
54
 
55
55
  private release(): void {
@@ -59,19 +59,15 @@ export class Semaphore {
59
59
  }
60
60
  }
61
61
 
62
- export interface SubagentOutcome {
62
+ interface SubagentOutcome {
63
63
  kind: 'completed' | 'timeout' | 'no-output' | 'blocked' | 'spawn-failed';
64
- /** Why: Preserve the established compatibility and safety behavior. */
65
64
  text: string;
66
65
  }
67
66
 
68
- /** Why: Preserve the established compatibility and safety behavior. */
69
67
  export function formatSubagentResult(outcome: SubagentOutcome, description: string): string {
70
68
  switch (outcome.kind) {
71
- case 'completed': {
72
- const text = outcome.text;
73
- return text ? text : 'Subagent finished but produced no output.';
74
- }
69
+ case 'completed':
70
+ return outcome.text ? outcome.text : 'Subagent finished but produced no output.';
75
71
  case 'timeout':
76
72
  return `Error: subagent "${description}" timed out. Partial output:\n${outcome.text || '(none)'}`;
77
73
  case 'blocked':
@@ -83,27 +79,25 @@ export function formatSubagentResult(outcome: SubagentOutcome, description: stri
83
79
  }
84
80
  }
85
81
 
86
- /** Why: Preserve the established compatibility and safety behavior (A2, B1). */
87
-
88
- /** Why: Preserve the established compatibility and safety behavior. */
89
82
  export interface AliveProbe {
90
83
  paneExists: boolean;
91
84
  agentStatus: string | null;
92
- /** Why: Preserve the established compatibility and safety behavior. */
93
85
  lastActivityMs: number | null;
94
86
  /** Herdr 0.9.1: foreground working directory of the PTY process. */
95
87
  foregroundCwd?: string | null;
96
88
  }
97
89
 
98
- /** Why: Preserve the established compatibility and safety behavior. */
90
+ /**
91
+ * A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
92
+ * age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
93
+ * the caller's to report, not guessed here.
94
+ */
99
95
  export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
100
96
  if (!probe.paneExists) return false;
101
97
  if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
102
- // Why: Preserve the established compatibility and safety behavior.
103
98
  return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
104
99
  }
105
100
 
106
- /** Why: Preserve the established compatibility and safety behavior. */
107
101
  export function agoText(ms: number, nowMs: number): string {
108
102
  const s = Math.max(0, Math.round((nowMs - ms) / 1000));
109
103
  if (s < 60) return `${s}s ago`;
@@ -111,7 +105,6 @@ export function agoText(ms: number, nowMs: number): string {
111
105
  return `${Math.floor(s / 3600)}h ago`;
112
106
  }
113
107
 
114
- /** Why: Preserve the established compatibility and safety behavior (A2, B1). */
115
108
  export function buildAliveNotice(
116
109
  opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
117
110
  nowMs: number,
@@ -134,7 +127,7 @@ export function buildAliveNotice(
134
127
  ].join(' ');
135
128
  }
136
129
 
137
- /** Why: Preserve the established compatibility and safety behavior (E1, E2). */
130
+ /** A pane blocked on a human decision keeps running: the master must not take the work over. */
138
131
  export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
139
132
  const { paneId, description, question } = opts;
140
133
  const q = question ? ` (question: "${question}")` : '';
@@ -147,35 +140,30 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
147
140
  ].join(' ');
148
141
  }
149
142
 
150
- /** Why: Preserve the established compatibility and safety behavior. */
151
-
152
143
  export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
153
144
 
154
145
  export interface SubEntry {
155
146
  taskId: string;
156
- /** Why: Preserve the established compatibility and safety behavior. */
147
+ /** 'task' or a role name; only a display label for GC. */
157
148
  kind: string;
158
149
  paneId: string;
159
150
  tabId: string;
160
- /** Why: Preserve the established compatibility and safety behavior (D25, D26). */
161
151
  tabName: string;
162
152
  cwd: string;
163
153
  description: string;
164
154
  background: boolean;
165
155
  status: 'running' | 'settled' | 'consumed' | 'closed';
166
- /** Why: Preserve the established compatibility and safety behavior. */
156
+ /** GC grace basis (durable); closed rows keep the original value. */
167
157
  consumedAt?: number | null;
168
158
  sessionFile: string | null;
169
159
  launchCommand: string[];
170
160
  createdAt: number;
171
161
  revivedFrom?: string | null;
172
- /** Why: Preserve the established compatibility and safety behavior (D94). */
162
+ /** D94: the human took the pane over; observation window judges when to hand control back. */
173
163
  userTakeover?: boolean;
174
- /** Why: Preserve the established compatibility and safety behavior (D94). */
175
164
  observationStartedAt?: number | null;
176
- /** Why: Preserve the established compatibility and safety behavior (D94). */
177
165
  lastAgentStatus?: string | null;
178
- /** Why: Preserve the established compatibility and safety behavior (D98). */
166
+ /** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
179
167
  isolate?: {
180
168
  worktreePath: string;
181
169
  branch: string;
@@ -194,14 +182,15 @@ export function makeRegistry(subs: SubEntry[] = []): SubsRegistry {
194
182
  return { version: 2, subs };
195
183
  }
196
184
 
197
- /** Why: Preserve the established compatibility and safety behavior. */
198
- export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegistry {
185
+ const SUB_STATUSES: Record<string, true> = { running: true, settled: true, consumed: true, closed: true };
186
+
187
+ /** Replay `pi-herdr.subs` custom branch entries into a registry; last snapshot wins. Tolerant of
188
+ * v1 rows (missing taskId/kind/tabName) and unknown future fields. */
189
+ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customType?: string; data?: unknown }>): SubsRegistry {
199
190
  let found = makeRegistry();
200
191
  for (const entry of entries) {
201
192
  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;
193
+ const data = entry.data as { subs?: Array<Partial<SubEntry> & { paneId: string }> } | undefined;
205
194
  if (!data || !Array.isArray(data.subs)) continue;
206
195
  const subs: SubEntry[] = data.subs
207
196
  .filter((s) => typeof s?.paneId === 'string')
@@ -214,7 +203,9 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
214
203
  cwd: typeof s.cwd === 'string' ? s.cwd : '',
215
204
  description: typeof s.description === 'string' ? s.description : '',
216
205
  background: s.background === true,
217
- status: s.status === 'closed' ? 'closed' : s.status === 'consumed' ? 'consumed' : s.status === 'settled' ? 'settled' : 'running',
206
+ status: typeof s.status === 'string' && SUB_STATUSES[s.status] === true
207
+ ? (s.status as SubEntry['status'])
208
+ : 'running',
218
209
  consumedAt: typeof s.consumedAt === 'number' ? s.consumedAt : null,
219
210
  sessionFile: typeof s.sessionFile === 'string' ? s.sessionFile : null,
220
211
  launchCommand: Array.isArray(s.launchCommand) ? s.launchCommand : [],
@@ -240,36 +231,75 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
240
231
  return found;
241
232
  }
242
233
 
243
- export interface BranchEntryLike2 {
244
- type?: string;
245
- customType?: string;
246
- data?: unknown;
234
+ /** Latest row per taskId (same taskId rows are generations); newest createdAt wins. */
235
+ export function newestPerTaskId(entries: Iterable<SubEntry>): Map<string, SubEntry> {
236
+ const byTask = new Map<string, SubEntry>();
237
+ for (const sub of entries) {
238
+ const prev = byTask.get(sub.taskId);
239
+ if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
240
+ }
241
+ return byTask;
247
242
  }
248
243
 
249
- /** Why: Preserve the established compatibility and safety behavior. */
244
+ /** Re-key a revived entry under its new pane id; the dead id must go, or a later zombie sweep closes the live row. */
245
+ export function rekeySub(subs: Map<string, SubEntry>, previousPaneId: string, entry: SubEntry): void {
246
+ if (previousPaneId !== entry.paneId && subs.get(previousPaneId) === entry) subs.delete(previousPaneId);
247
+ subs.set(entry.paneId, entry);
248
+ }
250
249
 
251
- export interface ProgressUpdate {
252
- content: Array<{ type: 'text'; text: string }>;
253
- details: Record<string, never>;
250
+ /** Split `provider/model` on the first '/' only: model ids such as `moonshotai/kimi-k2` carry their own. */
251
+ export function splitRoleModel(roleModel: string): { provider: string | null; model: string } {
252
+ const slash = roleModel.indexOf('/');
253
+ if (slash <= 0 || slash === roleModel.length - 1) return { provider: null, model: roleModel };
254
+ return { provider: roleModel.slice(0, slash), model: roleModel.slice(slash + 1) };
254
255
  }
255
256
 
256
- /** Why: Preserve the established compatibility and safety behavior. */
257
- export function makeProgressUpdate(msg: string): ProgressUpdate {
258
- return { content: [{ type: 'text', text: msg }], details: {} };
257
+ /** pi's AgentToolResult shape — a bare string crashes the interactive TUI's getTextOutput. */
258
+ export function makeProgressUpdate(msg: string) {
259
+ return { content: [{ type: 'text' as const, text: msg }], details: {} as Record<string, never> };
259
260
  }
260
261
 
261
- /** Why: Preserve the established compatibility and safety behavior (D25, D26). */
262
+ /**
263
+ * Models sometimes wrap fields in a `parameters` object (JSON-schema habit). Top-level wins;
264
+ * only missing keys are filled from the nested bag so a real field is never overwritten.
265
+ */
266
+ export function flattenToolParams(params: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
267
+ if (!params) return params;
268
+ const nested = params.parameters;
269
+ if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) return params;
270
+ const out: Record<string, unknown> = { ...params };
271
+ for (const [key, value] of Object.entries(nested as Record<string, unknown>)) {
272
+ if (key === 'parameters') continue;
273
+ const current = out[key];
274
+ if (current == null || current === '') out[key] = value;
275
+ }
276
+ return out;
277
+ }
278
+
279
+
280
+ export function idParam(params: Record<string, unknown> | undefined, ...keys: string[]): string {
281
+ const flat = flattenToolParams(params);
282
+ for (const key of keys) {
283
+ const v = flat?.[key];
284
+ if (v != null && String(v).trim() !== '') return String(v).trim();
285
+ }
286
+ return '';
287
+ }
288
+
289
+ export function ambiguousIdError(label: 'task id' | 'subagent id', query: string, candidates: readonly string[]): string {
290
+ const head = candidates.slice(0, 5).join(', ');
291
+ const more = candidates.length > 5 ? `, ... (+${candidates.length - 5} more)` : '';
292
+ return `Error: ambiguous ${label} "${query}" matches ${candidates.length} tasks: ${head}${more}`;
293
+ }
262
294
 
263
295
  export const TAB_NAME_MAX = 20;
264
296
 
265
- /** Why: Preserve the established compatibility and safety behavior. */
266
297
  export function tabNameForTask(description: string): string {
267
298
  const cleaned = String(description ?? '').replace(/\s+/g, ' ').trim();
268
299
  const truncated = cleaned.slice(0, TAB_NAME_MAX).trim();
269
300
  return truncated || 'task';
270
301
  }
271
302
 
272
- /** Why: Preserve the established compatibility and safety behavior (D26). */
273
303
  export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>): string {
274
304
  let name = base;
275
305
  let n = 2;
@@ -282,16 +312,12 @@ export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>
282
312
  }
283
313
 
284
314
  export interface TabPlacementPlan {
285
- /** Why: Preserve the established compatibility and safety behavior. */
286
315
  mode: 'append' | 'new';
287
316
  tabName: string;
288
- /** Why: Preserve the established compatibility and safety behavior. */
289
317
  tabId: string | null;
290
318
  }
291
319
 
292
- /** Why: Preserve the established compatibility and safety behavior (D86). */
293
-
294
- /** Why: Preserve the established compatibility and safety behavior (D86). */
320
+ /** Case/slash-insensitive containment; `/repo-x` is NOT under `/repo`. */
295
321
  export function isPathUnder(cwd: string, wt: string): boolean {
296
322
  const n = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
297
323
  const a = n(cwd);
@@ -300,13 +326,11 @@ export function isPathUnder(cwd: string, wt: string): boolean {
300
326
  }
301
327
 
302
328
  export interface WorktreeZone {
303
- /** Why: Preserve the established compatibility and safety behavior. */
304
329
  zone: 'main' | 'worktree';
305
- /** Why: Preserve the established compatibility and safety behavior. */
306
330
  tabName: string | null;
307
331
  }
308
332
 
309
- /** Why: Preserve the established compatibility and safety behavior (D86). */
333
+ /** D86: subagents working in the master's checkout share its tab; another worktree gets its own. */
310
334
  export function classifyWorktreeZone(opts: {
311
335
  cwd: string;
312
336
  masterCwd: string;
@@ -314,21 +338,19 @@ export function classifyWorktreeZone(opts: {
314
338
  }): WorktreeZone {
315
339
  for (const wt of opts.worktrees) {
316
340
  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.
341
+ if (isPathUnder(opts.masterCwd, wt)) return { zone: 'main', tabName: null };
318
342
  const base = wt.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'worktree';
319
343
  return { zone: 'worktree', tabName: base };
320
344
  }
321
345
  return { zone: 'main', tabName: null };
322
346
  }
323
347
 
324
- /** Why: Preserve the established compatibility and safety behavior (D26, D86). */
348
+ /** Explicit `tab` wins; otherwise D86 zone decides, and an unknown zone falls back to the description. */
325
349
  export function planTabPlacement(opts: {
326
350
  desiredTab?: string | null;
327
351
  description: string;
328
352
  knownTabs: ReadonlyArray<{ tabName: string; tabId: string }>;
329
- /** Why: Preserve the established compatibility and safety behavior (D86). */
330
353
  zone?: WorktreeZone;
331
- /** Why: Preserve the established compatibility and safety behavior (D86). */
332
354
  mainTabId?: string | null;
333
355
  }): TabPlacementPlan {
334
356
  const known = new Map<string, string>();
@@ -340,35 +362,21 @@ export function planTabPlacement(opts: {
340
362
  }
341
363
  const zone = opts.zone;
342
364
  if (zone?.zone === 'main' && opts.mainTabId) {
343
- // Why: Preserve the established compatibility and safety behavior (D86, R1).
344
365
  return { mode: 'append', tabName: 'main', tabId: opts.mainTabId };
345
366
  }
346
367
  if (zone?.zone === 'worktree' && zone.tabName) {
347
- // Why: Preserve the established compatibility and safety behavior (D86, R1).
348
368
  const base = zone.tabName;
349
369
  const existing = [...known.keys()].find((k) => k.toLowerCase() === base.toLowerCase());
350
370
  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 };
371
+ return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
353
372
  }
354
- // Why: Preserve the established compatibility and safety behavior.
355
373
  const base = tabNameForTask(opts.description);
356
- const tabName = nextTaskTabName(base, new Set(known.keys()));
357
- return { mode: 'new', tabName, tabId: null };
358
- }
359
-
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;
374
+ return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
367
375
  }
368
376
 
369
- /** Why: Preserve the established compatibility and safety behavior (D97). */
377
+ /** D97: fullscreen is what makes the pane a static frame the master can read. */
370
378
  export function buildLaunchParts(
371
- runtime: LaunchRuntime,
379
+ runtime: { nodePath: string; cliPath: string; extPath: string },
372
380
  opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
373
381
  env: { PI_HERDR_TUI?: string | undefined } = process.env,
374
382
  ): string[] {
@@ -376,29 +384,22 @@ export function buildLaunchParts(
376
384
  if (opts.approve) parts.push('-a');
377
385
  parts.push('-e', runtime.extPath);
378
386
  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);
387
+ if (opts.roleModel) {
388
+ const { provider, model } = splitRoleModel(opts.roleModel);
389
+ if (provider) parts.push('--provider', provider);
390
+ parts.push('--model', model);
391
+ }
380
392
  if (opts.resumeFile) parts.push('--session', opts.resumeFile);
381
393
  return parts;
382
394
  }
383
395
 
384
- /** Why: Preserve the established compatibility and safety behavior (D98). */
385
-
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;
396
+ const ISOLATE_SLUG_MAX = 40;
395
397
 
396
- /** Why: Preserve the established compatibility and safety behavior (D98). */
397
398
  export function planIsolateWorktree(opts: {
398
399
  description: string;
399
400
  taskHex: string;
400
401
  existingPierBranches: ReadonlySet<string>;
401
- }): IsolatePlan {
402
+ }) {
402
403
  const folded = String(opts.description ?? '')
403
404
  .toLowerCase()
404
405
  .replace(/[^a-z0-9]+/g, '-')
@@ -415,7 +416,7 @@ export function planIsolateWorktree(opts: {
415
416
  return { branch: `pier/${name}`, slug: name, worktreeDirName: `pier-${name}` };
416
417
  }
417
418
 
418
- /** Why: Preserve the established compatibility and safety behavior (D98). */
419
+ /** Commit discipline is injected into the prompt: the worktree has no other protection. */
419
420
  export function buildIsolatePreamble(opts: { worktreePath: string; branch: string; baseShort: string }): string {
420
421
  return [
421
422
  `You are working in an isolated git worktree: ${opts.worktreePath} (branch ${opts.branch}, base ${opts.baseShort}).`,
@@ -426,7 +427,6 @@ export function buildIsolatePreamble(opts: { worktreePath: string; branch: strin
426
427
  ].join('\n');
427
428
  }
428
429
 
429
- /** Why: Preserve the established compatibility and safety behavior (D98). */
430
430
  export function formatWorktreeStat(opts: {
431
431
  branch: string | null;
432
432
  commits: number | null;
@@ -445,17 +445,16 @@ export function formatWorktreeStat(opts: {
445
445
  return `git: ${opts.statLine}; uncommitted: ${opts.dirtyCount} file(s)`;
446
446
  }
447
447
 
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 {
448
+ /** D98: only a merged, clean, known worktree may be removed automatically. */
449
+ export function evaluateRelease(opts: { merged: boolean | null; dirtyCount: number | null }):
450
+ { action: 'release' } | { action: 'retain'; reason: 'unmerged' | 'dirty' | 'unknown' } {
452
451
  if (opts.merged === null || opts.dirtyCount === null) return { action: 'retain', reason: 'unknown' };
453
452
  if (!opts.merged) return { action: 'retain', reason: 'unmerged' };
454
453
  if (opts.dirtyCount > 0) return { action: 'retain', reason: 'dirty' };
455
454
  return { action: 'release' };
456
455
  }
457
456
 
458
- /** Why: Preserve the established compatibility and safety behavior (D98). */
457
+ /** `git worktree list --porcelain` → branch (refs/heads/ stripped) → path. */
459
458
  export function parseWorktreePorcelain(out: string): Map<string, string> {
460
459
  const byBranch = new Map<string, string>();
461
460
  let curPath: string | null = null;
@@ -467,3 +466,139 @@ export function parseWorktreePorcelain(out: string): Map<string, string> {
467
466
  }
468
467
  return byBranch;
469
468
  }
469
+
470
+ /* ── execute planners ───────────────────────────────────────────── */
471
+
472
+ export const FOREGROUND_POLL_MS = 2_000;
473
+
474
+ type LaunchValidation =
475
+ | { kind: 'error'; text: string }
476
+ | {
477
+ kind: 'ok';
478
+ spec: SubagentSpec;
479
+ background: boolean;
480
+ isolate: boolean;
481
+ cwdParam: string | null;
482
+ roleKind: string;
483
+ suggested: string[];
484
+ manifestRole: string | null;
485
+ tab: string | null;
486
+ };
487
+
488
+ /** Validate spawn parameters. `role` doubles as the pane label and (when it names a profile) the
489
+ * manifest to compose; unknown names stay labels. `isolate` and `cwd` are mutually exclusive. */
490
+ export function planLaunchValidation(
491
+ params: {
492
+ description?: unknown;
493
+ prompt?: unknown;
494
+ run_in_background?: unknown;
495
+ cwd?: unknown;
496
+ isolate?: unknown;
497
+ role?: unknown;
498
+ allowed_tools?: unknown;
499
+ tab?: unknown;
500
+ } | null | undefined,
501
+ herdrAvailable: boolean,
502
+ ): LaunchValidation {
503
+ if (!herdrAvailable) {
504
+ return { kind: 'error', text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).' };
505
+ }
506
+ const spec: SubagentSpec = {
507
+ description: String(params?.description ?? 'subagent'),
508
+ prompt: String(params?.prompt ?? ''),
509
+ };
510
+ if (!spec.prompt.trim()) return { kind: 'error', text: 'Error: `prompt` must be a non-empty string' };
511
+ const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
512
+ const isolate = params?.isolate === true;
513
+ if (isolate && cwdParam) {
514
+ return {
515
+ kind: 'error',
516
+ text: 'Error: `isolate` and `cwd` are mutually exclusive — isolate creates a new worktree, cwd delegates into an existing one',
517
+ };
518
+ }
519
+ const role = typeof params?.role === 'string' ? params.role.trim() : undefined;
520
+ return {
521
+ kind: 'ok',
522
+ spec,
523
+ background: params?.run_in_background === true,
524
+ isolate,
525
+ cwdParam,
526
+ roleKind: normalizeEntryKind(role),
527
+ suggested: Array.isArray(params?.allowed_tools)
528
+ ? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
529
+ : [],
530
+ manifestRole: role || 'worker-default',
531
+ tab: typeof params?.tab === 'string' ? params.tab : null,
532
+ };
533
+ }
534
+
535
+ export type ForegroundTickPlan =
536
+ | { kind: 'blocked' }
537
+ | { kind: 'settled'; text: string }
538
+ | { kind: 'wait'; delayMs: number }
539
+ | { kind: 'collect-final' }
540
+ | { kind: 'continue' };
541
+
542
+ /** One foreground-wait iteration: only a human gate or finalized text ends the wait. */
543
+ export function planForegroundTick(input: {
544
+ state: string | null;
545
+ session: { text: string | null; pendingTool: boolean; activity: boolean };
546
+ }): ForegroundTickPlan {
547
+ if (input.state === 'blocked') return { kind: 'blocked' };
548
+ if (input.state !== 'idle' && input.state !== 'done') return { kind: 'continue' };
549
+ if (input.session.text) return { kind: 'settled', text: input.session.text };
550
+ if (input.session.pendingTool || !input.session.activity) return { kind: 'wait', delayMs: FOREGROUND_POLL_MS };
551
+ return { kind: 'collect-final' };
552
+ }
553
+
554
+ /* ── task-id resolution ─────────────────────────────────────────── */
555
+
556
+ type TaskIdResolutionResult =
557
+ | { kind: 'resolved'; taskId: string }
558
+ | { kind: 'ambiguous'; query: string; candidates: string[] }
559
+ | { kind: 'too_short'; query: string }
560
+ | { kind: 'not_found'; query: string };
561
+
562
+ /** Resolve a full or short task ID: exact matches at any length, prefix matches need four characters. */
563
+ export function resolveTaskIdPrefix(
564
+ query: string,
565
+ candidates: Iterable<string>,
566
+ ): TaskIdResolutionResult {
567
+ const trimmed = query.trim();
568
+ if (!trimmed) return { kind: 'not_found', query: trimmed };
569
+
570
+ const unique = Array.from(new Set(candidates));
571
+ const exact = unique.find((candidate) => candidate === trimmed);
572
+ if (exact) return { kind: 'resolved', taskId: exact };
573
+
574
+ if (trimmed.length < 4) return { kind: 'too_short', query: trimmed };
575
+
576
+ const lower = trimmed.toLowerCase();
577
+ const matches = unique.filter((candidate) => candidate.toLowerCase().startsWith(lower));
578
+ if (matches.length === 1) return { kind: 'resolved', taskId: matches[0]! };
579
+ if (matches.length > 1) {
580
+ matches.sort();
581
+ return { kind: 'ambiguous', query: trimmed, candidates: matches };
582
+ }
583
+ return { kind: 'not_found', query: trimmed };
584
+ }
585
+
586
+ /* ── outbound port (composition root → plugin) ──────────────────── */
587
+
588
+ /** Bound atomically at session_start: consumers read `current` and must tolerate null. */
589
+ export interface SubagentPort {
590
+ applyReplySession(paneId: string, sessionFile: string | null): void;
591
+ reconcileOnReply(paneId: string): string[];
592
+ listRunningSubs(): Array<{ paneId: string; description: string }>;
593
+ settleStatLine(paneId: string): Promise<string | null>;
594
+ /** Pipe settlement: drop the row out of listRunningSubs before the finish notice wakes the master. */
595
+ consumeReply(paneId: string, outcome: string | null): void;
596
+ }
597
+
598
+ export interface SubagentPortBox {
599
+ current: SubagentPort | null;
600
+ }
601
+
602
+ export function emptySubagentPortBox(): SubagentPortBox {
603
+ return { current: null };
604
+ }