piher 0.0.0-stage → 0.2.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 (60) hide show
  1. package/README.md +60 -2
  2. package/package.json +58 -4
  3. package/schemas/role-manifest.schema.json +62 -0
  4. package/skills/piher-tools/SKILL.md +212 -0
  5. package/src/ansi-text.ts +143 -0
  6. package/src/bg-job-core.ts +412 -0
  7. package/src/bootstrap.ts +102 -0
  8. package/src/config-catalog-core.ts +200 -0
  9. package/src/config-command.ts +324 -0
  10. package/src/cordis-services.ts +38 -0
  11. package/src/core-client.ts +715 -0
  12. package/src/delegation-core.ts +94 -0
  13. package/src/gc-core.ts +150 -0
  14. package/src/guards.ts +6 -0
  15. package/src/hc-templates.ts +134 -0
  16. package/src/index-locks.ts +118 -0
  17. package/src/index-master.ts +92 -0
  18. package/src/index-notices.ts +79 -0
  19. package/src/index-pipe.ts +71 -0
  20. package/src/index-roles.ts +265 -0
  21. package/src/index-runtime.ts +23 -0
  22. package/src/index.ts +460 -0
  23. package/src/ledger.ts +61 -0
  24. package/src/lock-core.ts +200 -0
  25. package/src/manifest-compose.ts +103 -0
  26. package/src/options.ts +78 -0
  27. package/src/pi-surface.ts +140 -0
  28. package/src/pipe-channel.ts +291 -0
  29. package/src/platform-paths.ts +37 -0
  30. package/src/plugins/bg-job.ts +560 -0
  31. package/src/plugins/subagent.ts +1244 -0
  32. package/src/prompt-store.ts +50 -0
  33. package/src/renderers.ts +195 -0
  34. package/src/role-catalog.ts +99 -0
  35. package/src/role-loader.ts +196 -0
  36. package/src/role-manifest.ts +163 -0
  37. package/src/role-state.ts +119 -0
  38. package/src/roles/master.json +23 -0
  39. package/src/roles/worker-default.json +23 -0
  40. package/src/runtime-policy.ts +45 -0
  41. package/src/session-state.ts +19 -0
  42. package/src/session-tail.ts +276 -0
  43. package/src/settle-wake-core.ts +73 -0
  44. package/src/settlement-card.ts +96 -0
  45. package/src/storage-layout.ts +92 -0
  46. package/src/subagent-core.ts +543 -0
  47. package/src/subagent-gc.ts +312 -0
  48. package/src/subagent-poller.ts +484 -0
  49. package/src/subagent-session.ts +548 -0
  50. package/src/subagent-spawn.ts +695 -0
  51. package/src/swallow.ts +59 -0
  52. package/src/task-row.ts +257 -0
  53. package/src/task-store.ts +173 -0
  54. package/src/tool-error.ts +19 -0
  55. package/src/tool-gate.ts +99 -0
  56. package/src/tree-view.ts +58 -0
  57. package/src/vocab.ts +41 -0
  58. package/src/worker-marker.ts +65 -0
  59. package/src/worktree-io.ts +177 -0
  60. package/src/worktree.ts +280 -0
