pi-pier 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +270 -47
  19. package/src/core/todo.ts +49 -14
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +60 -3
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +161 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
- import { sessionDirCandidates, sessionDirName } from './storage-layout.ts';
12
+ import { piSessionDirCandidates, sessionDirName } from './storage-layout.ts';
13
13
 
14
14
  export { sessionDirName };
15
15
 
@@ -90,6 +90,34 @@ export function hasAssistantAfter(
90
90
  return !!m && m.role === 'assistant' && (typeof m.timestamp === 'number' ? m.timestamp : 0) >= sinceTs;
91
91
  });
92
92
  }
93
+ /**
94
+ * Return whether the NEWEST assistant message after `sinceTs` ended its turn.
95
+ *
96
+ * Why (A16): a worker sitting between tool calls (tool result already written, next assistant
97
+ * message still streaming) has "an assistant message" but has NOT finished. Treating any assistant
98
+ * message as activity made the poller announce such workers as finished — three real workers were
99
+ * declared settled while still working on 2026-09-13 and one was later closed mid-task by GC.
100
+ *
101
+ * `stopReason === 'toolUse'` means the model requested tools, so the turn continues; a non-string
102
+ * stopReason (message still streaming / older shapes) is treated as "not ended" on purpose — the
103
+ * cost of waiting is small, the cost of a false settlement is losing supervision of live work.
104
+ */
105
+ export function lastAssistantTurnEnded(
106
+ entries: readonly SessionEntryLike[],
107
+ sinceTs: number,
108
+ ): boolean {
109
+ let newest: { ts: number; stopReason: unknown } | null = null;
110
+ for (const entry of entries) {
111
+ const m = messageOf(entry);
112
+ if (!m || m.role !== 'assistant') continue;
113
+ const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
114
+ if (ts < sinceTs) continue;
115
+ if (!newest || ts >= newest.ts) newest = { ts, stopReason: m.stopReason };
116
+ }
117
+ if (!newest) return false;
118
+ const sr = newest.stopReason;
119
+ return typeof sr === 'string' && sr !== 'toolUse' && sr !== 'pending';
120
+ }
93
121
  /**
94
122
  * Return whether an initiated toolCall still lacks a result after injection (for example,
95
123
  * ask_user_question waiting on human input), meaning settlement has not actually completed
@@ -115,10 +143,37 @@ export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs
115
143
 
116
144
 
117
145
 
118
- /** Newest `limit` session files under cwd's session dir (new encoding, then legacy). */
146
+ /** Derived settlement state of one child session, from the inject point onward. */
147
+ export interface SubSessionState {
148
+ text: string | null;
149
+ pendingTool: boolean;
150
+ activity: boolean;
151
+ turnEnded: boolean;
152
+ }
153
+
154
+ /**
155
+ * Derive the settlement state from already-parsed entries.
156
+ *
157
+ * Closing text is by construction terminal (lastAssistantText requires stopReason 'stop').
158
+ * A16: an assistant message alone is not a settlement; the turn must have ENDED.
159
+ */
160
+ export function deriveSubSessionState(
161
+ entries: readonly SessionEntryLike[],
162
+ sinceTs: number,
163
+ ): SubSessionState {
164
+ const r = lastAssistantText(entries, { sinceTs });
165
+ if (r?.text) return { text: r.text, pendingTool: false, activity: true, turnEnded: true };
166
+ if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true, turnEnded: false };
167
+ if (hasAssistantAfter(entries, sinceTs)) {
168
+ return { text: null, pendingTool: false, activity: true, turnEnded: lastAssistantTurnEnded(entries, sinceTs) };
169
+ }
170
+ return { text: null, pendingTool: false, activity: false, turnEnded: false };
171
+ }
172
+
173
+ /** Newest `limit` session files under cwd's session dir (pi core's name first, then pier's old encodings). */
119
174
  export function listSessionFiles(cwd: string, agentDir: string, limit = 4): string[] {
120
175
  const files: Array<{ file: string; mtime: number }> = [];
121
- for (const name of sessionDirCandidates(cwd)) {
176
+ for (const name of piSessionDirCandidates(cwd)) {
122
177
  const dir = path.join(agentDir, name);
123
178
  let names: string[];
124
179
  try {
@@ -142,11 +197,11 @@ export function listSessionFiles(cwd: string, agentDir: string, limit = 4): stri
142
197
 
143
198
  /**
144
199
  * Locate a session file by id (`<ts>_<id>.jsonl`).
145
- * Searches the new encoding dir first, then the legacy flattened dir.
200
+ * Searches pi core's own directory name first, then pier's legacy flattened dirs.
146
201
  */
147
202
  export function sessionFileById(cwd: string, agentDir: string, id: string): string | null {
148
203
  const suffix = `_${id}.jsonl`;
149
- for (const name of sessionDirCandidates(cwd)) {
204
+ for (const name of piSessionDirCandidates(cwd)) {
150
205
  const dir = path.join(agentDir, name);
151
206
  let names: string[];
152
207
  try {
@@ -21,6 +21,8 @@ export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
21
21
  export interface SettleWakeInput {
22
22
  /** stopReason of the last assistant turn before this settlement (unknown = null, treated as natural completion). */
23
23
  lastStopReason: string | null;
24
+ /** True when abort was triggered intentionally by internal mechanisms like OCC. */
25
+ intentionalAbort?: boolean;
24
26
  /** Background subagents still running (empty means none). */
25
27
  running: ReadonlyArray<{ paneId: string }>;
26
28
  /** Set key recorded for the previous D96 notice (none = null). */
@@ -41,7 +43,7 @@ export interface SettleWakePlan {
41
43
  }
42
44
 
43
45
  export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
44
- if (input.lastStopReason === ABORT_STOP_REASON) {
46
+ if (input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
45
47
  return { wake: false, notice: false, noticeKey: input.lastNoticeKey, noticeAt: input.lastNoticeAt };
46
48
  }
47
49
  const key = input.running.length === 0 ? null : input.running.map((s) => s.paneId).sort().join(',');
package/src/slim-frame.ts CHANGED
@@ -21,11 +21,16 @@
21
21
  * (done() is never called, leaving the Promise pending, swallowing rejection, and never awaiting it). If switching
22
22
  * pi sessions removes the overlay (resetExtensionUI), the static frame naturally exits without affecting the main
23
23
  * flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
24
+ * Resize watchdog (D98): the component owns a SIGWINCH listener plus a 1s size poll that call tui.requestRender() on
25
+ * any PTY size change. Why: long-running sessions stop re-rendering on resize (observed in herdr — an idle master
26
+ * shrunk by heat reflow froze on a stale clipped frame: no todo, no title), so pi's own resize wiring cannot be relied
27
+ * on; columns/rows are read live per render, so one render is the whole fix. Stopped in dispose().
24
28
  */
25
29
 
26
30
  import { isArchived } from './stale-core.ts';
27
31
  import type { TodoItem } from './vocab.ts';
28
32
  import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from './todo-window.ts';
33
+ import { pierOption } from './pier-options.ts';
29
34
 
30
35
  /** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
31
36
  * Below either threshold the pane cannot display a readable TUI, so the overlay covers it. */
@@ -38,6 +43,8 @@ export const SLIM_TODO_MIN_ROWS = 3;
38
43
  export const SLIM_TODO_MIN_COLS = 16;
39
44
  /** Empty-list copy: the pane is alive but has no plan yet (replaces a lone ·). */
40
45
  export const SLIM_EMPTY_COPY = 'no todos yet';
46
+ /** D98: watchdog poll period — SIGWINCH covers the delivered signal; the poll covers lost ones (e.g. after suspend). */
47
+ export const RESIZE_WATCHDOG_MS = 1_000;
41
48
 
42
49
  /** Visibility predicate (overlayOptions.visible invokes it on every render frame). */
43
50
  export function isSlimFrame(cols: number, rows: number): boolean {
@@ -227,6 +234,11 @@ class SlimFrameComponent {
227
234
  private cache: { width: number; lines: string[] } | null = null;
228
235
  private readonly tui: FrameTui;
229
236
  private readonly colorize: (s: string) => string;
237
+ /** D98 watchdog: last PTY size seen by onMaybeResized (re-seeded at watchdog start). */
238
+ private seenCols = 0;
239
+ private seenRows = 0;
240
+ private sigwinch: (() => void) | null = null;
241
+ private poll: NodeJS.Timeout | null = null;
230
242
 
231
243
  constructor(tui: FrameTui, colorize: (s: string) => string) {
232
244
  this.tui = tui;
@@ -271,13 +283,56 @@ class SlimFrameComponent {
271
283
 
272
284
  /** Reclaim the module singleton when pi tears down the overlay (session switch/exit), allowing re-registration. */
273
285
  dispose(): void {
286
+ this.stopResizeWatchdog();
274
287
  if (active === this) active = null;
275
288
  }
289
+
290
+ /** Live PTY size (pi-tui reads the same source on every render; an instance method so tests can stub it). */
291
+ viewportSize(): { cols: number; rows: number } {
292
+ return { cols: process.stdout.columns ?? 0, rows: process.stdout.rows ?? 0 };
293
+ }
294
+
295
+ /** D98: SIGWINCH handler and poll tick — exactly one requestRender per actual size change; the next render reads live sizes. */
296
+ readonly onMaybeResized = (): void => {
297
+ const { cols, rows } = this.viewportSize();
298
+ if (cols === this.seenCols && rows === this.seenRows) return;
299
+ this.seenCols = cols;
300
+ this.seenRows = rows;
301
+ this.tui.requestRender();
302
+ };
303
+
304
+ /** Start the D98 watchdog (idempotent). SIGWINCH covers the delivered signal; the poll covers lost ones. */
305
+ startResizeWatchdog(): void {
306
+ if (this.poll) return;
307
+ const size = this.viewportSize();
308
+ this.seenCols = size.cols;
309
+ this.seenRows = size.rows;
310
+ try {
311
+ process.on('SIGWINCH', this.onMaybeResized);
312
+ this.sigwinch = this.onMaybeResized;
313
+ } catch {
314
+ /* Platforms without a SIGWINCH mapping keep the poll only. */
315
+ }
316
+ this.poll = setInterval(this.onMaybeResized, RESIZE_WATCHDOG_MS);
317
+ this.poll.unref?.();
318
+ }
319
+
320
+ private stopResizeWatchdog(): void {
321
+ if (this.sigwinch) {
322
+ process.removeListener('SIGWINCH', this.sigwinch);
323
+ this.sigwinch = null;
324
+ }
325
+ if (this.poll) {
326
+ clearInterval(this.poll);
327
+ this.poll = null;
328
+ }
329
+ }
276
330
  }
277
331
 
278
332
 
279
- /** Test injection point (reset module state). */
333
+ /** Test injection point (reset module state); dispose first so an undisposed component's watchdog cannot leak. */
280
334
  export function resetForTest(): void {
335
+ active?.dispose();
281
336
  active = null;
282
337
  }
283
338
 
@@ -289,7 +344,7 @@ let active: SlimFrameComponent | null = null;
289
344
  */
290
345
  export function registerSlimFrame(eventCtx: unknown): void {
291
346
  if (active) return;
292
- if (process.env.PI_HERDR_SLIM_FRAME === '0') return;
347
+ if (pierOption('PIER_SLIM_FRAME') === '0') return;
293
348
  const ui = eventCtx !== null && typeof eventCtx === 'object' && 'ui' in eventCtx
294
349
  ? eventCtx.ui
295
350
  : null;
@@ -299,9 +354,11 @@ export function registerSlimFrame(eventCtx: unknown): void {
299
354
  try {
300
355
  // overlay: true keeps the floating layer from clearing the screen (pi ctx.ui.custom replaces the editor area by default, so cover it explicitly);
301
356
  // nonCapturing leaves the keyboard with the editor (typing in a focused narrow pane remains possible, and the expanded heat view hides the frame).
357
+ if (!custom) throw new Error('pi ui.custom unavailable');
302
358
  const pending = custom(
303
- (tui, theme) => {
359
+ (tui: FrameTui, theme: { fg(color: string, text: string): string }) => {
304
360
  const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
361
+ comp.startResizeWatchdog();
305
362
  active = comp;
306
363
  return comp;
307
364
  },
@@ -43,6 +43,29 @@ export function sessionDirCandidates(cwd: string): readonly string[] {
43
43
  return next === prev ? [next] : [next, prev];
44
44
  }
45
45
 
46
+ /**
47
+ * pi core's own session-directory encoding, byte-for-byte.
48
+ *
49
+ * Why: `<agentDir>/sessions/` is owned by pi core, which names it
50
+ * `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--` (strip ONE leading separator,
51
+ * then flatten `/` `\` `:` — note it does NOT escape `%` like `sessionDirName` does).
52
+ * Verified on this machine: `/Users/yehaoyu/Documents/pier` →
53
+ * `--Users-yehaoyu-Documents-pier--` (pier's own encodings produce `---Users-…--` and
54
+ * `--%2FUsers…--`, neither of which exists on disk).
55
+ */
56
+ export function piCoreSessionDirName(cwd: string): string {
57
+ return `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
58
+ }
59
+
60
+ /**
61
+ * Candidates for reading pi core's session dirs: pi core's exact name first, then pier's
62
+ * own historical encodings (they may hold stray dirs from earlier versions).
63
+ * Read-only on purpose: writes to `<agentDir>/sessions/` are pi core's business.
64
+ */
65
+ export function piSessionDirCandidates(cwd: string): readonly string[] {
66
+ return [...new Set([piCoreSessionDirName(cwd), ...sessionDirCandidates(cwd)])];
67
+ }
68
+
46
69
  /**
47
70
  * Directory to read/write under `parent`.
48
71
  * Existing legacy dirs win over a missing new dir so history is not split.
@@ -91,6 +91,8 @@ export interface AliveProbe {
91
91
  agentStatus: string | null;
92
92
  /** Why: Preserve the established compatibility and safety behavior. */
93
93
  lastActivityMs: number | null;
94
+ /** Herdr 0.9.1: foreground working directory of the PTY process. */
95
+ foregroundCwd?: string | null;
94
96
  }
95
97
 
96
98
  /** Why: Preserve the established compatibility and safety behavior. */
@@ -368,7 +370,7 @@ export interface LaunchRuntime {
368
370
  export function buildLaunchParts(
369
371
  runtime: LaunchRuntime,
370
372
  opts: { resumeFile?: string | null; roleModel?: string | null; approve?: boolean } = {},
371
- env: Pick<NodeJS.ProcessEnv, 'PI_HERDR_TUI'> = process.env,
373
+ env: { PI_HERDR_TUI?: string | undefined } = process.env,
372
374
  ): string[] {
373
375
  const parts = [runtime.nodePath, runtime.cliPath];
374
376
  if (opts.approve) parts.push('-a');
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Subagent `spawn` action (isolate worktree, pane launch, prompt injection, foreground wait).
3
+ *
4
+ * Why: the plugin entry keeps tool registration and lifecycle wiring; the spawn path is the one
5
+ * action with git, placement, injection, and settle branches, so it lives here as a unit.
6
+ */
7
+ import { randomUUID } from 'node:crypto';
8
+ import { mkdirSync, stat as statCb } from 'node:fs';
9
+ import { basename, dirname, join, resolve as pathResolve } from 'node:path';
10
+ import { promisify } from 'node:util';
11
+ import type { HerdrClientLike } from './herdr-client.ts';
12
+ import { composeForRole } from './manifest-compose.ts';
13
+ import { pipeNameFor, pipeRequestTo } from './pipe-channel.ts';
14
+ import { platformPaths } from './platform-paths.ts';
15
+ import { runtimePolicy } from './runtime-policy.ts';
16
+ import {
17
+ FOREGROUND_POLL_MS,
18
+ planForegroundTick,
19
+ planIsolateRepoGuard,
20
+ planLaunchValidation,
21
+ planPatienceExpiry,
22
+ } from './subagent-launch.ts';
23
+ import {
24
+ Semaphore,
25
+ buildAliveNotice,
26
+ buildBlockedGateNotice,
27
+ buildIsolatePreamble,
28
+ classifyWorktreeZone,
29
+ formatSubagentResult,
30
+ isAlive,
31
+ isPathUnder,
32
+ makeProgressUpdate,
33
+ planIsolateWorktree,
34
+ type SubEntry,
35
+ } from './subagent-core.ts';
36
+ import type { HistoryEntry } from './history-store.ts';
37
+ import type { GitIo } from './subagent-git-io.ts';
38
+ import { sleep, type Poller } from './subagent-poll-loop.ts';
39
+ import type { SessionIo } from './subagent-session-io.ts';
40
+ import type { SpawnEnv, Spawner } from './subagent-spawn.ts';
41
+ import { toolError } from './tool-error.ts';
42
+
43
+ const statAsync = promisify(statCb);
44
+
45
+ export interface SpawnActionHost {
46
+ client: HerdrClientLike;
47
+ env: SpawnEnv | null;
48
+ subSemaphore: Semaphore;
49
+ subs: Map<string, SubEntry>;
50
+ persistSubs(): void;
51
+ writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
52
+ pendingIsolateBranches: Set<string>;
53
+ lastMachineInjectAt: Map<string, number>;
54
+ lastRequestIdByPane: Map<string, string>;
55
+ git: GitIo;
56
+ session: SessionIo;
57
+ spawn: Spawner;
58
+ poller: Poller;
59
+ }
60
+
61
+ export type SpawnAction = (
62
+ params: Record<string, unknown> | undefined,
63
+ toolCtx: unknown,
64
+ onUpdate?: (update: unknown) => void,
65
+ ) => Promise<unknown>;
66
+
67
+ export function createSpawnAction(h: SpawnActionHost): SpawnAction {
68
+ const { client, env, git, subs } = h;
69
+ const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = h.session;
70
+ const { spawnPaneInTaskTab, launchLine, waitSubReady } = h.spawn;
71
+ const { startPoller } = h.poller;
72
+
73
+ return async function executeSubagentSpawn(params, toolCtx, onUpdate) {
74
+ const launch = planLaunchValidation(params, client.available);
75
+ if (launch.kind === 'error') {
76
+ return toolError(launch.text);
77
+ }
78
+ const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
79
+ const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
80
+ // Move taskId generation above the original spawn section because isolate planning needs taskHex; this is behavior-neutral.
81
+ const taskId = randomUUID();
82
+ // D98 2c: Create the isolate through pier's execFile git path (decision 1), not herdr's
83
+ // socket worktree.create, which has bootstrap races, missing root-pane env, worker/master
84
+ // workspace separation, and linked_worktree_source rejection. The model makes the decision; do not infer it.
85
+ let isolateMeta: SubEntry['isolate'] | null = null;
86
+ let cwd = masterCwd;
87
+ if (isolate) {
88
+ const baseSha = await git.runGit(masterCwd, ['rev-parse', 'HEAD']);
89
+ const isoGuard = planIsolateRepoGuard(baseSha);
90
+ if (isoGuard.kind === 'error') {
91
+ return toolError(isoGuard.text);
92
+ }
93
+ const sha = isoGuard.sha.trim();
94
+ const pierBranches = new Set(
95
+ ((await git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
96
+ .split('\n').filter(Boolean),
97
+ );
98
+ const worktrees = await git.listWorktrees(masterCwd);
99
+ const repoName = basename(worktrees[0] ?? masterCwd);
100
+ const plan = planIsolateWorktree({
101
+ description: spec.description,
102
+ taskHex: taskId.slice(0, 6),
103
+ existingPierBranches: pierBranches,
104
+ });
105
+ const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
106
+ try {
107
+ mkdirSync(dirname(wtPath), { recursive: true }); // git worktree add does not create its parent directory, so a first run would fail.
108
+ } catch { /* Existing directory or permissions failure is handled by worktree add. */ }
109
+ // Keep the branch pending between worktree add and subs.set: the 2–3s readiness wait lets the ticker
110
+ // scan, and without this guard it could reclaim a new clean =HEAD-ancestor worktree as an orphan.
111
+ h.pendingIsolateBranches.add(plan.branch);
112
+ const added = await git.runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
113
+ if (added === null) {
114
+ h.pendingIsolateBranches.delete(plan.branch);
115
+ return toolError(`Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries`);
116
+ }
117
+ git.invalidateWorktreesCache(); // Invalidate the 5s cache so the zone classifier sees the new worktree immediately.
118
+ cwd = wtPath;
119
+ isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
120
+ spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
121
+ } else if (cwdParam) {
122
+ cwd = pathResolve(masterCwd, cwdParam);
123
+ try {
124
+ const st = await statAsync(cwd);
125
+ if (!st.isDirectory()) throw new Error('not a directory');
126
+ } catch {
127
+ return toolError(`Error: \`cwd\` is not an existing directory: ${cwdParam}`);
128
+ }
129
+ }
130
+ // D86 R1: Group by the git worktree containing cwd (main checkout → main tab; other worktree → directory-named tab).
131
+ const zone = classifyWorktreeZone({
132
+ cwd,
133
+ masterCwd,
134
+ worktrees: await git.listWorktrees(masterCwd),
135
+ });
136
+ // D86 trust: pass -a only for the master's checkout/worktrees; external directories remain behind pi's Trust dialog.
137
+ const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
138
+ let roleManifestEnv: Record<string, string> = {};
139
+ let roleModel: string | null = null;
140
+ try {
141
+ // planLaunchValidation always names a role ('worker-default' by default); keep the same fallback
142
+ // here for callers that build a launch spec directly.
143
+ const roleName = manifestRole ?? 'worker-default';
144
+ const { role, manifest } = composeForRole(roleName, suggested, { loadRoleOpts: { baseDir: masterCwd } });
145
+ roleManifestEnv = {
146
+ PI_HERDR_ROLE_MANIFEST: JSON.stringify({
147
+ role: role.role,
148
+ version: role.version,
149
+ tools: manifest.tools,
150
+ permissions: manifest.permissions,
151
+ unknownTools: manifest.unknownTools,
152
+ services: role.services ?? {},
153
+ }),
154
+ };
155
+ // WS-D10: Route the model by role; omission intentionally uses the process default.
156
+ if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
157
+ } catch (err) {
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ return toolError(`Error: role "${manifestRole}" manifest invalid — ${msg}`);
160
+ }
161
+ const release = await h.subSemaphore.acquire();
162
+ let paneId = '';
163
+ try {
164
+ const spawnedAt = Date.now();
165
+ const spawned = await spawnPaneInTaskTab(
166
+ { desiredTab: tab, description: spec.description, zone },
167
+ cwd,
168
+ { PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
169
+ launchLine(null, roleModel, approve),
170
+ );
171
+ paneId = spawned.paneId;
172
+ const entry: SubEntry = {
173
+ taskId,
174
+ kind,
175
+ paneId,
176
+ tabId: spawned.tabId,
177
+ tabName: spawned.tabName,
178
+ cwd,
179
+ description: spec.description,
180
+ background,
181
+ status: 'running',
182
+ sessionFile: null,
183
+ launchCommand: [launchLine(null, roleModel, approve)],
184
+ createdAt: Date.now(),
185
+ revivedFrom: null,
186
+ ...(isolateMeta ? { isolate: isolateMeta } : {}),
187
+ };
188
+ const ready = await waitSubReady(cwd, paneId);
189
+ if (!ready.ok) throw new Error(ready.message);
190
+ entry.sessionFile = await resolveSessionFile(paneId, cwd);
191
+ subs.set(paneId, entry);
192
+ h.persistSubs();
193
+ onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
194
+ // M11 (D45/D46): Inject through the extension pipe to the child extension's sendUserMessage(followUp);
195
+ // reserve the PTY keyboard channel entirely for the person, with no soft-lock window or mixed input.
196
+ // Regression evidence (session 01a03bf0): ecc0bc4's foreground-wait rewrite once removed this block,
197
+ // leaving the prompt uninjected and injectTs undefined (ReferenceError → spawn-failed, with a ghost
198
+ // running ledger entry). Keep injection and injectTs together in future refactors.
199
+ const injectTs = Date.now();
200
+ const injected = await pipeRequestTo(cwd, paneId, {
201
+ type: 'prompt',
202
+ id: `prompt-${taskId}`,
203
+ text: spec.prompt,
204
+ from: pipeNameFor(cwd, env?.paneId ?? ''),
205
+ push: background,
206
+ });
207
+ if (injected.type !== 'ok') {
208
+ throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
209
+ }
210
+ h.lastMachineInjectAt.set(paneId, injectTs); // B4: attribute working state during the observation window.
211
+
212
+ // A7: run_in_background returns immediately after successful injection instead of blocking in foreground wait.
213
+ if (background) {
214
+ entry.background = true;
215
+ h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
216
+ void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
217
+ h.persistSubs();
218
+ h.writeHistory(entry, undefined, 'to-background');
219
+ return {
220
+ content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
221
+ details: { paneId, taskId, background: true, role: kind },
222
+ };
223
+ }
224
+
225
+ // A1+A2 (user-verified fix): foreground waiting uses a content gate plus a patience threshold before backgrounding.
226
+ // Treating idle as settled with a hard 90s window misclassified real 4–6 minute working periods as no-output;
227
+ // three healthy subagents were observed becoming consumed at 101s while producing results four minutes later.
228
+ const patienceDeadline = Date.now() + runtimePolicy.foregroundPatienceMs;
229
+ let text: string | null = null;
230
+ let settledKind: 'settled' | 'timeout' = 'timeout';
231
+ while (Date.now() < Math.min(patienceDeadline, spawnedAt + runtimePolicy.subagentTimeoutMs)) {
232
+ const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
233
+ const session = (state === 'idle' || state === 'done')
234
+ ? await subSessionState(paneId, cwd, injectTs)
235
+ : { text: null, pendingTool: false, activity: false };
236
+ const tick = planForegroundTick({ state, session });
237
+ if (tick.kind === 'blocked') {
238
+ const question = await readAskFlag(paneId);
239
+ entry.background = true;
240
+ h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
241
+ void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
242
+ h.persistSubs();
243
+ h.writeHistory(entry, undefined, 'to-background');
244
+ return {
245
+ content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
246
+ details: { paneId, taskId, background: true, blocked: true, role: kind },
247
+ };
248
+ }
249
+ if (tick.kind === 'settled') {
250
+ text = tick.text;
251
+ settledKind = 'settled';
252
+ break;
253
+ }
254
+ if (tick.kind === 'wait') {
255
+ await sleep(tick.delayMs);
256
+ continue;
257
+ }
258
+ if (tick.kind === 'collect-final') {
259
+ text = await collectFinalText(paneId, cwd, injectTs, 6);
260
+ if (text) { settledKind = 'settled'; break; }
261
+ await sleep(FOREGROUND_POLL_MS);
262
+ continue;
263
+ }
264
+ }
265
+ if (!text) {
266
+ const probe = await probeAlive(paneId, cwd);
267
+ if (planPatienceExpiry(isAlive(probe, Date.now())) === 'move-to-background') {
268
+ entry.background = true;
269
+ h.lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
270
+ void startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
271
+ h.persistSubs();
272
+ const notice = buildAliveNotice(
273
+ { paneId, description: spec.description, scenario: 'moved-to-bg', probe },
274
+ Date.now(),
275
+ );
276
+ return {
277
+ content: [{ type: 'text', text: notice }],
278
+ details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
279
+ };
280
+ }
281
+ settledKind = 'timeout';
282
+ }
283
+ const outcome =
284
+ settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
285
+ : text ? { kind: 'completed' as const, text }
286
+ : { kind: 'no-output' as const, text: '' };
287
+ entry.status = 'consumed';
288
+ entry.consumedAt = Date.now();
289
+ h.persistSubs();
290
+ h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
291
+ return {
292
+ content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
293
+ details: { paneId, taskId, background, role: kind },
294
+ };
295
+ } catch (err) {
296
+ // Regression fix (session 01a03bf0): a mid-spawn failure must remove the ledger entry and close its pane,
297
+ // otherwise a ghost running entry never settles, causing D96 reminder storms and send_message calls
298
+ // to land in an empty session with no task context. Pane closure is best effort; GC covers board exceptions.
299
+ if (paneId) {
300
+ subs.delete(paneId);
301
+ h.persistSubs();
302
+ void client.closePane(paneId).catch(() => { /* Best effort only. */ });
303
+ }
304
+ // D98 2d: If isolate startup never becomes ready, best-effort remove the new worktree and branch (no work
305
+ // exists to preserve); failure stays silent because the branch is harmless and remains visible to git worktree list.
306
+ if (isolateMeta) {
307
+ const branch = isolateMeta.branch;
308
+ const worktreePath = isolateMeta.worktreePath;
309
+ void git.runGit(masterCwd, ['worktree', 'remove', '--force', worktreePath])
310
+ .then(() => git.runGit(masterCwd, ['branch', '-D', branch]))
311
+ .catch(() => { /* Best effort only. */ });
312
+ }
313
+ return {
314
+ content: [{
315
+ type: 'text',
316
+ text: formatSubagentResult(
317
+ { kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
318
+ spec.description,
319
+ ),
320
+ }],
321
+ details: {},
322
+ };
323
+ } finally {
324
+ release();
325
+ if (isolateMeta) h.pendingIsolateBranches.delete(isolateMeta.branch); // D98: release the creation-window guard on every success/failure path.
326
+ }
327
+ };
328
+ }