pi-crew 0.10.1 → 0.10.3

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 (127) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/executor.md +1 -1
  5. package/agents/test-engineer.md +1 -1
  6. package/dist/index.mjs +2555 -932
  7. package/package.json +2 -1
  8. package/schema.json +503 -94
  9. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  10. package/skills/real-test-pi-crew/SKILL.md +278 -79
  11. package/src/config/config-merge.ts +11 -1
  12. package/src/config/config-validation.ts +47 -2
  13. package/src/config/config.ts +28 -6
  14. package/src/config/defaults.ts +43 -11
  15. package/src/config/env-vars.ts +27 -2
  16. package/src/config/role-tools.ts +4 -2
  17. package/src/config/types.ts +55 -1
  18. package/src/extension/crew-cleanup.ts +13 -0
  19. package/src/extension/crew-vibes/footer.ts +19 -0
  20. package/src/extension/crew-vibes/index.ts +11 -1
  21. package/src/extension/register.ts +8 -0
  22. package/src/extension/registration/command-registration.ts +1 -0
  23. package/src/extension/registration/commands/run.ts +15 -1
  24. package/src/extension/registration/commands/shared.ts +8 -0
  25. package/src/extension/registration/foreground-run-controller.ts +10 -2
  26. package/src/extension/registration/lifecycle-handlers.ts +92 -11
  27. package/src/extension/registration/runtime-cleanup.ts +23 -5
  28. package/src/extension/registration/team-tool.ts +58 -6
  29. package/src/extension/registration/ui.ts +5 -4
  30. package/src/extension/team-tool/doctor.ts +364 -7
  31. package/src/extension/team-tool/handle-settings.ts +19 -0
  32. package/src/extension/team-tool/inspect.ts +10 -2
  33. package/src/extension/team-tool/run-deadline.ts +20 -3
  34. package/src/extension/team-tool/run.ts +26 -2
  35. package/src/extension/team-tool/status.ts +7 -0
  36. package/src/extension/team-tool.ts +35 -2
  37. package/src/hooks/registry.ts +59 -56
  38. package/src/prompt/inbox-poll.ts +90 -0
  39. package/src/prompt/message-tool.ts +166 -0
  40. package/src/prompt/prompt-runtime.ts +201 -18
  41. package/src/prompt/surface-worker.ts +720 -0
  42. package/src/prompt/worker-events-channel.ts +49 -3
  43. package/src/runtime/async-runner.ts +29 -1
  44. package/src/runtime/background-runner.ts +13 -7
  45. package/src/runtime/broker/broker-issuer.ts +27 -2
  46. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  47. package/src/runtime/broker/crew-broker.ts +261 -41
  48. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  49. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  50. package/src/runtime/child-pi/child-pi-streams.ts +30 -2
  51. package/src/runtime/child-pi/child-pi.ts +353 -5
  52. package/src/runtime/crew-agent-records.ts +13 -1
  53. package/src/runtime/detached-run-results.ts +90 -0
  54. package/src/runtime/dispatch-batch.ts +12 -1
  55. package/src/runtime/event-log-tail-source.ts +374 -0
  56. package/src/runtime/finalize-run.ts +4 -0
  57. package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
  58. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
  59. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  60. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  61. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  62. package/src/runtime/manifest-cache.ts +128 -17
  63. package/src/runtime/merge-gate.ts +25 -9
  64. package/src/runtime/model/model-fallback.ts +7 -3
  65. package/src/runtime/model/pi-args.ts +54 -65
  66. package/src/runtime/output/sidechain-output.ts +61 -6
  67. package/src/runtime/process/proc-stat.ts +46 -0
  68. package/src/runtime/process/zombie-scanner.ts +32 -19
  69. package/src/runtime/process-status.ts +16 -1
  70. package/src/runtime/recovery/crash-recovery.ts +16 -0
  71. package/src/runtime/run-tracker.ts +77 -10
  72. package/src/runtime/spawn-policy.ts +27 -41
  73. package/src/runtime/surface/degrade.ts +776 -0
  74. package/src/runtime/surface/herdr-provider.ts +546 -0
  75. package/src/runtime/surface/launch-script.ts +172 -0
  76. package/src/runtime/surface/resolve-surface.ts +274 -0
  77. package/src/runtime/surface/surface-provider.ts +129 -0
  78. package/src/runtime/surface/surface-spawn.ts +475 -0
  79. package/src/runtime/surface/tmux-provider.ts +400 -0
  80. package/src/runtime/task-runner/child-executor.ts +47 -0
  81. package/src/runtime/task-runner/post-execution.ts +57 -2
  82. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  83. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  84. package/src/runtime/task-runner/state-helpers.ts +54 -30
  85. package/src/runtime/task-runner.ts +4 -2
  86. package/src/runtime/team-runner.ts +104 -2
  87. package/src/schema/config-schema.ts +25 -1
  88. package/src/state/atomic-write.ts +219 -40
  89. package/src/state/coordination/locks.ts +7 -5
  90. package/src/state/coordination/mailbox.ts +56 -10
  91. package/src/state/event-log/cursor.ts +413 -23
  92. package/src/state/event-log/event-log.ts +120 -113
  93. package/src/state/event-log/sequence-cache.ts +21 -3
  94. package/src/state/stores/plan-store.ts +1 -1
  95. package/src/state/stores/state-store.ts +171 -9
  96. package/src/state/types.ts +53 -0
  97. package/src/ui/dock-footer.ts +49 -0
  98. package/src/ui/inline-panel/agent-pane.ts +378 -0
  99. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  100. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  101. package/src/ui/inline-panel/crew-editor.ts +192 -0
  102. package/src/ui/inline-panel/index.ts +290 -0
  103. package/src/ui/inline-panel/panel-rows.ts +37 -0
  104. package/src/ui/inline-panel/panel-selection.ts +157 -0
  105. package/src/ui/inline-panel/panel-store.ts +111 -0
  106. package/src/ui/inline-panel/view-session-store.ts +36 -0
  107. package/src/ui/pi-ui-compat.ts +9 -0
  108. package/src/ui/render-diff.ts +16 -8
  109. package/src/ui/run-dashboard.ts +87 -42
  110. package/src/ui/run-event-bus.ts +10 -1
  111. package/src/ui/run-snapshot-cache.ts +83 -35
  112. package/src/ui/transcript-cache.ts +101 -13
  113. package/src/ui/transcript-viewer.ts +92 -24
  114. package/src/ui/widget/index.ts +203 -25
  115. package/src/ui/widget/task-list.ts +198 -0
  116. package/src/ui/widget/widget-formatters.ts +240 -4
  117. package/src/ui/widget/widget-renderer.ts +234 -37
  118. package/src/ui/widget/widget-types.ts +11 -0
  119. package/src/utils/child-process-shield.ts +106 -0
  120. package/src/utils/redaction.ts +7 -0
  121. package/src/utils/safe-abort.ts +45 -0
  122. package/src/utils/visual.ts +43 -0
  123. package/src/workflows/discover-workflows.ts +1 -0
  124. package/src/workflows/workflow-config.ts +7 -0
  125. package/src/worktree/worktree-manager.ts +65 -4
  126. package/workflows/default.workflow.md +36 -26
  127. package/workflows/strict-fast-fix.workflow.md +26 -0
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { logInternalError } from "../../utils/internal-error.ts";
3
4
  import { redactSecrets } from "../../utils/redaction.ts";
