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,44 +1,275 @@
1
1
  /**
2
- * Task-tab pane spawn, launch-line construction, and trust-flag decisions.
3
- *
4
- * Why: spawn mixed herdr layout I/O with the subagent tool body. Placement
5
- * races (D26 mutex) and D86 worktree grouping belong in one adapter.
2
+ * Subagent spawn domain: git/worktree I/O, tab placement, launch line, readiness wait, and the `spawn`
3
+ * action; placement is serialized by a mutex (concurrent spawns raced on tab lookup).
6
4
  */
5
+ import { execFile as nodeExecFile } from 'node:child_process';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { mkdirSync, stat as statCb } from 'node:fs';
8
+ import { basename, dirname, join, resolve as pathResolve } from 'node:path';
9
+ import { promisify } from 'node:util';
7
10
  import type { HerdrClientLike } from './herdr-client.ts';
8
- import { pingUntilReady, pipeNameCandidates } from './pipe-channel.ts';
11
+ import { pipeNameCandidates, pipeNameFor, pipeRequest, pipeRequestTo } from './pipe-channel.ts';
12
+ import { composeForRole } from './manifest-compose.ts';
13
+ import { platformPaths } from './platform-paths.ts';
14
+ import { planSpawnProfileRow, type RoutingTelemetryRecord } from './routing-telemetry.ts';
9
15
  import { runtimePolicy } from './runtime-policy.ts';
16
+ import { toolError } from './tool-error.ts';
17
+ import type { HistoryEntry } from './history-store.ts';
10
18
  import {
19
+ FOREGROUND_POLL_MS,
11
20
  Semaphore,
21
+ buildAliveNotice,
22
+ buildBlockedGateNotice,
23
+ buildIsolatePreamble,
12
24
  buildLaunchLine,
13
25
  buildLaunchParts,
14
26
  classifyWorktreeZone,
27
+ formatSubagentResult,
28
+ formatWorktreeStat,
29
+ isAlive,
15
30
  isPathUnder,
31
+ makeProgressUpdate,
32
+ planForegroundTick,
33
+ planIsolateWorktree,
34
+ planLaunchValidation,
16
35
  planTabPlacement,
36
+ sleep,
37
+ type SubEntry,
17
38
  type TabPlacementPlan,
18
39
  type WorktreeZone,
19
40
  } from './subagent-core.ts';
20
- import { parseShapeTree, pickGridSplit } from './core/grid-shape.ts';
21
- import type { GitIo } from './subagent-git-io.ts';
41
+ import { parseShapeTree, pickGridSplit, type PaneCell } from './plugins/grid-shape.ts';
42
+ import { fromShapeTree, planSpawnSplitRatio, type LayoutNode } from './plugins/heat-plan.ts';
43
+ import type { Poller } from './subagent-poller.ts';
44
+ import type { SessionIo } from './subagent-session.ts';
22
45
 
