pi-pier 0.2.7 → 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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -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,25 +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;
86
+ /** Herdr 0.9.1: foreground working directory of the PTY process. */
87
+ foregroundCwd?: string | null;
94
88
  }
95
89
 
96
- /** 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
+ */
97
95
  export function isAlive(probe: AliveProbe, nowMs: number, staleAfterMs = 120_000): boolean {
98
96
  if (!probe.paneExists) return false;
99
97
  if (probe.agentStatus === 'working' || probe.agentStatus === 'blocked') return true;
100
- // Why: Preserve the established compatibility and safety behavior.
101
98
  return probe.lastActivityMs != null && nowMs - probe.lastActivityMs < staleAfterMs;
102
99
  }
103
100
 
104
- /** Why: Preserve the established compatibility and safety behavior. */
105
101
  export function agoText(ms: number, nowMs: number): string {
106
102
  const s = Math.max(0, Math.round((nowMs - ms) / 1000));
107
103
  if (s < 60) return `${s}s ago`;
@@ -109,7 +105,6 @@ export function agoText(ms: number, nowMs: number): string {
109
105
  return `${Math.floor(s / 3600)}h ago`;
110
106
  }
111
107
 
112
- /** Why: Preserve the established compatibility and safety behavior (A2, B1). */
113
108
  export function buildAliveNotice(
114
109
  opts: { paneId: string; description: string; scenario: 'moved-to-bg' | 'error-alive'; probe: AliveProbe },
115
110
  nowMs: number,
@@ -132,7 +127,7 @@ export function buildAliveNotice(
132
127
  ].join(' ');
133
128
  }
134
129
 
135
- /** 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. */
136
131
  export function buildBlockedGateNotice(opts: { paneId: string; description: string; question: string | null }): string {
137
132
  const { paneId, description, question } = opts;
138
133
  const q = question ? ` (question: "${question}")` : '';
@@ -145,35 +140,30 @@ export function buildBlockedGateNotice(opts: { paneId: string; description: stri
145
140
  ].join(' ');
146
141
  }
147
142
 
148
- /** Why: Preserve the established compatibility and safety behavior. */
149
-
150
143
  export const SUBS_CUSTOM_TYPE = 'pi-herdr.subs';
151
144
 
152
145
  export interface SubEntry {
153
146
  taskId: string;
154
- /** Why: Preserve the established compatibility and safety behavior. */
147
+ /** 'task' or a role name; only a display label for GC. */
155
148
  kind: string;
156
149
  paneId: string;
157
150
  tabId: string;
158
- /** Why: Preserve the established compatibility and safety behavior (D25, D26). */
159
151
  tabName: string;
160
152
  cwd: string;
161
153
  description: string;
162
154
  background: boolean;
163
155
  status: 'running' | 'settled' | 'consumed' | 'closed';
164
- /** Why: Preserve the established compatibility and safety behavior. */
156
+ /** GC grace basis (durable); closed rows keep the original value. */
165
157
  consumedAt?: number | null;
166
158
  sessionFile: string | null;
167
159
  launchCommand: string[];
168
160
  createdAt: number;
169
161
  revivedFrom?: string | null;
170
- /** 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. */
171
163
  userTakeover?: boolean;
172
- /** Why: Preserve the established compatibility and safety behavior (D94). */
173
164
  observationStartedAt?: number | null;
174
- /** Why: Preserve the established compatibility and safety behavior (D94). */
175
165
  lastAgentStatus?: string | null;
176
- /** Why: Preserve the established compatibility and safety behavior (D98). */
166
+ /** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
177
167
  isolate?: {
178
168
  worktreePath: string;
179
169
  branch: string;
@@ -192,14 +182,15 @@ export function makeRegistry(subs: SubEntry[] = []): SubsRegistry {
192
182
  return { version: 2, subs };
193
183
  }
194
184
 
195
- /** Why: Preserve the established compatibility and safety behavior. */
196
- 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 {
197
190
  let found = makeRegistry();
198
191
  for (const entry of entries) {
199
192
  if (entry.type !== 'custom' || entry.customType !== SUBS_CUSTOM_TYPE) continue;
200
- const data = entry.data as {
201
- subs?: Array<Partial<SubEntry> & { paneId: string }>;
202
- } | undefined;
193
+ const data = entry.data as { subs?: Array<Partial<SubEntry> & { paneId: string }> } | undefined;
203
194
  if (!data || !Array.isArray(data.subs)) continue;
204
195
  const subs: SubEntry[] = data.subs
205
196
  .filter((s) => typeof s?.paneId === 'string')
@@ -212,7 +203,9 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
212
203
  cwd: typeof s.cwd === 'string' ? s.cwd : '',
213
204
  description: typeof s.description === 'string' ? s.description : '',
214
205
  background: s.background === true,
215
- 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',
216
209
  consumedAt: typeof s.consumedAt === 'number' ? s.consumedAt : null,
217
210
  sessionFile: typeof s.sessionFile === 'string' ? s.sessionFile : null,
218
211
  launchCommand: Array.isArray(s.launchCommand) ? s.launchCommand : [],
@@ -238,36 +231,75 @@ export function foldSubsRegistry(entries: readonly BranchEntryLike2[]): SubsRegi
238
231
  return found;
239
232
  }
240
233
 
241
- export interface BranchEntryLike2 {
242
- type?: string;
243
- customType?: string;
244
- 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;
245
242
  }
246
243
 
247
- /** 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
+ }
248
249
 
249
- export interface ProgressUpdate {
250
- content: Array<{ type: 'text'; text: string }>;
251
- 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) };
252
255
  }
253
256
 
254
- /** Why: Preserve the established compatibility and safety behavior. */
255
- export function makeProgressUpdate(msg: string): ProgressUpdate {
256
- 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> };
257
260
  }
258
261
 
259
- /** 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
+ }
260
294
 
261
295
  export const TAB_NAME_MAX = 20;
262
296
 
263
- /** Why: Preserve the established compatibility and safety behavior. */
264
297
  export function tabNameForTask(description: string): string {
265
298
  const cleaned = String(description ?? '').replace(/\s+/g, ' ').trim();
266
299
  const truncated = cleaned.slice(0, TAB_NAME_MAX).trim();
267
300
  return truncated || 'task';
268
301
  }
269
302
 
270
- /** Why: Preserve the established compatibility and safety behavior (D26). */
271
303
  export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>): string {
272
304
  let name = base;
273
305
  let n = 2;
@@ -280,16 +312,12 @@ export function nextTaskTabName(base: string, existingNames: ReadonlySet<string>
280
312
  }
281
313
 
282
314
  export interface TabPlacementPlan {
283
- /** Why: Preserve the established compatibility and safety behavior. */
284
315
  mode: 'append' | 'new';
285
316
  tabName: string;
286
- /** Why: Preserve the established compatibility and safety behavior. */
287
317
  tabId: string | null;
288
318
  }
289
319
 
290
- /** Why: Preserve the established compatibility and safety behavior (D86). */
291
-
292
- /** Why: Preserve the established compatibility and safety behavior (D86). */
320
+ /** Case/slash-insensitive containment; `/repo-x` is NOT under `/repo`. */
293
321
  export function isPathUnder(cwd: string, wt: string): boolean {
294
322
  const n = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
295
323
  const a = n(cwd);
@@ -298,13 +326,11 @@ export function isPathUnder(cwd: string, wt: string): boolean {
298
326
  }
299
327
 
300
328
  export interface WorktreeZone {
301
- /** Why: Preserve the established compatibility and safety behavior. */
302
329
  zone: 'main' | 'worktree';
303
- /** Why: Preserve the established compatibility and safety behavior. */
304
330
  tabName: string | null;
305
331
  }
306
332
 
307
- /** 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. */
308
334
  export function classifyWorktreeZone(opts: {
309
335
  cwd: string;
310
336
  masterCwd: string;
@@ -312,21 +338,19 @@ export function classifyWorktreeZone(opts: {
312
338
  }): WorktreeZone {
313
339
  for (const wt of opts.worktrees) {
314
340
  if (!isPathUnder(opts.cwd, wt)) continue;
315
- 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 };
316
342
  const base = wt.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'worktree';
317
343
  return { zone: 'worktree', tabName: base };
318
344
  }
319
345
  return { zone: 'main', tabName: null };
320
346
  }
321
347
 
322
- /** 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. */
323
349
  export function planTabPlacement(opts: {
324
350
  desiredTab?: string | null;
325
351
  description: string;
326
352
  knownTabs: ReadonlyArray<{ tabName: string; tabId: string }>;
327
- /** Why: Preserve the established compatibility and safety behavior (D86). */
328
353
  zone?: WorktreeZone;
329
- /** Why: Preserve the established compatibility and safety behavior (D86). */
330
354
  mainTabId?: string | null;
331
355
  }): TabPlacementPlan {
332
356
  const known = new Map<string, string>();
@@ -338,65 +362,44 @@ export function planTabPlacement(opts: {
338
362
  }
339
363
  const zone = opts.zone;
340
364
  if (zone?.zone === 'main' && opts.mainTabId) {
341
- // Why: Preserve the established compatibility and safety behavior (D86, R1).
342
365
  return { mode: 'append', tabName: 'main', tabId: opts.mainTabId };
343
366
  }
344
367
  if (zone?.zone === 'worktree' && zone.tabName) {
345
- // Why: Preserve the established compatibility and safety behavior (D86, R1).
346
368
  const base = zone.tabName;
347
369
  const existing = [...known.keys()].find((k) => k.toLowerCase() === base.toLowerCase());
348
370
  if (existing) return { mode: 'append', tabName: existing, tabId: known.get(existing)! };
349
- const tabName = nextTaskTabName(base, new Set(known.keys()));
350
- return { mode: 'new', tabName, tabId: null };
371
+ return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
351
372
  }
352
- // Why: Preserve the established compatibility and safety behavior.
353
373
  const base = tabNameForTask(opts.description);
354
- const tabName = nextTaskTabName(base, new Set(known.keys()));
355
- return { mode: 'new', tabName, tabId: null };
356
- }
357
-
358
- /** Why: Preserve the established compatibility and safety behavior (D97). */
359
-
360
- /** Why: Preserve the established compatibility and safety behavior. */
361
- export interface LaunchRuntime {
362
- nodePath: string;
363
- cliPath: string;
364
- extPath: string;
374
+ return { mode: 'new', tabName: nextTaskTabName(base, new Set(known.keys())), tabId: null };
365
375
  }
366
376
 
367
- /** Why: Preserve the established compatibility and safety behavior (D97). */
377
+ /** D97: fullscreen is what makes the pane a static frame the master can read. */
368
378
  export function buildLaunchParts(
369
- runtime: LaunchRuntime,
379
+ runtime: { nodePath: string; cliPath: string; extPath: string },
370
380
  opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
371
- env: Pick<NodeJS.ProcessEnv, 'PI_HERDR_TUI'> = process.env,
381
+ env: { PI_HERDR_TUI?: string | undefined } = process.env,
372
382
  ): string[] {
373
383
  const parts = [runtime.nodePath, runtime.cliPath];
374
384
  if (opts.approve) parts.push('-a');
375
385
  parts.push('-e', runtime.extPath);
376
386
  if (env.PI_HERDR_TUI !== 'regular') parts.push('--tui-mode', 'fullscreen');
377
- 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
+ }
378
392
  if (opts.resumeFile) parts.push('--session', opts.resumeFile);
379
393
  return parts;
380
394
  }
381
395
 
382
- /** Why: Preserve the established compatibility and safety behavior (D98). */
383
-
384
- /** Why: Preserve the established compatibility and safety behavior. */
385
- export interface IsolatePlan {
386
- branch: string;
387
- slug: string;
388
- worktreeDirName: string;
389
- }
390
-
391
- /** Why: Preserve the established compatibility and safety behavior. */
392
- export const ISOLATE_SLUG_MAX = 40;
396
+ const ISOLATE_SLUG_MAX = 40;
393
397
 
394
- /** Why: Preserve the established compatibility and safety behavior (D98). */
395
398
  export function planIsolateWorktree(opts: {
396
399
  description: string;
397
400
  taskHex: string;
398
401
  existingPierBranches: ReadonlySet<string>;
399
- }): IsolatePlan {
402
+ }) {
400
403
  const folded = String(opts.description ?? '')
401
404
  .toLowerCase()
402
405
  .replace(/[^a-z0-9]+/g, '-')
@@ -413,7 +416,7 @@ export function planIsolateWorktree(opts: {
413
416
  return { branch: `pier/${name}`, slug: name, worktreeDirName: `pier-${name}` };
414
417
  }
415
418
 
416
- /** Why: Preserve the established compatibility and safety behavior (D98). */
419
+ /** Commit discipline is injected into the prompt: the worktree has no other protection. */
417
420
  export function buildIsolatePreamble(opts: { worktreePath: string; branch: string; baseShort: string }): string {
418
421
  return [
419
422
  `You are working in an isolated git worktree: ${opts.worktreePath} (branch ${opts.branch}, base ${opts.baseShort}).`,
@@ -424,7 +427,6 @@ export function buildIsolatePreamble(opts: { worktreePath: string; branch: strin
424
427
  ].join('\n');
425
428
  }
426
429
 
427
- /** Why: Preserve the established compatibility and safety behavior (D98). */
428
430
  export function formatWorktreeStat(opts: {
429
431
  branch: string | null;
430
432
  commits: number | null;
@@ -443,17 +445,16 @@ export function formatWorktreeStat(opts: {
443
445
  return `git: ${opts.statLine}; uncommitted: ${opts.dirtyCount} file(s)`;
444
446
  }
445
447
 
446
- /** Why: Preserve the established compatibility and safety behavior (D98). */
447
- export type ReleaseDecision = { action: 'release' } | { action: 'retain'; reason: 'unmerged' | 'dirty' | 'unknown' };
448
-
449
- 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' } {
450
451
  if (opts.merged === null || opts.dirtyCount === null) return { action: 'retain', reason: 'unknown' };
451
452
  if (!opts.merged) return { action: 'retain', reason: 'unmerged' };
452
453
  if (opts.dirtyCount > 0) return { action: 'retain', reason: 'dirty' };
453
454
  return { action: 'release' };
454
455
  }
455
456
 
456
- /** Why: Preserve the established compatibility and safety behavior (D98). */
457
+ /** `git worktree list --porcelain` → branch (refs/heads/ stripped) → path. */
457
458
  export function parseWorktreePorcelain(out: string): Map<string, string> {
458
459
  const byBranch = new Map<string, string>();
459
460
  let curPath: string | null = null;
@@ -465,3 +466,139 @@ export function parseWorktreePorcelain(out: string): Map<string, string> {
465
466
  }
466
467
  return byBranch;
467
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
+ }