4
5
  import { isSafePathId } from "../../utils/safe-paths.ts";
5
6
 
@@ -12,15 +13,69 @@ export interface SidechainEntry {
12
13
  cwd: string;
13
14
  }
14
15
 
16
+ // ── Batched JSONL writer (Task 26, 2026-08-24) ──────────────────────────
17
+ // PERF (2026-08-24): the old writeSidechainEntry did one mkdirSync +
18
+ // appendFileSync + full redaction clone PER STREAMING EVENT (message_update
19
+ // fires per chunk). Batch per path on a 50ms unref'd timer like
20
+ // child-pi-transcript; redaction + serialization still run per event at
21
+ // queue time, and the flush does ONE mkdir + append per path per window.
22
+ interface PendingJsonlBatch {
23
+ lines: string[];
24
+ timer: NodeJS.Timeout;
25
+ }
26
+ const pendingJsonlBatches = new Map<string, PendingJsonlBatch>();
27
+ const JSONL_FLUSH_MS = 50;
28
+
29
+ function queueJsonlLine(filePath: string, line: string): void {
30
+ const pending = pendingJsonlBatches.get(filePath);
31
+ if (pending) {
32
+ pending.lines.push(line);
33
+ return;
34
+ }
35
+ const timer = setTimeout(() => flushJsonlBatch(filePath), JSONL_FLUSH_MS);
36
+ timer.unref();
37
+ pendingJsonlBatches.set(filePath, { lines: [line], timer });
38
+ }
39
+
40
+ function flushJsonlBatch(filePath: string): void {
41
+ const pending = pendingJsonlBatches.get(filePath);
42
+ if (!pending) return;
43
+ pendingJsonlBatches.delete(filePath);
44
+ clearTimeout(pending.timer);
45
+ try {
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ fs.appendFileSync(filePath, pending.lines.join(""), "utf-8");
48
+ } catch (error) {
49
+ logInternalError("sidechain-output.flush", error as Error, `path=${filePath}`);
50
+ }
51
+ }
52
+
15
53
  export function writeSidechainEntry(filePath: string, entry: Omit<SidechainEntry, "isSidechain" | "timestamp">): void {
16
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
- fs.appendFileSync(
18
- filePath,
19
- `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`,
20
- "utf-8",
21
- );
54
+ queueJsonlLine(filePath, `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`);
22
55
  }