@@ -0,0 +1,695 @@
1
+ /**
2
+ * Subagent spawn domain: git I/O, app tab placement, launch argv, readiness wait, and the `spawn`
3
+ * action. Children are created by herdr-core (`terminal.run`) with the full argv, so no shell
4
+ * prompt probe or pasted launch line is involved; the app adopts the terminal as a pane of its tab.
5
+ */
6
+ import { execFile as nodeExecFile } from 'node:child_process';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { stat as statCb } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { basename, resolve as pathResolve } from 'node:path';
11
+ import { promisify } from 'node:util';
12
+ import { childEnvironment, type CoreClientLike, type CoreEnv } from './core-client.ts';
13
+ import { delegationEnv } from './delegation-core.ts';
14
+ import { saveTaskPrompt } from './prompt-store.ts';
15
+ import { bareSessionId } from './session-tail.ts';
16
+ import { mainWorktreeRoot } from './storage-layout.ts';
17
+ import { checkoutAckLine, checkoutSpaceId, ensureCheckout, planWorktree, releaseCheckout, type CheckoutIo, type CheckoutSpace, type WorktreeRecord } from './worktree.ts';
18
+ import { pipeNameCandidates, pipeNameFor, pipeRequest, pipeRequestTo } from './pipe-channel.ts';
19
+ import { composeForRole } from './manifest-compose.ts';
20
+ import type { Lifecycle } from './role-manifest.ts';
21
+ import { runtimePolicy } from './runtime-policy.ts';
22
+ import { toolError } from './tool-error.ts';
23
+ import {
24
+ Semaphore,
25
+ buildLaunchLine,
26
+ buildLaunchParts,
27
+ classifyWorktreeZone,
28
+ formatSpawnFailure,
29
+ isPathUnder,
30
+ planLaunchValidation,
31
+ planTabPlacement,
32
+ sleep,
33
+ withAdmission,
34
+ withoutPlacement,
35
+ type WorktreeZone,
36
+ } from './subagent-core.ts';
37
+ import { allocateTaskSlug, taskRef, type TaskEntry } from './task-row.ts';
38
+ import type { Poller } from './subagent-poller.ts';
39
+ import type { SessionIo } from './subagent-session.ts';
40
+
41
+ const statAsync = promisify(statCb);
42
+
43
+ /* ── git adapter ────────────────────────────────────────────────── */
44
+
45
+ export interface GitResult {
46
+ readonly stdout: string;
47
+ readonly stderr: string;
48
+ }
49
+
50
+ export interface GitError extends Error {
51
+ readonly code?: string | number;
52
+ readonly stdout?: string;
53
+ readonly stderr?: string;
54
+ }
55
+
56
+ export type GitExecFile = (
57
+ file: string,
58
+ args: readonly string[],
59
+ options: { timeout: number; encoding: 'utf8'; maxBuffer: number },
60
+ ) => Promise<{ stdout: string; stderr: string }>;
61
+
62
+ /** Output ceiling for one git call: a large checkout blows past execFile's 1MB default (ENOBUFS). */
63
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
64
+
65
+ export interface GitAdapter {
66
+ listWorktrees(cwd: string): Promise<GitResult>;
67
+ status(cwd: string): Promise<GitResult>;
68
+ /** Throws a GitError on failure/timeout; callers wanting "null on error" catch at the call site. */
69
+ run(cwd: string, args: readonly string[]): Promise<GitResult>;
70
+ }
71
+
72
+ const execFile = promisify(nodeExecFile);
73
+
74
+ export class NodeGitAdapter implements GitAdapter {
75
+ private readonly gitExecutable: string;
76
+ private readonly timeoutMs: number;
77
+ private readonly exec: GitExecFile;
78
+
79
+ constructor(
80
+ gitExecutable: string = 'git',
81
+ timeoutMs: number = runtimePolicy.gitTimeoutMs,
82
+ exec: GitExecFile = execFile as unknown as GitExecFile,
83
+ ) {
84
+ this.gitExecutable = gitExecutable;
85
+ this.timeoutMs = timeoutMs;
86
+ this.exec = exec;
87
+ }
88
+
89
+ listWorktrees(cwd: string): Promise<GitResult> {
90
+ return this.run(cwd, ['worktree', 'list', '--porcelain']);
91
+ }
92
+
93
+ status(cwd: string): Promise<GitResult> {
94
+ return this.run(cwd, ['status', '--short']);
95
+ }
96
+
97
+ async run(cwd: string, args: readonly string[]): Promise<GitResult> {
98
+ try {
99
+ const { stdout, stderr } = await this.exec(
100
+ this.gitExecutable,
101
+ ['-C', cwd, ...args],
102
+ { timeout: this.timeoutMs, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER },
103
+ );
104
+ return { stdout, stderr };
105
+ } catch (err) {
106
+ const message = err instanceof Error ? err.message : String(err);
107
+ throw Object.assign(err instanceof Error ? err : new Error(), {
108
+ message: `Git ${args[0] ?? 'command'} failed: ${message}`,
109
+ }) as GitError;
110
+ }
111
+ }
112
+ }
113
+
114
+ export const defaultGitAdapter = new NodeGitAdapter();
115
+
116
+ /* ── git I/O used by spawn and GC ── */
117
+
118
+ const WORKTREES_CACHE_MS = 5000;
119
+
120
+ export interface GitIo {
121
+ listWorktrees(cwd: string): Promise<string[]>;
122
+ }
123
+
124
+ export function createGitIo(git: GitAdapter = defaultGitAdapter): GitIo {
125
+ let worktreesCache: { at: number; list: string[] } | null = null;
126
+
127
+ async function listWorktrees(cwd: string): Promise<string[]> {
128
+ if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
129
+ let list: string[] = [];
130
+ try {
131
+ const { stdout } = await git.listWorktrees(cwd);
132
+ for (const line of String(stdout).split('\n')) {
133
+ const m = /^worktree (.+)$/.exec(line.trim());
134
+ if (m) list.push(m[1]!);
135
+ }
136
+ } catch {
137
+ list = [];
138
+ }
139
+ worktreesCache = { at: Date.now(), list };
140
+ return list;
141
+ }
142
+
143
+ return {
144
+ listWorktrees,
145
+ };
146
+ }
147
+
148
+ /* ── readiness wait (pure planners) ─────────────────────────────── */
149
+
150
+ const READY_BASE_INTERVAL_MS = 500;
151
+ const READY_MAX_INTERVAL_MS = 4000;
152
+ /** Liveness/agent sampling is far more expensive than a ping, so it runs on its own cadence. */
153
+ const READY_LIVENESS_SAMPLE_MS = 2500;
154
+ /** Enough of the pane tail to carry a stack trace head into the failure text. */
155
+ const READY_TAIL_CHARS = 1200;
156
+
157
+ interface ReadyFailure {
158
+ readonly paneId: string;
159
+ readonly reason: 'pane-gone' | 'timeout';
160
+ readonly elapsedMs: number;
161
+ readonly timeoutMs: number;
162
+ readonly lastStatus?: string | null;
163
+ readonly tail?: string | null;
164
+ }
165
+
166
+ type ReadyAttemptPlan =
167
+ | { readonly kind: 'ready' }
168
+ | { readonly kind: 'retry'; readonly delayMs: number }
169
+ | { readonly kind: 'give-up'; readonly reason: 'pane-gone' | 'timeout' };
170
+
171
+ /** Decide one readiness attempt. `alive` is tri-state on purpose: an unavailable liveness probe
172
+ * (`null`) must not be read as death, or a slow herdr socket fails spawns that are merely slow. */
173
+ export function planReadyAttempt(opts: {
174
+ elapsedMs: number;
175
+ attempt: number;
176
+ timeoutMs: number;
177
+ alive: boolean | null;
178
+ ready: boolean;
179
+ }): ReadyAttemptPlan {
180
+ if (opts.ready) return { kind: 'ready' };
181
+ if (opts.alive === false) return { kind: 'give-up', reason: 'pane-gone' };
182
+ if (opts.elapsedMs >= opts.timeoutMs) return { kind: 'give-up', reason: 'timeout' };
183
+ return { kind: 'retry', delayMs: readyBackoffMs(opts.attempt) };
184
+ }
185
+
186
+ /** Exponential backoff with a cap: 500ms, 1s, 2s, 4s, 4s, … */
187
+ export function readyBackoffMs(attempt: number, baseMs = READY_BASE_INTERVAL_MS, capMs = READY_MAX_INTERVAL_MS): number {
188
+ const n = Math.max(0, Math.floor(attempt));
189
+ return Math.min(capMs, baseMs * 2 ** n);
190
+ }
191
+
192
+ /** Failure text for the model; the three cases stay distinguishable: pane died (the tail carries the
193
+ * reason), pane alive but never opened its pipe, pane alive and just slower than the timeout. */
194
+ export function readyFailureText(failure: ReadyFailure): string {
195
+ const seconds = Math.round(failure.elapsedMs / 1000);
196
+ const head = failure.reason === 'pane-gone'
197
+ ? `subagent pane ${failure.paneId} exited before its pipe became ready (waited ${seconds}s)`
198
+ : `subagent pane ${failure.paneId} pipe not ready within ${seconds}s (limit ${Math.round(failure.timeoutMs / 1000)}s)`;
199
+ const why = failure.reason === 'pane-gone'
200
+ ? 'The child process is gone — its last output below usually carries the reason.'
201
+ : failure.lastStatus === 'working'
202
+ ? 'The pane is alive and working, so the prompt may simply be slow to boot; retrying the same call is safe.'
203
+ : 'The pane is alive but never registered its pipe — the child pi process may still be starting, or it failed to load the piher extension.';
204
+ const tail = failure.tail ? `\nlast output of ${failure.paneId}:\n${failure.tail}` : '';
205
+ return `${head}\n${why}${tail}`;
206
+ }
207
+
208
+ /* ── spawner ────────────────────────────────────────────────────── */
209
+
210
+ interface SpawnerHost {
211
+ client: CoreClientLike;
212
+ env: CoreEnv | null;
213
+ runtime: { nodePath: string; cliPath: string; extPath: string };
214
+ git: GitIo;
215
+ /** Test override. Production reads `runtimePolicy.readinessTimeoutMs`. */
216
+ readinessTimeoutMs?: number;
217
+ }
218
+
219
+ type ReadyOutcome = { ok: true } | { ok: false; message: string };
220
+
221
+ export interface Spawner {
222
+ spawnPaneInTaskTab(
223
+ placement: {
224
+ desiredTab?: string | null;
225
+ description: string;
226
+ zone?: WorktreeZone;
227
+ spaceId?: string | null;
228
+ visible: boolean;
229
+ },
230
+ cwd: string,
231
+ envOver: Record<string, string>,
232
+ argv: readonly string[],
233
+ ): Promise<{ tabId: string; paneId: string; tabName: string }>;
234
+ launchParts(resumeFile?: string | null, roleModel?: string | null, roleThinking?: string | null, approve?: boolean): string[];
235
+ approveFor(cwd: string, masterCwd: string): Promise<boolean>;
236
+ waitSubReady(cwd: string, paneId: string): Promise<ReadyOutcome>;
237
+ findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null>;
238
+ }
239
+
240
+ export function createSpawner(h: SpawnerHost): Spawner {
241
+ const readyTimeoutMs = h.readinessTimeoutMs ?? runtimePolicy.readinessTimeoutMs;
242
+
243
+ async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
244
+ if (isPathUnder(cwd, masterCwd)) return true;
245
+ const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await h.git.listWorktrees(masterCwd) });
246
+ return zone.zone === 'worktree';
247
+ }
248
+
249
+ /** Wait for the child's pipe with backoff; give up early when the pane is gone. The screen tail
250
+ * is sampled while the pane exists so a crash that leaves a shell still explains itself. */
251
+ async function waitSubReady(cwd: string, paneId: string): Promise<ReadyOutcome> {
252
+ const names = pipeNameCandidates(cwd, paneId);
253
+ const startedAt = Date.now();
254
+ let attempt = 0;
255
+ let lastProbeAt = 0;
256
+ let alive: boolean | null = null;
257
+ let lastStatus: string | null = null;
258
+ let tail: string | null = null;
259
+
260
+ while (true) {
261
+ const now = Date.now();
262
+ const elapsedMs = now - startedAt;
263
+ if (now - lastProbeAt >= READY_LIVENESS_SAMPLE_MS || attempt === 0) {
264
+ lastProbeAt = now;
265
+ const probe = await probeChildPane(paneId);
266
+ alive = probe.alive;
267
+ lastStatus = probe.status;
268
+ if (probe.tail) tail = probe.tail;
269
+ }
270
+ const pinged = await pingAnyName(names);
271
+ const plan = planReadyAttempt({ elapsedMs, attempt, timeoutMs: readyTimeoutMs, alive, ready: pinged });
272
+ if (plan.kind === 'ready') return { ok: true };
273
+ if (plan.kind === 'give-up') {
274
+ const failure: ReadyFailure = {
275
+ paneId,
276
+ reason: plan.reason,
277
+ elapsedMs: Math.max(elapsedMs, 1),
278
+ timeoutMs: readyTimeoutMs,
279
+ lastStatus,
280
+ tail,
281
+ };
282
+ return { ok: false, message: readyFailureText(failure) };
283
+ }
284
+ attempt += 1;
285
+ await sleep(plan.delayMs);
286
+ }
287
+ }
288
+
289
+ /** One ping round over every candidate pipe name (mixed-version peers register different names). */
290
+ async function pingAnyName(names: readonly string[]): Promise<boolean> {
291
+ for (const name of names) {
292
+ try {
293
+ const res = await pipeRequest(name, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
294
+ if (res.type === 'ok') return true;
295
+ } catch { /* not ready on this name */ }
296
+ }
297
+ return false;
298
+ }
299
+
300
+ /** Pane liveness + last visible output; socket failures degrade to "unknown", never "dead". */
301
+ async function probeChildPane(paneId: string): Promise<{ alive: boolean | null; status: string | null; tail: string | null }> {
302
+ let alive: boolean | null = null;
303
+ let status: string | null = null;
304
+ try {
305
+ // A terminal that core marks finished is gone; a live one may still be booting its pipe.
306
+ const terminal = await h.client.getTerminal(paneId);
307
+ alive = terminal != null && terminal.state !== 'finished';
308
+ status = terminal?.status ?? null;
309
+ } catch {
310
+ /* core unreachable: liveness stays unknown, never "dead" */
311
+ }
312
+ let tail: string | null = null;
313
+ try {
314
+ const read = await h.client.read(paneId, { lines: 40 });
315
+ if (read.text) tail = read.text.slice(-READY_TAIL_CHARS);
316
+ } catch {
317
+ /* reaped terminal or socket blip — caller keeps the last cached tail */
318
+ }
319
+ return { alive, status, tail };
320
+ }
321
+
322
+ /**
323
+ * Create the worker terminal where its labels say.
324
+ *
325
+ * A worker opens no pane at all unless asked: with no placement label the app never adopts it, so
326
+ * it lives in core alone. The app's agents view still lists it — that view walks the core's
327
+ * terminals and nests pane-less children under their parent (`mods/agents/main.js:176`), with
328
+ * `peek` to watch one on demand. A pane beside the master was noise nobody asked for.
329
+ *
330
+ * Two cases do ask for a pane: a worker in its own checkout names the checkout's `herdr.space`
331
+ * (the app's worktree button), and an explicit `tab` joins a tab of this master's space. A second
332
+ * worker on that same checkout also joins the tab the app made there, so a worker and its reviewer
333
+ * sit in one tab instead of two.
334
+ */
335
+ async function spawnPaneInTaskTab(
336
+ placement: {
337
+ desiredTab?: string | null;
338
+ description: string;
339
+ zone?: WorktreeZone;
340
+ spaceId?: string | null;
341
+ /** Only an explicit `tab` puts the worker in this master's space. */
342
+ visible: boolean;
343
+ },
344
+ cwd: string,
345
+ envOver: Record<string, string>,
346
+ argv: readonly string[],
347
+ ): Promise<{ tabId: string; paneId: string; tabName: string }> {
348
+ const plan = planTabPlacement({
349
+ desiredTab: placement.desiredTab,
350
+ description: placement.description,
351
+ zone: placement.zone,
352
+ });
353
+ const placementLabels = await h.client.placementLabels();
354
+ // A second worker on the same checkout joins the tab the app already made in its space, so a
355
+ // worker and the reviewer that reads it sit side by side instead of in two tabs of one space.
356
+ const checkoutTab = placement.spaceId ? await tabInCheckoutSpace(placement.spaceId) : '';
357
+ const labels = placement.spaceId
358
+ ? {
359
+ ...withoutPlacement(placementLabels),
360
+ 'herdr.space': placement.spaceId,
361
+ ...(checkoutTab ? { 'herdr.tab': checkoutTab } : {}),
362
+ }
363
+ : !placement.visible
364
+ ? withoutPlacement(placementLabels)
365
+ : plan.tabId ? { ...placementLabels, 'herdr.tab': plan.tabId } : placementLabels;
366
+ const terminal = await h.client.run({
367
+ argv: [...argv],
368
+ cwd,
369
+ env: childEnvironment(process.env, envOver),
370
+ labels,
371
+ noNotify: true,
372
+ });
373
+ return placement.spaceId
374
+ ? { tabId: checkoutTab, paneId: terminal.id, tabName: '' }
375
+ : !placement.visible
376
+ ? { tabId: '', paneId: terminal.id, tabName: '' }
377
+ : {
378
+ tabId: plan.tabId ?? placementLabels['herdr.tab'] ?? '',
379
+ paneId: terminal.id,
380
+ tabName: plan.tabName,
381
+ };
382
+ }
383
+
384
+ /** The tab the app already made inside a checkout's space, if a terminal is there already.
385
+ *
386
+ * A space spawn names only `herdr.space`: the app creates the space and a tab inside it, and writes
387
+ * the tab id back onto the terminal it adopted. Reading it from the sibling is what keeps the
388
+ * second spawn of a checkout in that same tab; the first one of a checkout finds nothing here and
389
+ * lets the app make the tab. A worker of ours wins over an unrelated terminal that happens to sit
390
+ * in the space, and any failure falls back to today's behaviour (a tab of its own).
391
+ */
392
+ async function tabInCheckoutSpace(spaceId: string): Promise<string> {
393
+ try {
394
+ const terminals = await h.client.listTerminals({ all: true });
395
+ const inSpace = terminals.filter((t) => t.labels['herdr.space'] === spaceId && t.labels['herdr.tab']);
396
+ const ours = inSpace.find((t) => t.labels['piher.session']);
397
+ return (ours ?? inSpace[0])?.labels['herdr.tab'] ?? '';
398
+ } catch {
399
+ return '';
400
+ }
401
+ }
402
+
403
+ /** D94: reuse a terminal that already runs the same session instead of competing on one transcript.
404
+ * Workers self-report their session file in the `piher.session` label. */
405
+ async function findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null> {
406
+ if (!sessionFile) return null;
407
+ const wanted = bareSessionId(sessionFile);
408
+ try {
409
+ const terminals = await h.client.listTerminals();
410
+ const match = terminals.find((t) => {
411
+ const reported = t.labels['piher.session'];
412
+ return reported != null && bareSessionId(reported) === wanted;
413
+ });
414
+ if (!match) return null;
415
+ return { paneId: match.id, tabId: match.labels['herdr.tab'] ?? '' };
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+
421
+ return {
422
+ spawnPaneInTaskTab,
423
+ launchParts: (resumeFile, roleModel, roleThinking, approve) => buildLaunchParts(h.runtime, { resumeFile, roleModel, roleThinking, approve: approve ?? false }),
424
+ approveFor,
425
+ waitSubReady,
426
+ findExistingPane,
427
+ };
428
+ }
429
+
430
+ /* ── spawn action ───────────────────────────────────────────────── */
431
+
432
+ interface SpawnActionHost {
433
+ client: CoreClientLike;
434
+ env: CoreEnv | null;
435
+ subSemaphore: Semaphore;
436
+ tasks: Map<string, TaskEntry>;
437
+ panes: Map<string, string>;
438
+ writeRecord(entry: TaskEntry): void;
439
+ appendView(taskId: string): void;
440
+ /** A spawn that never started leaves no record and no view id behind. */
441
+ dropTask?(taskId: string): void;
442
+ lastRequestIdByPane: Map<string, number | string>;
443
+ /** Pane id → its in-flight boot (pipe readiness, session resolution, prompt injection). */
444
+ boots: Map<string, Promise<void>>;
445
+ /** A spawn whose pane never came up: the only report a failed start can make off the turn. */
446
+ notifySpawnFailed: (content: string) => Promise<void>;
447
+ git: GitIo;
448
+ /** S71: the checkout piher may create on request. */
449
+ checkout: CheckoutIo;
450
+ /** Closes the app space a checkout worker opened; omitted means no app at all. */
451
+ checkoutSpace?: CheckoutSpace;
452
+ session: SessionIo;
453
+ spawn: Spawner;
454
+ poller: Poller;
455
+ /** Master session id for the delegation identity handed to the worker. */
456
+ getSessionId: () => string;
457
+ }
458
+
459
+ export function createSpawnAction(h: SpawnActionHost) {
460
+ const { client, env, git, tasks, panes } = h;
461
+ const { resolveSessionFile } = h.session;
462
+ const { spawnPaneInTaskTab, launchParts, waitSubReady } = h.spawn;
463
+ const { startPoller } = h.poller;
464
+
465
+ return async function executeSubagentSpawn(
466
+ params: Record<string, unknown> | undefined,
467
+ toolCtx: unknown,
468
+ ): Promise<unknown> {
469
+ const launch = planLaunchValidation(params, client.available);
470
+ if (launch.kind === 'error') return toolError(launch.text);
471
+ const { spec, cwdParam, worktreeParam, roleKind: kind, suggested, manifestRole, tab } = launch;
472
+ const masterCwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
473
+ const taskId = randomUUID();
474
+ // Validate the role before any pane exists: this early return has no cleanup path.
475
+ let roleManifestEnv: Record<string, string> = {};
476
+ let roleModel: string | null = null;
477
+ let roleThinking: string | null = null;
478
+ /** S54: one-shot lifetimes get their pane reclaimed at settlement instead of at the GC TTL. */
479
+ let roleLifecycle: Lifecycle = 'persistent';
480
+ // Delegation identity (RFC rfc-delegation-identity §2.1): the worker learns who delegated it.
481
+ let delegationEnvPatch: Record<string, string> = {};
482
+ try {
483
+ const { role, manifest } = composeForRole(manifestRole ?? 'worker-default', suggested, { loadRoleOpts: { baseDir: masterCwd } });
484
+ roleManifestEnv = {
485
+ PIHER_ROLE_MANIFEST: JSON.stringify({
486
+ role: role.role,
487
+ version: role.version,
488
+ tools: manifest.tools,
489
+ permissions: manifest.permissions,
490
+ unknownTools: manifest.unknownTools,
491
+ // Without guidelines parseRuntimeManifest yields none and the piher-role prompt section silently no-ops.
492
+ ...(role.guidelines?.length ? { guidelines: role.guidelines } : {}),
493
+ ...(role.instructions ? { instructions: role.instructions } : {}),
494
+ }),
495
+ // Role base: /piher-role resolves .piher/roles/ against the MASTER's checkout.
496
+ PIHER_ROLE_BASE: masterCwd,
497
+ };
498
+ // S9/S54: the lifetime is the role's, and the worker learns it from its own delegation record.
499
+ roleLifecycle = role.lifecycle === 'one-shot' ? 'one-shot' : 'persistent';
500
+ delegationEnvPatch = delegationEnv({
501
+ parentPaneId: env?.terminalId ?? '',
502
+ parentSessionId: bareSessionId(h.getSessionId()),
503
+ taskId,
504
+ description: spec.description,
505
+ role: role.role,
506
+ lifecycle: roleLifecycle,
507
+ });
508
+ if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
509
+ if (typeof role.thinking === 'string' && role.thinking.trim()) roleThinking = role.thinking.trim();
510
+ } catch (err) {
511
+ const msg = err instanceof Error ? err.message : String(err);
512
+ return toolError(`Error: role "${manifestRole}" manifest invalid — ${msg}`);
513
+ }
514
+ let cwd = masterCwd;
515
+ let worktreeRecord: WorktreeRecord | null = null;
516
+ let worktreeOutcome: 'created' | 'reused' | null = null;
517
+ /** Only what this call created may be undone by this call (S71). */
518
+ let createdCheckout = false;
519
+ /** A spawn that fails after the checkout was made leaves nothing behind for the human. */
520
+ const discardCheckout = (): void => {
521
+ const record = worktreeRecord;
522
+ if (!createdCheckout || !record) return;
523
+ void (async (): Promise<void> => {
524
+ await releaseCheckout(h.checkout, record, h.checkoutSpace).catch(() => null);
525
+ })();
526
+ };
527
+ if (worktreeParam) {
528
+ const repoRoot = mainWorktreeRoot(masterCwd);
529
+ if (!repoRoot) {
530
+ return toolError(`Error: \`worktree\` needs a git repository: ${masterCwd} is not inside one, so there is no branch to create the checkout from.`);
531
+ }
532
+ const planned = planWorktree({ name: worktreeParam, repoRoot, home: homedir() });
533
+ if (planned.kind === 'error') return toolError(planned.text);
534
+ const ensured = await ensureCheckout(h.checkout, { plan: planned.plan, cwd: masterCwd });
535
+ if (ensured.kind === 'error') return toolError(ensured.text);
536
+ createdCheckout = ensured.kind === 'created';
537
+ worktreeOutcome = ensured.kind;
538
+ cwd = planned.plan.path;
539
+ worktreeRecord = { path: planned.plan.path, branch: planned.plan.branch };
540
+ // S72: the task record names the checkout, so `resume` and `close` find it after a restart.
541
+ // The app opens it by listing worktrees, so there is nothing else to record.
542
+ } else if (cwdParam) {
543
+ cwd = pathResolve(masterCwd, cwdParam);
544
+ try {
545
+ if (!(await statAsync(cwd)).isDirectory()) throw new Error('not a directory');
546
+ } catch {
547
+ return toolError(`Error: \`cwd\` is not an existing directory: ${cwdParam}`);
548
+ }
549
+ }
550
+ // D86 R1: group by the git worktree containing cwd (main checkout → main tab; other → its tab).
551
+ // A checkout piher just made is not in the (cached) worktree list yet, so it is its own case.
552
+ const zone: WorktreeZone = worktreeRecord
553
+ ? { zone: 'worktree', tabName: basename(worktreeRecord.path) }
554
+ : classifyWorktreeZone({ cwd, masterCwd, worktrees: await git.listWorktrees(masterCwd) });
555
+ // A checkout piher just made gets its own app space, named by the label the app adopts.
556
+ const spaceId = worktreeRecord ? checkoutSpaceId(worktreeRecord.path) : null;
557
+ // D86 trust: pass -a only for the master's checkout/worktrees; external dirs stay behind pi's Trust dialog.
558
+ const approve = worktreeRecord != null || isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
559
+
560
+ let paneId = '';
561
+ try {
562
+ const spawnedAt = Date.now();
563
+ const argv = launchParts(null, roleModel, roleThinking, approve);
564
+ // The slot covers pane creation only: the readiness wait and the prompt injection run off this
565
+ // call's turn (S50), so they must never hold a slot a spawn would have to queue behind.
566
+ const spawned = await withAdmission(h.subSemaphore, () => spawnPaneInTaskTab(
567
+ {
568
+ desiredTab: tab,
569
+ description: spec.description,
570
+ zone,
571
+ spaceId,
572
+ // An explicit `tab` is the only reason to give a worker a pane in this master's space.
573
+ visible: tab != null && String(tab).trim() !== '',
574
+ },
575
+ cwd,
576
+ // Status comes ONLY from pi's native OSC 7501 reporter; piher never writes it.
577
+ { PIHER_SUBAGENT: '1', ...roleManifestEnv, ...delegationEnvPatch },
578
+ argv,
579
+ ));
580
+ paneId = spawned.paneId;
581
+ // Allocate once per spawn; revives keep the row's slug (stability beats beauty).
582
+ const slug = allocateTaskSlug(
583
+ spec.description,
584
+ taskId,
585
+ new Set([...tasks.values()].map((s) => s.gen.slug ?? '').filter(Boolean)),
586
+ );
587
+ const entry: TaskEntry = {
588
+ taskId,
589
+ kind,
590
+ lifecycle: roleLifecycle,
591
+ cwd,
592
+ description: spec.description,
593
+ ...(worktreeRecord ? { worktree: worktreeRecord } : {}),
594
+ createdAt: Date.now(),
595
+ closedAt: null,
596
+ gen: {
597
+ paneId,
598
+ slug,
599
+ tabId: spawned.tabId,
600
+ tabName: spawned.tabName,
601
+ sessionFile: null,
602
+ launchCommand: [buildLaunchLine(argv)],
603
+ startedAt: Date.now(),
604
+ consumedAt: null,
605
+ reportDelivered: null,
606
+ },
607
+ };
608
+ // A spawn's acknowledgement is the whole answer: what follows happens off the caller's turn.
609
+ // Spawn writes the record and the view, then answers (domain: spawn reads nothing).
610
+ tasks.set(taskId, entry);
611
+ panes.set(paneId, taskId);
612
+ h.writeRecord(entry);
613
+ h.appendView(taskId);
614
+ // Keep the brief: an upstream failure is nobody's fault in the task, and `retry` re-sends this
615
+ // text instead of making the master spend tokens writing it again.
616
+ saveTaskPrompt(taskId, spec.prompt);
617
+ const injectId = `prompt-${taskId}`;
618
+ const boot = (async (): Promise<void> => {
619
+ const ready = await waitSubReady(cwd, paneId);
620
+ if (!ready.ok) throw new Error(ready.message);
621
+ // The `piher.session` label lags right after spawn, so the mtime fallback would otherwise adopt
622
+ // the newest PRE-EXISTING session in cwd; only a file written after the terminal existed counts.
623
+ entry.gen.sessionFile = await resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: spawnedAt - 2_000 });
624
+ h.writeRecord(entry);
625
+ // M11: inject through the extension pipe, reserving the PTY keyboard channel for the human;
626
+ // injection and injectTs must stay together or the spawn leaves a ghost running row.
627
+ const injectTs = Date.now();
628
+ const injected = await pipeRequestTo(cwd, paneId, {
629
+ type: 'prompt',
630
+ id: injectId,
631
+ text: spec.prompt,
632
+ // The pipe name MUST be scoped by the MASTER's cwd: the server binds pipeNameFor(master
633
+ // session cwd, own terminal); the worker's cwd names a pipe nobody listens on.
634
+ from: pipeNameFor(masterCwd, env?.terminalId ?? ''),
635
+ });
636
+ if (injected.type !== 'ok') {
637
+ throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
638
+ }
639
+ h.lastRequestIdByPane.set(paneId, injectId);
640
+ void startPoller(paneId, cwd, injectTs, spec.description, injectId);
641
+ h.writeRecord(entry);
642
+ })();
643
+ h.boots.set(paneId, boot);
644
+ void boot.catch(async (err: unknown) => {
645
+ // A worker that never came up leaves nothing behind, and a failed start must not look like a
646
+ // task that is quietly still running: retire the pane, drop the row and its record, say so.
647
+ h.dropTask?.(taskId);
648
+ tasks.delete(taskId);
649
+ panes.delete(paneId);
650
+ discardCheckout();
651
+ try { await client.kill(paneId); } catch { /* already gone */ }
652
+ try { await client.remove(paneId); } catch { /* core reaps finished terminals */ }
653
+ try {
654
+ await h.notifySpawnFailed(
655
+ `Subagent "${spec.description}" (pane ${paneId}, task ${taskId}) failed to start: ${(err as Error)?.message ?? String(err)} Nothing is running there; spawn it again if the task still matters.`,
656
+ );
657
+ } catch { /* a missed notice must not break the session */ }
658
+ }).finally(() => {
659
+ if (h.boots.get(paneId) === boot) h.boots.delete(paneId);
660
+ });
661
+ return {
662
+ content: [{
663
+ type: 'text',
664
+ text: `started subagent ${taskRef(entry.gen)} (task ${taskId}, ${roleLifecycle === 'one-shot' ? 'one-shot — reports once and exits' : 'persistent — stays until you close it'})${worktreeRecord && worktreeOutcome ? ` ${checkoutAckLine(worktreeRecord, worktreeOutcome)}` : ''}. Your prompt is already its first message — do not resend it. Its result arrives as a settlement notice at your next turn boundary — do not sleep or poll for it.`,
665
+ }],
666
+ details: {
667
+ paneId,
668
+ taskId,
669
+ role: kind,
670
+ ...(slug ? { slug } : {}),
671
+ ...(worktreeRecord ? { worktree: worktreeRecord } : {}),
672
+ },
673
+ };
674
+ } catch (err) {
675
+ // A mid-spawn failure must remove the task and retire its terminal, or a ghost entry never settles.
676
+ if (paneId) {
677
+ h.dropTask?.(taskId);
678
+ tasks.delete(taskId);
679
+ panes.delete(paneId);
680
+ discardCheckout();
681
+ void (async () => {
682
+ try { await client.kill(paneId); } catch { /* already gone */ }
683
+ try { await client.remove(paneId); } catch { /* core reaps finished terminals */ }
684
+ })();
685
+ }
686
+ return {
687
+ content: [{
688
+ type: 'text',
689
+ text: formatSpawnFailure(spec.description, String((err as Error)?.message ?? err)),
690
+ }],
691
+ details: {},
692
+ };
693
+ }
694
+ };
695
+ }