23
- export interface SpawnEnv {
46
+ const statAsync = promisify(statCb);
47
+
48
+ /* ── git adapter ────────────────────────────────────────────────── */
49
+
50
+ export interface GitResult {
51
+ readonly stdout: string;
52
+ readonly stderr: string;
53
+ }
54
+
55
+ export interface GitError extends Error {
56
+ readonly code?: string | number;
57
+ readonly stdout?: string;
58
+ readonly stderr?: string;
59
+ }
60
+
61
+ export type GitExecFile = (
62
+ file: string,
63
+ args: readonly string[],
64
+ options: { timeout: number; encoding: 'utf8'; maxBuffer: number },
65
+ ) => Promise<{ stdout: string; stderr: string }>;
66
+
67
+ /** Output ceiling for one git call: a large checkout blows past execFile's 1MB default (ENOBUFS). */
68
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
69
+
70
+ export interface GitAdapter {
71
+ listWorktrees(cwd: string): Promise<GitResult>;
72
+ status(cwd: string): Promise<GitResult>;
73
+ /** Throws a GitError on failure/timeout; callers wanting "null on error" catch at the call site. */
74
+ run(cwd: string, args: readonly string[]): Promise<GitResult>;
75
+ }
76
+
77
+ const execFile = promisify(nodeExecFile);
78
+
79
+ export class NodeGitAdapter implements GitAdapter {
80
+ private readonly gitExecutable: string;
81
+ private readonly timeoutMs: number;
82
+ private readonly exec: GitExecFile;
83
+
84
+ constructor(
85
+ gitExecutable: string = 'git',
86
+ timeoutMs: number = runtimePolicy.gitTimeoutMs,
87
+ exec: GitExecFile = execFile as unknown as GitExecFile,
88
+ ) {
89
+ this.gitExecutable = gitExecutable;
90
+ this.timeoutMs = timeoutMs;
91
+ this.exec = exec;
92
+ }
93
+
94
+ listWorktrees(cwd: string): Promise<GitResult> {
95
+ return this.run(cwd, ['worktree', 'list', '--porcelain']);
96
+ }
97
+
98
+ status(cwd: string): Promise<GitResult> {
99
+ return this.run(cwd, ['status', '--short']);
100
+ }
101
+
102
+ async run(cwd: string, args: readonly string[]): Promise<GitResult> {
103
+ try {
104
+ const { stdout, stderr } = await this.exec(
105
+ this.gitExecutable,
106
+ ['-C', cwd, ...args],
107
+ { timeout: this.timeoutMs, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER },
108
+ );
109
+ return { stdout, stderr };
110
+ } catch (err) {
111
+ const message = err instanceof Error ? err.message : String(err);
112
+ throw Object.assign(err instanceof Error ? err : new Error(), {
113
+ message: `Git ${args[0] ?? 'command'} failed: ${message}`,
114
+ }) as GitError;
115
+ }
116
+ }
117
+ }
118
+
119
+ export const defaultGitAdapter = new NodeGitAdapter();
120
+
121
+ /* ── git I/O used by spawn, isolate, GC and settlement stat lines ── */
122
+
123
+ const WORKTREES_CACHE_MS = 5000;
124
+
125
+ export interface GitIo {
126
+ listWorktrees(cwd: string): Promise<string[]>;
127
+ runGit(cwd: string, args: string[]): Promise<string | null>;
128
+ worktreeStatLine(entry: SubEntry): Promise<string | null>;
129
+ invalidateWorktreesCache(): void;
130
+ }
131
+
132
+ export function createGitIo(git: GitAdapter = defaultGitAdapter): GitIo {
133
+ let worktreesCache: { at: number; list: string[] } | null = null;
134
+
135
+ async function listWorktrees(cwd: string): Promise<string[]> {
136
+ if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
137
+ let list: string[] = [];
138
+ try {
139
+ const { stdout } = await git.listWorktrees(cwd);
140
+ for (const line of String(stdout).split('\n')) {
141
+ const m = /^worktree (.+)$/.exec(line.trim());
142
+ if (m) list.push(m[1]!);
143
+ }
144
+ } catch {
145
+ list = [];
146
+ }
147
+ worktreesCache = { at: Date.now(), list };
148
+ return list;
149
+ }
150
+
151
+ async function runGit(cwd: string, args: string[]): Promise<string | null> {
152
+ try {
153
+ const { stdout } = await git.run(cwd, args);
154
+ return stdout == null ? null : String(stdout);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ function lastStatLine(out: string | null): string | null {
161
+ if (!out) return null;
162
+ const lines = out.replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
163
+ return lines.length ? lines[lines.length - 1]! : null;
164
+ }
165
+
166
+ async function worktreeStatLine(entry: SubEntry): Promise<string | null> {
167
+ const porcelain = await runGit(entry.cwd, ['status', '--porcelain']);
168
+ if (porcelain === null) return null;
169
+ const dirtyCount = porcelain.split('\n').filter((l) => l.trim() !== '').length;
170
+ if (entry.isolate) {
171
+ const statOut = await runGit(entry.cwd, ['diff', '--stat', `${entry.isolate.baseSha}...HEAD`]);
172
+ const commitsOut = await runGit(entry.cwd, ['rev-list', '--count', `${entry.isolate.baseSha}..HEAD`]);
173
+ const commits = commitsOut != null && /^\d+$/.test(commitsOut.trim()) ? Number(commitsOut.trim()) : null;
174
+ return formatWorktreeStat({ branch: entry.isolate.branch, commits, statLine: lastStatLine(statOut), dirtyCount });
175
+ }
176
+ return formatWorktreeStat({
177
+ branch: null,
178
+ commits: null,
179
+ statLine: lastStatLine(await runGit(entry.cwd, ['diff', '--stat', 'HEAD'])),
180
+ dirtyCount,
181
+ });
182
+ }
183
+
184
+ return {
185
+ listWorktrees,
186
+ runGit,
187
+ worktreeStatLine,
188
+ invalidateWorktreesCache: () => { worktreesCache = null; },
189
+ };
190
+ }
191
+
192
+ /* ── readiness wait (pure planners) ─────────────────────────────── */
193
+
194
+ const READY_BASE_INTERVAL_MS = 500;
195
+ const READY_MAX_INTERVAL_MS = 4000;
196
+ /** Liveness/agent sampling is far more expensive than a ping, so it runs on its own cadence. */
197
+ const READY_LIVENESS_SAMPLE_MS = 2500;
198
+ /** Enough of the pane tail to carry a stack trace head into the failure text. */
199
+ const READY_TAIL_CHARS = 1200;
200
+
201
+ interface ReadyFailure {
202
+ readonly paneId: string;
203
+ readonly reason: 'pane-gone' | 'timeout';
204
+ readonly elapsedMs: number;
205
+ readonly timeoutMs: number;
206
+ readonly lastStatus?: string | null;
207
+ readonly tail?: string | null;
208
+ readonly hint?: string | null;
209
+ }
210
+
211
+ type ReadyAttemptPlan =
212
+ | { readonly kind: 'ready' }
213
+ | { readonly kind: 'retry'; readonly delayMs: number }
214
+ | { readonly kind: 'give-up'; readonly reason: 'pane-gone' | 'timeout' };
215
+
216
+ /** Decide one readiness attempt. `alive` is tri-state on purpose: an unavailable liveness probe
217
+ * (`null`) must not be read as death, or a slow herdr socket fails spawns that are merely slow. */
218
+ export function planReadyAttempt(opts: {
219
+ elapsedMs: number;
220
+ attempt: number;
221
+ timeoutMs: number;
222
+ alive: boolean | null;
223
+ ready: boolean;
224
+ }): ReadyAttemptPlan {
225
+ if (opts.ready) return { kind: 'ready' };
226
+ if (opts.alive === false) return { kind: 'give-up', reason: 'pane-gone' };
227
+ if (opts.elapsedMs >= opts.timeoutMs) return { kind: 'give-up', reason: 'timeout' };
228
+ return { kind: 'retry', delayMs: readyBackoffMs(opts.attempt) };
229
+ }
230
+
231
+ /** Exponential backoff with a cap: 500ms, 1s, 2s, 4s, 4s, … */
232
+ export function readyBackoffMs(attempt: number, baseMs = READY_BASE_INTERVAL_MS, capMs = READY_MAX_INTERVAL_MS): number {
233
+ const n = Math.max(0, Math.floor(attempt));
234
+ return Math.min(capMs, baseMs * 2 ** n);
235
+ }
236
+
237
+ /** Failure text for the model; the three cases stay distinguishable: pane died (the tail carries the
238
+ * reason), pane alive but never opened its pipe, pane alive and just slower than the timeout. */
239
+ export function readyFailureText(failure: ReadyFailure): string {
240
+ const seconds = Math.round(failure.elapsedMs / 1000);
241
+ const head = failure.reason === 'pane-gone'
242
+ ? `subagent pane ${failure.paneId} exited before its pipe became ready (waited ${seconds}s)`
243
+ : `subagent pane ${failure.paneId} pipe not ready within ${seconds}s (limit ${Math.round(failure.timeoutMs / 1000)}s)`;
244
+ const why = failure.reason === 'pane-gone'
245
+ ? 'The child process is gone — its last output below usually carries the reason.'
246
+ : failure.lastStatus === 'working'
247
+ ? 'The pane is alive and working, so the prompt may simply be slow to boot; retrying the same call is safe.'
248
+ : 'The pane is alive but never registered its pipe — the child pi process may still be starting, or it failed to load the pier extension.';
249
+ const tail = failure.tail ? `\nlast output of ${failure.paneId}:\n${failure.tail}` : '';
250
+ const hint = failure.hint ? `\n${failure.hint}` : '';
251
+ return `${head}\n${why}${hint}${tail}`;
252
+ }
253
+
254
+ /* ── spawner ────────────────────────────────────────────────────── */
255
+
256
+ interface SpawnEnv {
24
257
  paneId: string;
25
258
  tabId: string;
26
259
  workspaceId: string;
27
260
  }
28
261
 
29
- export interface SpawnRuntime {
30
- nodePath: string;
31
- cliPath: string;
32
- extPath: string;
33
- }
34
-
35
- export interface SpawnerHost {
262
+ interface SpawnerHost {
36
263
  client: HerdrClientLike;
37
264
  env: SpawnEnv | null;
38
- runtime: SpawnRuntime;
265
+ runtime: { nodePath: string; cliPath: string; extPath: string };
39
266
  git: GitIo;
267
+ /** Test override. Production reads `runtimePolicy.readinessTimeoutMs`. */
268
+ readinessTimeoutMs?: number;
40
269
  }
41
270
 
271
+ type ReadyOutcome = { ok: true } | { ok: false; message: string };
272
+
42
273
  export interface Spawner {
43
274
  spawnPaneInTaskTab(
44
275
  placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
@@ -48,14 +279,13 @@ export interface Spawner {
48
279
  ): Promise<{ tabId: string; paneId: string; tabName: string }>;
49
280
  launchLine(resumeFile?: string | null, roleModel?: string | null, approve?: boolean): string;
50
281
  approveFor(cwd: string, masterCwd: string): Promise<boolean>;
51
- waitSubReady(cwd: string, paneId: string): Promise<boolean>;
52
- liveTabs(): Promise<Array<{ tabName: string; tabId: string }>>;
282
+ waitSubReady(cwd: string, paneId: string): Promise<ReadyOutcome>;
53
283
  findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null>;
54
284
  }
55
285
 
56
286
  export function createSpawner(h: SpawnerHost): Spawner {
57
287
  const tabMutex = new Semaphore(1);
58
- const readyTimeoutMs = runtimePolicy.readinessTimeoutMs;
288
+ const readyTimeoutMs = h.readinessTimeoutMs ?? runtimePolicy.readinessTimeoutMs;
59
289
 
60
290
  async function liveTabs(): Promise<Array<{ tabName: string; tabId: string }>> {
61
291
  try {
@@ -78,8 +308,99 @@ export function createSpawner(h: SpawnerHost): Spawner {
78
308
  return zone.zone === 'worktree';
79
309
  }
80
310
 
81
- async function waitSubReady(cwd: string, paneId: string): Promise<boolean> {
82
- return pingUntilReady(pipeNameCandidates(cwd, paneId), readyTimeoutMs);
311
+ /** Wait for the child's pipe with backoff; give up early when the pane is gone. The screen tail
312
+ * is sampled while the pane exists so a crash that leaves a shell still explains itself. */
313
+ async function waitSubReady(cwd: string, paneId: string): Promise<ReadyOutcome> {
314
+ const names = pipeNameCandidates(cwd, paneId);
315
+ const startedAt = Date.now();
316
+ let attempt = 0;
317
+ let lastProbeAt = 0;
318
+ let alive: boolean | null = null;
319
+ let lastStatus: string | null = null;
320
+ let tail: string | null = null;
321
+
322
+ while (true) {
323
+ const now = Date.now();
324
+ const elapsedMs = now - startedAt;
325
+ if (now - lastProbeAt >= READY_LIVENESS_SAMPLE_MS || attempt === 0) {
326
+ lastProbeAt = now;
327
+ const probe = await probeChildPane(paneId);
328
+ alive = probe.alive;
329
+ lastStatus = probe.status;
330
+ if (probe.tail) tail = probe.tail;
331
+ }
332
+ const pinged = await pingAnyName(names);
333
+ const plan = planReadyAttempt({ elapsedMs, attempt, timeoutMs: readyTimeoutMs, alive, ready: pinged });
334
+ if (plan.kind === 'ready') return { ok: true };
335
+ if (plan.kind === 'give-up') {
336
+ let explainHint: string | null = null;
337
+ if (h.client.available) {
338
+ try {
339
+ const diag = await h.client.agentExplain(paneId);
340
+ if (diag && typeof diag === 'object') {
341
+ const explainObj = ((diag.explain ?? diag) as Record<string, unknown>);
342
+ const parts: string[] = [];
343
+ if (explainObj.skip_state_reason) parts.push(`skip reason: ${String(explainObj.skip_state_reason)}`);
344
+ if (explainObj.screen_detection_skip_reason) parts.push(`screen rule bypassed: ${String(explainObj.screen_detection_skip_reason)}`);
345
+ if (explainObj.matched_rule) parts.push(`matched rule: ${String(explainObj.matched_rule)}`);
346
+ if (explainObj.idle_fallback_reason) parts.push(`idle fallback: ${String(explainObj.idle_fallback_reason)}`);
347
+ if (parts.length > 0) explainHint = `Herdr detection diagnosis: ${parts.join('; ')}`;
348
+ }
349
+ } catch {
350
+ /* diagnostics never mask the root error */
351
+ }
352
+ }
353
+ const defaultHint = plan.reason === 'timeout' && lastStatus === 'working'
354
+ ? 'Tip: pass run_in_background to avoid blocking the master turn while the worker boots.'
355
+ : null;
356
+ const hint = [explainHint, defaultHint].filter(Boolean).join('\n') || null;
357
+ const failure: ReadyFailure = {
358
+ paneId,
359
+ reason: plan.reason,
360
+ elapsedMs: Math.max(elapsedMs, 1),
361
+ timeoutMs: readyTimeoutMs,
362
+ lastStatus,
363
+ tail,
364
+ hint,
365
+ };
366
+ return { ok: false, message: readyFailureText(failure) };
367
+ }
368
+ attempt += 1;
369
+ await sleep(plan.delayMs);
370
+ }
371
+ }
372
+
373
+ /** One ping round over every candidate pipe name (mixed-version peers register different names). */
374
+ async function pingAnyName(names: readonly string[]): Promise<boolean> {
375
+ for (const name of names) {
376
+ try {
377
+ const res = await pipeRequest(name, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
378
+ if (res.type === 'ok') return true;
379
+ } catch { /* not ready on this name */ }
380
+ }
381
+ return false;
382
+ }
383
+
384
+ /** Pane liveness + last visible output; socket failures degrade to "unknown", never "dead". */
385
+ async function probeChildPane(paneId: string): Promise<{ alive: boolean | null; status: string | null; tail: string | null }> {
386
+ let alive: boolean | null = null;
387
+ let status: string | null = null;
388
+ try {
389
+ // pane.list includes unknown shells; agent.list does not. A fresh split is absent from
390
+ // agent.list until pi is identified — that is boot, not death.
391
+ const pane = (await h.client.listPanes()).find((p) => p.paneId === paneId);
392
+ alive = pane != null;
393
+ status = pane?.agentStatus ?? null;
394
+ } catch {
395
+ }
396
+ let tail: string | null = null;
397
+ try {
398
+ const read = await h.client.readPane(paneId, { stripAnsi: true });
399
+ if (read.text) tail = read.text.slice(-READY_TAIL_CHARS);
400
+ } catch {
401
+ /* recycled pane or socket blip — caller keeps the last cached tail */
402
+ }
403
+ return { alive, status, tail };
83
404
  }
84
405
 
85
406
  async function spawnPaneInTaskTab(
@@ -91,8 +412,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
91
412
  const release = await tabMutex.acquire();
92
413
  try {
93
414
  const allPanes = await h.client.listPanes();
94
- const mainTabId = allPanes.find((p) => p.paneId === h.env?.paneId)?.tabId
95
- ?? (h.env?.tabId ? h.env.tabId : null);
415
+ const mainTabId = allPanes.find((p) => p.paneId === h.env?.paneId)?.tabId ?? (h.env?.tabId ? h.env.tabId : null);
96
416
  let plan: TabPlacementPlan = planTabPlacement({
97
417
  desiredTab: placement.desiredTab,
98
418
  description: placement.description,
@@ -105,20 +425,51 @@ export function createSpawner(h: SpawnerHost): Spawner {
105
425
  allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
106
426
  );
107
427
  let pick: { targetPaneId: string; direction: 'right' | 'down' } | null = null;
428
+ let heatRoot: LayoutNode | null = null;
429
+ let focusPaneId: string | null = null;
430
+ let zoomed = false;
108
431
  try {
109
432
  const snapshot = await h.client.exportLayout({ tabId: plan.tabId });
110
433
  const tree = snapshot?.root ? parseShapeTree(snapshot.root) : null;
111
- if (tree) pick = pickGridSplit(tree, { exclude });
434
+ if (tree) {
435
+ heatRoot = fromShapeTree(tree);
436
+ focusPaneId = snapshot?.focusedPaneId ?? null;
437
+ zoomed = snapshot?.zoomed === true;
438
+ let cells: PaneCell[] | undefined;
439
+ try {
440
+ const live = await h.client.paneLayout({ paneId: allPanes.find((p) => p.tabId === plan.tabId)?.paneId });
441
+ if (live?.panes.length) {
442
+ cells = live.panes.map((p) => ({ id: p.paneId, x: p.x, y: p.y, w: p.w, h: p.h }));
443
+ }
444
+ } catch { /* pane.layout missing → 200×50 model */ }
445
+ pick = pickGridSplit(tree, { exclude, cells });
446
+ }
112
447
  } catch { /* layout export failure → legacy anchor split */ }
113
448
  const anchorPaneId = pick?.targetPaneId
114
449
  ?? allPanes.find((p) => p.tabId === plan.tabId && p.agentStatus !== 'unknown')?.paneId
115
450
  ?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
116
451
  if (anchorPaneId) {
452
+ const direction = pick?.direction ?? 'down';
453
+ const statuses: Record<string, string> = {};
454
+ for (const p of allPanes) {
455
+ if (p.tabId === plan.tabId) statuses[p.paneId] = p.agentStatus;
456
+ }
457
+ const ratio = heatRoot && !zoomed
458
+ ? planSpawnSplitRatio({
459
+ root: heatRoot,
460
+ targetPaneId: anchorPaneId,
461
+ focusPaneId: focusPaneId ?? anchorPaneId,
462
+ direction,
463
+ statuses,
464
+ })
465
+ : null;
117
466
  const paneId = await h.client.splitPane({
118
- direction: pick?.direction ?? 'down',
467
+ direction,
119
468
  cwd,
120
469
  env: envOver,
121
470
  targetPaneId: anchorPaneId,
471
+ focus: false,
472
+ ...(ratio != null ? { ratio } : {}),
122
473
  });
123
474
  await h.client.sendPaneText(paneId, launch);
124
475
  return { tabId: plan.tabId, paneId, tabName: plan.tabName };
@@ -138,19 +489,291 @@ export function createSpawner(h: SpawnerHost): Spawner {
138
489
  }
139
490
  }
140
491
 
492
+ /** D94: reuse a pane that already runs the same session instead of competing on one transcript. */
141
493
  async function findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null> {
142
494
  if (!sessionFile) return null;
143
495
  try {
144
496
  const agents = await h.client.listAgents();
145
497
  const match = agents.find((a) => a.session === sessionFile && a.status !== 'unknown');
146
498
  if (!match) return null;
147
- const panes = await h.client.listPanes();
148
- const pane = panes.find((p) => p.paneId === match.paneId);
499
+ const pane = (await h.client.listPanes()).find((p) => p.paneId === match.paneId);
149
500
  return pane ? { paneId: match.paneId, tabId: pane.tabId } : null;
150
501
  } catch {
151
502
  return null;
152
503
  }
153
504
  }
154
505
 
155
- return { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, liveTabs, findExistingPane };
506
+ return { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, findExistingPane };
507
+ }
508
+
509
+ /* ── spawn action ───────────────────────────────────────────────── */
510
+
511
+ interface SpawnActionHost {
512
+ client: HerdrClientLike;
513
+ env: SpawnEnv | null;
514
+ subSemaphore: Semaphore;
515
+ subs: Map<string, SubEntry>;
516
+ persistSubs(): void;
517
+ writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
518
+ pendingIsolateBranches: Set<string>;
519
+ lastMachineInjectAt: Map<string, number>;
520
+ lastRequestIdByPane: Map<string, string>;
521
+ git: GitIo;
522
+ session: SessionIo;
523
+ spawn: Spawner;
524
+ poller: Poller;
525
+ /** Phase 0 routing telemetry (RFC rfc-jev-role-routing §8): best-effort spawn profile; absent in tests. */
526
+ logRouting?: (row: RoutingTelemetryRecord) => void;
527
+ }
528
+
529
+ export function createSpawnAction(h: SpawnActionHost) {
530
+ const { client, env, git, subs } = h;
531
+ const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = h.session;
532
+ const { spawnPaneInTaskTab, launchLine, waitSubReady } = h.spawn;
533
+ const { startPoller } = h.poller;
534
+
535
+ return async function executeSubagentSpawn(
536
+ params: Record<string, unknown> | undefined,
537
+ toolCtx: unknown,
538
+ onUpdate?: (update: unknown) => void,
539
+ ): Promise<unknown> {
540
+ const launch = planLaunchValidation(params, client.available);
541
+ if (launch.kind === 'error') return toolError(launch.text);
542
+ const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
543
+ const masterCwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
544
+ const taskId = randomUUID();
545
+ // Validate the role before any isolate worktree exists: this early return has no cleanup path.
546
+ let roleManifestEnv: Record<string, string> = {};
547
+ let roleModel: string | null = null;
548
+ try {
549
+ const { role, manifest } = composeForRole(manifestRole ?? 'worker-default', suggested, { loadRoleOpts: { baseDir: masterCwd } });
550
+ roleManifestEnv = {
551
+ PI_HERDR_ROLE_MANIFEST: JSON.stringify({
552
+ role: role.role,
553
+ version: role.version,
554
+ tools: manifest.tools,
555
+ permissions: manifest.permissions,
556
+ unknownTools: manifest.unknownTools,
557
+ services: role.services ?? {},
558
+ // Without guidelines parseRuntimeManifest yields none and the pier-role prompt section silently no-ops.
559
+ ...(role.guidelines?.length ? { guidelines: role.guidelines } : {}),
560
+ }),
561
+ // Role base: /pier-role and the pipe switch resolve .pi-herdr/roles/ against the MASTER's checkout.
562
+ PI_HERDR_ROLE_BASE: masterCwd,
563
+ };
564
+ if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
565
+ h.logRouting?.(
566
+ planSpawnProfileRow({
567
+ now: Date.now(),
568
+ roleExplicit: typeof manifestRole === 'string' && manifestRole.trim() !== '',
569
+ role: role.role,
570
+ allowedTools: suggested,
571
+ manifestTools: manifest.tools,
572
+ task: spec.prompt,
573
+ }),
574
+ );
575
+ } catch (err) {
576
+ const msg = err instanceof Error ? err.message : String(err);
577
+ return toolError(`Error: role "${manifestRole}" manifest invalid — ${msg}`);
578
+ }
579
+ // D98: create the isolate through pier's execFile git path, not herdr's socket worktree.create
580
+ // (bootstrap races, missing root-pane env, linked_worktree_source rejection).
581
+ let isolateMeta: SubEntry['isolate'] | null = null;
582
+ let cwd = masterCwd;
583
+ if (isolate) {
584
+ const baseSha = await git.runGit(masterCwd, ['rev-parse', 'HEAD']);
585
+ if (baseSha == null) return toolError('Error: isolate requires a git repository with at least one commit');
586
+ const sha = baseSha.trim();
587
+ const pierBranches = new Set(
588
+ ((await git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
589
+ .split('\n').filter(Boolean),
590
+ );
591
+ const repoName = basename((await git.listWorktrees(masterCwd))[0] ?? masterCwd);
592
+ const plan = planIsolateWorktree({
593
+ description: spec.description,
594
+ taskHex: taskId.slice(0, 6),
595
+ existingPierBranches: pierBranches,
596
+ });
597
+ const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
598
+ try {
599
+ // git worktree add does not create its parent directory, so a first run would fail.
600
+ mkdirSync(dirname(wtPath), { recursive: true });
601
+ } catch { /* existing directory or permissions failure is handled by worktree add */ }
602
+ // Keep the branch pending between worktree add and subs.set: the readiness wait lets the
603
+ // ticker scan, which could otherwise reclaim a new clean =HEAD-ancestor worktree as an orphan.
604
+ h.pendingIsolateBranches.add(plan.branch);
605
+ const added = await git.runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
606
+ if (added === null) {
607
+ h.pendingIsolateBranches.delete(plan.branch);
608
+ return toolError(`Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries`);
609
+ }
610
+ git.invalidateWorktreesCache(); // so the zone classifier sees the new worktree immediately
611
+ cwd = wtPath;
612
+ isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
613
+ spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
614
+ } else if (cwdParam) {
615
+ cwd = pathResolve(masterCwd, cwdParam);
616
+ try {
617
+ if (!(await statAsync(cwd)).isDirectory()) throw new Error('not a directory');
618
+ } catch {
619
+ return toolError(`Error: \`cwd\` is not an existing directory: ${cwdParam}`);
620
+ }
621
+ }
622
+ // D86 R1: group by the git worktree containing cwd (main checkout → main tab; other → its tab).
623
+ const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await git.listWorktrees(masterCwd) });
624
+ // D86 trust: pass -a only for the master's checkout/worktrees; external dirs stay behind pi's Trust dialog.
625
+ const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
626
+
627
+ const release = await h.subSemaphore.acquire();
628
+ let paneId = '';
629
+ try {
630
+ const spawnedAt = Date.now();
631
+ const spawned = await spawnPaneInTaskTab(
632
+ { desiredTab: tab, description: spec.description, zone },
633
+ cwd,
634
+ { PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
635
+ launchLine(null, roleModel, approve),
636
+ );
637
+ paneId = spawned.paneId;
638
+ const entry: SubEntry = {
639
+ taskId,
640
+ kind,
641
+ paneId,
642
+ tabId: spawned.tabId,
643
+ tabName: spawned.tabName,
644
+ cwd,
645
+ description: spec.description,
646
+ background,
647
+ status: 'running',
648
+ sessionFile: null,
649
+ launchCommand: [launchLine(null, roleModel, approve)],
650
+ createdAt: Date.now(),
651
+ revivedFrom: null,
652
+ ...(isolateMeta ? { isolate: isolateMeta } : {}),
653
+ };
654
+ const ready = await waitSubReady(cwd, paneId);
655
+ if (!ready.ok) throw new Error(ready.message);
656
+ // herdr's per-pane report lags right after spawn, so the mtime fallback would otherwise adopt
657
+ // the newest PRE-EXISTING session in cwd; only a file written after the pane existed counts.
658
+ entry.sessionFile = await resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: spawnedAt - 2_000 });
659
+ subs.set(paneId, entry);
660
+ h.persistSubs();
661
+ onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
662
+ // M11: inject through the extension pipe, reserving the PTY keyboard channel for the human;
663
+ // injection and injectTs must stay together or the spawn leaves a ghost running ledger entry.
664
+ const injectTs = Date.now();
665
+ const injected = await pipeRequestTo(cwd, paneId, {
666
+ type: 'prompt',
667
+ id: `prompt-${taskId}`,
668
+ text: spec.prompt,
669
+ // The pipe name MUST be scoped by the MASTER's cwd: the server binds pipeNameFor(master
670
+ // session cwd, own paneId); the worker's cwd names a pipe nobody listens on.
671
+ from: pipeNameFor(masterCwd, env?.paneId ?? ''),
672
+ push: background,
673
+ });
674
+ if (injected.type !== 'ok') {
675
+ throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
676
+ }
677
+ h.lastMachineInjectAt.set(paneId, injectTs); // attribute working state during the observation window
678
+ const handToPoller = (requestId: string): void => {
679
+ entry.background = true;
680
+ h.lastRequestIdByPane.set(paneId, requestId);
681
+ void startPoller(paneId, cwd, injectTs, spec.description, requestId);
682
+ h.persistSubs();
683
+ h.writeHistory(entry, undefined, 'to-background');
684
+ };
685
+
686
+ if (background) {
687
+ handToPoller(`prompt-${taskId}`);
688
+ return {
689
+ content: [{ type: 'text', text: `started subagent ${paneId} (task ${taskId})` }],
690
+ details: { paneId, taskId, background: true, role: kind },
691
+ };
692
+ }
693
+
694
+ // Foreground waiting: content gate + patience threshold before backgrounding an idle pane.
695
+ const patienceDeadline = Date.now() + runtimePolicy.foregroundPatienceMs;
696
+ let text: string | null = null;
697
+ let settledKind: 'settled' | 'timeout' = 'timeout';
698
+ while (Date.now() < Math.min(patienceDeadline, spawnedAt + runtimePolicy.subagentTimeoutMs)) {
699
+ const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
700
+ const session = (state === 'idle' || state === 'done')
701
+ ? await subSessionState(paneId, cwd, injectTs)
702
+ : { text: null, pendingTool: false, activity: false };
703
+ const tick = planForegroundTick({ state, session });
704
+ if (tick.kind === 'blocked') {
705
+ const question = await readAskFlag(paneId);
706
+ handToPoller(`prompt-${taskId}`);
707
+ return {
708
+ content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
709
+ details: { paneId, taskId, background: true, blocked: true, role: kind },
710
+ };
711
+ }
712
+ if (tick.kind === 'settled') {
713
+ text = tick.text;
714
+ settledKind = 'settled';
715
+ break;
716
+ }
717
+ if (tick.kind === 'wait') {
718
+ await sleep(tick.delayMs);
719
+ continue;
720
+ }
721
+ if (tick.kind === 'collect-final') {
722
+ text = await collectFinalText(paneId, cwd, injectTs, 6);
723
+ if (text) { settledKind = 'settled'; break; }
724
+ await sleep(FOREGROUND_POLL_MS);
725
+ }
726
+ }
727
+ if (!text) {
728
+ const probe = await probeAlive(paneId, cwd);
729
+ if (isAlive(probe, Date.now())) {
730
+ handToPoller(`prompt-${taskId}`);
731
+ return {
732
+ content: [{ type: 'text', text: buildAliveNotice({ paneId, description: spec.description, scenario: 'moved-to-bg', probe }, Date.now()) }],
733
+ details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
734
+ };
735
+ }
736
+ settledKind = 'timeout';
737
+ }
738
+ const outcome =
739
+ settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
740
+ : text ? { kind: 'completed' as const, text }
741
+ : { kind: 'no-output' as const, text: '' };
742
+ entry.status = 'consumed';
743
+ entry.consumedAt = Date.now();
744
+ h.persistSubs();
745
+ h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
746
+ return {
747
+ content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
748
+ details: { paneId, taskId, background, role: kind },
749
+ };
750
+ } catch (err) {
751
+ // A mid-spawn failure must remove the ledger entry and close its pane, or a ghost entry never settles.
752
+ if (paneId) {
753
+ subs.delete(paneId);
754
+ h.persistSubs();
755
+ void client.closePane(paneId).catch(() => { /* GC covers board exceptions */ });
756
+ }
757
+ // D98: an isolate whose startup never became ready has no work to preserve.
758
+ if (isolateMeta) {
759
+ const { branch, worktreePath } = isolateMeta;
760
+ void git.runGit(masterCwd, ['worktree', 'remove', '--force', worktreePath])
761
+ .then(() => git.runGit(masterCwd, ['branch', '-D', branch]))
762
+ .catch(() => { /* best effort only */ });
763
+ }
764
+ return {
765
+ content: [{
766
+ type: 'text',
767
+ text: formatSubagentResult(
768
+ { kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
769
+ spec.description,
770
+ ),
771
+ }],
772
+ details: {},
773
+ };
774
+ } finally {
775
+ release();
776
+ if (isolateMeta) h.pendingIsolateBranches.delete(isolateMeta.branch);
777
+ }
778
+ };
156
779
  }