23
56
 
57
+ /**
58
+ * Append one pre-serialized JSONL line through the shared batched writer.
59
+ * Task 26: the live-session transcript append routes through the same
60
+ * per-path 50ms batching as sidechain output. child-pi-transcript's batched
61
+ * writer was NOT reused — it takes a ChildPiRunInput and enforces
62
+ * artifactsRoot containment + a second redaction pass (redactJsonLine over
63
+ * the already-redacted line), so it is not generic over plain paths.
64
+ */
65
+ export function appendBatchedJsonlLine(filePath: string, line: string): void {
66
+ queueJsonlLine(filePath, line);
67
+ }
68
+
69
+ /** @internal — flush on session teardown / process exit so files are complete. */
70
+ export function flushPendingSidechainWrites(): void {
71
+ for (const filePath of [...pendingJsonlBatches.keys()]) flushJsonlBatch(filePath);
72
+ }
73
+
74
+ // The batch timers are unref'd, so a naturally-draining event loop can exit
75
+ // with unflushed telemetry. Same exit-hook pattern as atomic-write.ts and
76
+ // crew-agent-records.ts (sync-only work, safe inside "exit").
77
+ process.on("exit", () => flushPendingSidechainWrites());
78
+
24
79
  export function sidechainOutputPath(stateRoot: string, taskId: string): string {
25
80
  if (!isSafePathId(taskId)) throw new Error(`Invalid taskId: ${taskId}`);
26
81
  return path.join(stateRoot, "agents", taskId, "sidechain.output.jsonl");
@@ -0,0 +1,46 @@
1
+ /**
2
+ * proc-stat.ts — THE single `/proc/<pid>/stat` field parser.
3
+ *
4
+ * Three consumers previously hand-rolled the same paren-aware split (the
5
+ * surface spawn's parent starttime capture, the worker-side parent-guard, and
6
+ * the zombie scanner's ppid/starttime read). A divergence between them is not
7
+ * cosmetic: the spawn writes `PI_CREW_PARENT_START_TIME` from field 22, and
8
+ * the worker kills itself when that value stops matching — so both sides MUST
9
+ * index identically. comm (field 2) may contain spaces AND ')' characters,
10
+ * hence everything up to the LAST ")" is skipped, never a naive whitespace
11
+ * split.
12
+ *
13
+ * Layout after the comm field: rest[0] = state, rest[1] = ppid, …
14
+ * rest[STARTTIME_INDEX] = starttime (field 22, clock ticks since boot).
15
+ */
16
+
17
+ /** Field 22 (starttime) minus the two consumed by the comm cut = index 19. */
18
+ export const PROC_STAT_STARTTIME_INDEX = 19;
19
+ /** Field 3 (state) → first entry after the comm cut. */
20
+ export const PROC_STAT_STATE_INDEX = 0;
21
+ /** Field 4 (ppid) → second entry after the comm cut. */
22
+ export const PROC_STAT_PPID_INDEX = 1;
23
+
24
+ /**
25
+ * Fields of a `/proc/<pid>/stat` line AFTER the comm column (paren-aware).
26
+ * Returns undefined when the buffer has no closing paren at all.
27
+ */
28
+ export function fieldsAfterComm(stat: string): string[] | undefined {
29
+ const lastParen = stat.lastIndexOf(")");
30
+ if (lastParen === -1) return undefined;
31
+ return stat
32
+ .slice(lastParen + 1)
33
+ .trim()
34
+ .split(/\s+/);
35
+ }
36
+
37
+ /**
38
+ * Raw starttime ticks (field 22) as a string, for byte-exact comparison with
39
+ * the value the spawner recorded. undefined when absent/unparseable.
40
+ */
41
+ export function procStartTimeTicks(stat: string): string | undefined {
42
+ const fields = fieldsAfterComm(stat);
43
+ if (!fields) return undefined;
44
+ const raw = fields[PROC_STAT_STARTTIME_INDEX];
45
+ return raw && Number.isFinite(Number(raw)) ? raw : undefined;
46
+ }
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import * as fs from "node:fs";
26
+ import { fieldsAfterComm, PROC_STAT_PPID_INDEX, PROC_STAT_STARTTIME_INDEX } from "./proc-stat.ts";
26
27
 
27
28
  export interface ZombieSubagent {
28
29
  pid: number;
@@ -32,6 +33,15 @@ export interface ZombieSubagent {
32
33
  /** Whether the recorded crew parent PID is still alive. */
33
34
  parentAlive: boolean;
34
35
  role: string | undefined;
36
+ /**
37
+ * Mux kind from PI_CREW_SURFACE ("tmux"|"herdr") when the worker booted in
38
+ * a surface pane (T12). Surface workers have NO heartbeat (T9 handoff) —
39
+ * env markers are the only identity signal, so the scanner never gates a
40
+ * surface entry on liveness telemetry.
41
+ */
42
+ surface?: "tmux" | "herdr";
43
+ /** Pane id from PI_CREW_SURFACE_PANE — doctor closes orphan panes by it. */
44
+ surfacePaneId?: string;
35
45
  rssKb: number;
36
46
  elapsedSec: number | undefined;
37
47
  cmd: string;
@@ -66,17 +76,12 @@ function readProcStat(pid: number): { ppid: number; elapsedSec: number | undefin
66
76
  try {
67
77
  const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
68
78
  // stat format: pid (comm) state ppid ... starttime ...
69
- // comm may contain spaces/parens, so parse from the LAST ')' backwards.
70
- const closeParen = stat.lastIndexOf(")");
71
- if (closeParen < 0) return undefined;
72
- const rest = stat
73
- .slice(closeParen + 2)
74
- .trim()
75
- .split(/\s+/);
76
- // rest[0] = state, rest[1] = ppid
77
- const ppid = Number.parseInt(rest[1] ?? "", 10);
78
- // starttime (clock ticks since boot) is field 22 in the full stat → index 19 in `rest`
79
- const starttimeTicksRaw = Number.parseInt(rest[19] ?? "", 10);
79
+ // Shared paren-aware cut (proc-stat.ts) — MUST index identically with
80
+ // surface-spawn's PI_CREW_PARENT_START_TIME capture.
81
+ const rest = fieldsAfterComm(stat);
82
+ if (!rest) return undefined;
83
+ const ppid = Number.parseInt(rest[PROC_STAT_PPID_INDEX] ?? "", 10);
84
+ const starttimeTicksRaw = Number.parseInt(rest[PROC_STAT_STARTTIME_INDEX] ?? "", 10);
80
85
  const starttimeTicks = Number.isFinite(starttimeTicksRaw) ? starttimeTicksRaw : undefined;
81
86
  const elapsedSec = computeElapsedSec(starttimeTicks);
82
87
  return { ppid: Number.isFinite(ppid) ? ppid : 0, elapsedSec };
@@ -184,12 +189,17 @@ export function scanZombieSubagents(): ZombieScanResult {
184
189
 
185
190
  const crewParentPid = Number.parseInt(environ.PI_CREW_PARENT_PID ?? "", 10);
186
191
  const stat = readProcStat(pid);
192
+ // Surface identity (T12): strict allowlist — an unrelated PI_CREW_SURFACE
193
+ // value must not half-populate the fields doctor relies on for pane close.
194
+ const surface = environ.PI_CREW_SURFACE === "tmux" || environ.PI_CREW_SURFACE === "herdr" ? environ.PI_CREW_SURFACE : undefined;
187
195
  const entry: ZombieSubagent = {
188
196
  pid,
189
197
  ppid: stat?.ppid ?? 0,
190
198
  crewParentPid: Number.isFinite(crewParentPid) ? crewParentPid : 0,
191
199
  parentAlive: Number.isFinite(crewParentPid) && isPidAlive(crewParentPid),
192
200
  role: environ.PI_CREW_ROLE,
201
+ surface,
202
+ surfacePaneId: environ.PI_CREW_SURFACE_PANE || undefined,
193
203
  rssKb: readProcRssKb(pid),
194
204
  elapsedSec: stat?.elapsedSec,
195
205
  cmd: readProcCmdline(pid),
@@ -256,11 +266,9 @@ export function formatZombieReport(scan: ZombieScanResult): string {
256
266
  lines.push(`### Zombies — parent dead (${scan.zombies.length})`);
257
267
  lines.push("These sub-agents are orphaned. Safe to kill after review:");
258
268
  lines.push("");
259
- lines.push(" PID PARENT RSS ROLE CMD");
269
+ lines.push(" PID PARENT RSS ROLE SURFACE CMD");
260
270
  for (const z of scan.zombies) {
261
- lines.push(
262
- ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${z.cmd.slice(0, 60)}`,
263
- );
271
+ lines.push(formatZombieRow(z));
264
272
  }
265
273
  lines.push("");
266
274
  }
@@ -269,11 +277,9 @@ export function formatZombieReport(scan: ZombieScanResult): string {
269
277
  lines.push(`### Live — parent still running (${scan.live.length})`);
270
278
  lines.push("NOT zombies. Do not kill (parent PID is alive and may still reap them).");
271
279
  lines.push("");
272
- lines.push(" PID PARENT RSS ROLE CMD");
280
+ lines.push(" PID PARENT RSS ROLE SURFACE CMD");
273
281
  for (const l of scan.live) {
274
- lines.push(
275
- ` ${String(l.pid).padEnd(9)}${String(l.crewParentPid).padEnd(8)}${formatRss(l.rssKb).padEnd(10)}${(l.role ?? "?").padEnd(14)}${l.cmd.slice(0, 60)}`,
276
- );
282
+ lines.push(formatZombieRow(l));
277
283
  }
278
284
  lines.push("");
279
285
  }
@@ -288,6 +294,13 @@ export function formatZombieReport(scan: ZombieScanResult): string {
288
294
  return lines.join("\n");
289
295
  }
290
296
 
297
+ /** One table row — shared by the zombie + live sections so columns stay aligned. */
298
+ function formatZombieRow(z: ZombieSubagent): string {
299
+ // SURFACE = "kind:paneId" (T12) — the pane doctor closes when orphaned.
300
+ const surface = z.surface ? `${z.surface}:${z.surfacePaneId ?? "?"}` : "-";
301
+ return ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${surface.padEnd(16)}${z.cmd.slice(0, 44)}`;
302
+ }
303
+
291
304
  function formatRss(kb: number): string {
292
305
  if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}G`;
293
306
  if (kb >= 1024) return `${(kb / 1024).toFixed(0)}M`;
@@ -25,6 +25,12 @@ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
25
25
  /** Maximum age (ms) for an active run before it's considered stale.
26
26
  * After this time, PID-only liveness is unreliable due to PID recycling. */
27
27
  const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
28
+ /** Window (ms) during which an active run whose agents are all
29
+ * completed/queued-without-evidence still counts as display-active: the
30
+ * runner just wrote the manifest (task transition) and the next worker has
31
+ * not recorded progress yet. Without this the dock flickers off between
32
+ * phases of a healthy run. */
33
+ const SPAWN_GAP_VISIBILITY_MS = 60 * 1000;
28
34
 
29
35
  export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
30
36
  if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
@@ -150,5 +156,14 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
150
156
  // runs, or from cross-cwd registry history; showing them causes noisy 0/0 rows and
151
157
  // needless spinner redraws. The full dashboard can still list historical runs.
152
158
  if (agents.length === 0) return false;
153
- return agents.some(hasDurableActiveAgentEvidence);
159
+ if (agents.some(hasDurableActiveAgentEvidence)) return true;
160
+ // Spawn gap between phases: an active run whose manifest was JUST written
161
+ // and whose next agents are queued/waiting without evidence yet stays
162
+ // visible — the runner is alive and about to spawn them. Stale manifests
163
+ // (zombies) fall through and stay hidden.
164
+ if (agents.some((agent) => agent.status === "queued" || agent.status === "waiting")) {
165
+ const updatedAt = new Date(run.updatedAt).getTime();
166
+ if (Number.isFinite(updatedAt) && now - updatedAt < SPAWN_GAP_VISIBILITY_MS) return true;
167
+ }
168
+ return false;
154
169
  }
@@ -283,6 +283,22 @@ export function cancelOrphanedRuns(
283
283
  continue;
284
284
  }
285
285
 
286
+ // Foreground (in-process) runs have NO async worker PID, so the liveness
287
+ // check above cannot apply. They execute inside THIS pi process — a
288
+ // recently-updated manifest conclusively proves the run is alive; only a
289
+ // run that has gone quiet for the stale threshold can be an orphan (a
290
+ // crashed process stops updating). Without this, a YOUNG foreground run
291
+ // (first worker heartbeat not yet written) was orphan-cancelled by the
292
+ // session that opened its agent view seconds after the run started —
293
+ // killing the very run the user was looking at.
294
+ if (!manifest.async) {
295
+ const updatedMs = manifest.updatedAt ? new Date(manifest.updatedAt).getTime() : Number.NaN;
296
+ if (Number.isFinite(updatedMs) && now - updatedMs <= staleThresholdMs) {
297
+ skipped.push(manifest.runId);
298
+ continue;
299
+ }
300
+ }
301
+
286
302
  // Check for recent heartbeat activity
287
303
  const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
288
304
  if (!loaded) continue;
@@ -5,21 +5,38 @@ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
5
  import { projectCrewRoot } from "../utils/paths.ts";
6
6
  import { isFinishedRunStatus } from "./process-status.ts";
7
7
 
8
+ export interface RunWaitResult {
9
+ manifest: TeamRunManifest;
10
+ tasks: TeamTaskState[];
11
+ /** True when the waiter was released early by `detachRunPromise` while the
12
+ * run itself keeps executing (see that function). */
13
+ detached?: boolean;
14
+ }
15
+
8
16
  export interface ActiveRunPromise {
9
- promise: Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }>;
10
- resolve: (value: { manifest: TeamRunManifest; tasks: TeamTaskState[] }) => void;
17
+ promise: Promise<RunWaitResult>;
18
+ resolve: (value: RunWaitResult) => void;
11
19
  reject: (reason: unknown) => void;
12
20
  }
13
21
 
14
22
  const activeRunPromises = new Map<string, ActiveRunPromise>();
15
23
 
24
+ /**
25
+ * Runs whose waiter should be released on its next opportunity.
26
+ *
27
+ * A detach request can arrive BEFORE `executeTeamRun` registers its foreground
28
+ * promise (the tool starts the run and calls `waitForRun` immediately, so a
29
+ * slow-starting run puts the waiter on the polling path with no promise to
30
+ * resolve). The flag makes the request path-independent: the polling loop
31
+ * consumes it just like `detachRunPromise` resolves a registered promise.
32
+ */
33
+ const detachRequests = new Set<string>();
34
+
16
35
  export function registerRunPromise(runId: string): ActiveRunPromise {
17
- let resolve!: (value: { manifest: TeamRunManifest; tasks: TeamTaskState[] }) => void;
36
+ detachRequests.delete(runId);
37
+ let resolve!: (value: RunWaitResult) => void;
18
38
  let reject!: (reason: unknown) => void;
19
- const promise = new Promise<{
20
- manifest: TeamRunManifest;
21
- tasks: TeamTaskState[];
22
- }>((res, rej) => {
39
+ const promise = new Promise<RunWaitResult>((res, rej) => {
23
40
  resolve = res;
24
41
  reject = rej;
25
42
  });
@@ -28,7 +45,41 @@ export function registerRunPromise(runId: string): ActiveRunPromise {
28
45
  return entry;
29
46
  }
30
47
 
31
- export function resolveRunPromise(runId: string, result: { manifest: TeamRunManifest; tasks: TeamTaskState[] }): void {
48
+ /**
49
+ * Release a foreground waiter WITHOUT stopping the run.
50
+ *
51
+ * A foreground `team run` keeps the parent pi turn streaming for the whole run,
52
+ * and pi's `switchSession` tears the current session down via
53
+ * `session.abort()` → `waitForIdle()`. Opening an agent view therefore hung
54
+ * silently until the run finished. Detaching resolves the tool's `waitForRun`
55
+ * with the run's current (still-running) state so the tool call returns, the
56
+ * parent turn can settle, and the session switch lands — `executeTeamRun`
57
+ * itself keeps going in this process and the async notifier reports completion.
58
+ *
59
+ * Returns false only when the run's state cannot be read at all. When no
60
+ * foreground promise is registered yet (or the waiter is on the polling path),
61
+ * the request is recorded and consumed by `waitForRun` on its next poll.
62
+ */
63
+ export function detachRunPromise(runId: string, cwd: string): boolean {
64
+ const loaded = loadRunManifestById(cwd, runId);
65
+ if (!loaded) return false;
66
+ const entry = activeRunPromises.get(runId);
67
+ if (entry) {
68
+ activeRunPromises.delete(runId);
69
+ detachRequests.delete(runId);
70
+ entry.resolve({ ...loaded, detached: true });
71
+ return true;
72
+ }
73
+ detachRequests.add(runId);
74
+ return true;
75
+ }
76
+
77
+ /** True while a detach was requested but no waiter has consumed it yet. */
78
+ export function hasPendingRunDetach(runId: string): boolean {
79
+ return detachRequests.has(runId);
80
+ }
81
+
82
+ export function resolveRunPromise(runId: string, result: RunWaitResult): void {
32
83
  const entry = activeRunPromises.get(runId);
33
84
  if (entry) {
34
85
  entry.resolve(result);
@@ -54,15 +105,22 @@ export async function waitForRun(
54
105
  runId: string,
55
106
  cwd: string,
56
107
  options: { timeoutMs?: number; pollIntervalMs?: number } = {},
57
- ): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
108
+ ): Promise<RunWaitResult> {
58
109
  const { timeoutMs = 300_000, pollIntervalMs = 500 } = options;
59
110
  const deadline = Date.now() + timeoutMs;
60
111
 
112
+ // A detach requested before this waiter existed applies to it (and is
113
+ // consumed here, so it can never leak onto a later waiter for the same run).
114
+ const detachPending = detachRequests.delete(runId);
115
+
61
116
  // Fast path: already terminal on disk
62
117
  const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
63
118
  if (loaded && isFinishedRunStatus(loaded.manifest.status)) {
64
119
  return loaded;
65
120
  }
121
+ if (detachPending && loaded) {
122
+ return { ...loaded, detached: true };
123
+ }
66
124
 
67
125
  // Medium path: foreground promise registered in this process
68
126
  const entry = activeRunPromises.get(runId);
@@ -78,9 +136,17 @@ export async function waitForRun(
78
136
  }
79
137
  }
80
138
 
81
- // Slow path: background run — poll with exponential backoff capped at pollIntervalMs
139
+ // Slow path: background run — poll with exponential backoff capped at pollIntervalMs.
140
+ // This path is ALSO taken by a foreground run whose executeTeamRun has not
141
+ // registered its promise yet (the tool calls waitForRun immediately after
142
+ // starting the run), so it must honour a pending detach request too —
143
+ // otherwise opening an agent view can never release the parent turn.
82
144
  let attempt = 0;
83
145
  while (Date.now() < deadline) {
146
+ if (detachRequests.delete(runId)) {
147
+ const current = loadRunManifestById(cwd, runId);
148
+ if (current) return { ...current, detached: true };
149
+ }
84
150
  if (attempt === 0) {
85
151
  // Early exit: if the run directory doesn't exist, don't waste time polling.
86
152
  // Use projectCrewRoot() to honour the .pi/teams/ fallback for .pi-based
@@ -109,6 +175,7 @@ export function hasActiveRunPromise(runId: string): boolean {
109
175
  }
110
176
 
111
177
  export function clearRunPromisesForTest(): void {
178
+ detachRequests.clear();
112
179
  for (const entry of activeRunPromises.values()) {
113
180
  entry.reject(new Error("Cleared by test"));
114
181
  }
@@ -8,26 +8,26 @@
8
8
  *
9
9
  * Gate dimensions (each with a distinct machine-readable reason + fail-fast
10
10
  * message; evaluated in this order — cheapest/most-global first):
11
- * 1. nesting-disabled — `nesting.enabled` is false (DEFAULT; fail-closed)
12
- * 2. role-denied — the PARENT task's role is not executor-class
13
- * (read-only roles cannot raise spawn privileges),
14
- * or the requested grandchild role is not one of
15
- * the delegate-tool's allowed roles
16
- * 3. trust-denied — untrusted escalation context (manual-only trust
11
+ * 1. trust-denied — untrusted escalation context (manual-only trust
17
12
  * gate; the handler resolves trust, policy enforces)
18
- * 4. depth-exceeded — child depth (parent.depth + 1) exceeds the
19
- * resolved maxDepth (default 2). Depth comes from
13
+ * 2. role-denied — the REQUESTED grandchild role is not one of the
14
+ * delegate-tool's allowed roles (the parent role is
15
+ * NO LONGER gated — D8: every role may delegate;
16
+ * the spawn-side checkCrewDepth cap is the boundary)
17
+ * 3. depth-exceeded — child depth (parent.depth + 1) exceeds the
18
+ * resolved maxDepth (default 4). Depth comes from
20
19
  * the parent task RECORD, never worker env
21
20
  * (design §7 rev-2 P0-2)
22
- * 5. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
21
+ * 4. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
23
22
  * fail-fast, never queue
24
- * 6. budget-insufficient— requested budgetTokens exceed the parent task's
23
+ * 5. budget-insufficient— requested budgetTokens exceed the parent task's
25
24
  * remaining allocation (tokensGranted - tokensSpent)
26
- * 7. model-invalid — requested model is not in the resolved model
25
+ * 6. model-invalid — requested model is not in the resolved model
27
26
  * catalog (the unvalidated provider/model
28
27
  * pass-through — model-fallback.ts:282, the
29
28
  * 429-cascade root — must not be reachable here)
30
- * 8. timeout-invalid — timeoutSec outside 1..MAX (default 900)
29
+ * 7. timeout-invalid — timeoutSec outside 1..MAX (default 900)
30
+ * 8. workspace-conflict — write-capable grandchild narrowing cwd overlap
31
31
  */
32
32
 
33
33
  import { modelRefToString } from "./model/model-fallback.ts";
@@ -48,7 +48,6 @@ export const DEFAULT_DELEGATE_TIMEOUT_SEC = 900;
48
48
  export const MAX_DELEGATE_TIMEOUT_SEC = 86_400;
49
49
 
50
50
  export type SpawnPolicyDenyReason =
51
- | "nesting-disabled"
52
51
  | "role-denied"
53
52
  | "trust-denied"
54
53
  | "depth-exceeded"
@@ -59,14 +58,14 @@ export type SpawnPolicyDenyReason =
59
58
  | "workspace-conflict";
60
59
 
61
60
  export interface DelegateAdmissionInput {
62
- /** Resolved `nesting.enabled` config (DEFAULT false — fail-closed, ADR-5 §10). */
63
- nestingEnabled: boolean;
64
- /** Resolved max depth (nesting.maxDepth default 2; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
61
+ /** Resolved max depth (nesting.maxDepth default 4; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
65
62
  maxDepth: number;
66
63
  /** Parent task record fields the policy is allowed to trust. */
67
64
  parentTask: {
68
65
  taskId: string;
69
- role: string;
66
+ /** Role for workspace-capability evaluation (policy no longer gates the
67
+ * parent role — D8 opens delegation to every role). */
68
+ role?: string;
70
69
  /** Absent on pre-v2 records — treated as depth 1 (a worker). */
71
70
  depth?: number;
72
71
  allocation?: { tokensGranted?: number; tokensSpent?: number };
@@ -122,26 +121,13 @@ function deny(reason: SpawnPolicyDenyReason, message: string): SpawnPolicyDecisi
122
121
  }
123
122
 
124
123
  export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnPolicyDecision {
125
- const { nestingEnabled, maxDepth, parentTask, requested } = input;
124
+ const { maxDepth, parentTask, requested } = input;
126
125
  const parentDepth = parentTask.depth ?? 1; // pre-v2 records: workers are depth 1
127
126
  const childDepth = parentDepth + 1;
128
127
 
129
- // 1. Flag gate — fail-closed default (ADR-5 §10).
130
- if (!nestingEnabled) {
131
- return deny(
132
- "nesting-disabled",
133
- "delegate rejected: governed nesting is disabled (nesting.enabled=false, the fail-closed default; enable it in USER config)",
134
- );
135
- }
136
-
137
- // 2. Role gate — parent must be executor-class; requested role must be in
138
- // the tool's allowed set.
139
- if (!isExecutorClassRole(parentTask.role)) {
140
- return deny(
141
- "role-denied",
142
- `delegate rejected: role '${parentTask.role}' is not executor-class (${EXECUTOR_CLASS_ROLES.join("/")}) — read-only roles cannot delegate`,
143
- );
144
- }
128
+ // 1. Role gate — the REQUESTED grandchild role must be in the tool's allowed
129
+ // set (the PARENT role is no longer gated — D8: every role may delegate;
130
+ // the spawn-side checkCrewDepth cap + slot budget below are the boundary).
145
131
  const requestedRole = requested?.role;
146
132
  if (requestedRole !== undefined && !(DELEGATE_ALLOWED_ROLES as readonly string[]).includes(requestedRole)) {
147
133
  return deny(
@@ -150,12 +136,12 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
150
136
  );
151
137
  }
152
138
 
153
- // 3. Trust gate — untrusted escalation context never spawns.
139
+ // 2. Trust gate — untrusted escalation context never spawns.
154
140
  if (input.untrusted) {
155
141
  return deny("trust-denied", "delegate rejected: untrusted escalation context (trust gate is manual-only)");
156
142
  }
157
143
 
158
- // 4. Depth gate — computed from the parent RECORD (never worker env).
144
+ // 3. Depth gate — computed from the parent RECORD (never worker env).
159
145
  if (childDepth > maxDepth) {
160
146
  return deny(
161
147
  "depth-exceeded",
@@ -163,7 +149,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
163
149
  );
164
150
  }
165
151
 
166
- // 5. Nested-slot budget — fail-fast, never queue.
152
+ // 4. Nested-slot budget — fail-fast, never queue.
167
153
  if (input.slots.used >= input.slots.max) {
168
154
  return deny(
169
155
  "slots-exhausted",
@@ -171,7 +157,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
171
157
  );
172
158
  }
173
159
 
174
- // 6. Parent allocation sufficiency — reserve up-front or reject.
160
+ // 5. Parent allocation sufficiency — reserve up-front or reject.
175
161
  if (requested?.budgetTokens !== undefined) {
176
162
  const granted = parentTask.allocation?.tokensGranted ?? 0;
177
163
  const spent = parentTask.allocation?.tokensSpent ?? 0;
@@ -184,7 +170,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
184
170
  }
185
171
  }
186
172
 
187
- // 7. Model validation against the resolved catalog.
173
+ // 6. Model validation against the resolved catalog.
188
174
  const normalizedModel = requested?.model !== undefined ? modelRefToString(requested.model) : undefined;
189
175
  if (normalizedModel !== undefined && input.modelCatalog !== undefined) {
190
176
  if (!input.modelCatalog.includes(normalizedModel)) {
@@ -192,7 +178,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
192
178
  }
193
179
  }
194
180
 
195
- // 8. Timeout validation (mandatory default 900).
181
+ // 7. Timeout validation (mandatory default 900).
196
182
  const timeoutSec = requested?.timeoutSec ?? DEFAULT_DELEGATE_TIMEOUT_SEC;
197
183
  if (!Number.isFinite(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_DELEGATE_TIMEOUT_SEC) {
198
184
  return deny(
@@ -201,7 +187,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
201
187
  );
202
188
  }
203
189
 
204
- // 9. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
190
+ // 8. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
205
191
  // the parent's cwd with ANOTHER in-flight executor is only admitted when
206
192
  // serialization is established (limits.serializeOnPathOverlap) — otherwise
207
193
  // reject; read-only grandchild roles (explorer/analyst) never conflict.