pi-crew 0.11.0 → 0.11.2

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 (174) hide show
  1. package/CHANGELOG.md +155 -9
  2. package/README.md +161 -1037
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -90644
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +39 -0
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  12. package/skills/real-test-pi-crew/SKILL.md +371 -34
  13. package/src/agents/agent-config.ts +1 -1
  14. package/src/agents/discover-agents.ts +1 -1
  15. package/src/config/config-validation.ts +15 -2
  16. package/src/config/config.ts +47 -13
  17. package/src/config/defaults.ts +0 -1
  18. package/src/config/env-vars.ts +35 -0
  19. package/src/config/types.ts +19 -5
  20. package/src/errors.ts +2 -2
  21. package/src/extension/async-notifier.ts +23 -0
  22. package/src/extension/crew-vibes/config.ts +0 -21
  23. package/src/extension/crew-vibes/index.ts +0 -2
  24. package/src/extension/crew-vibes/render.ts +1 -50
  25. package/src/extension/help.ts +21 -12
  26. package/src/extension/knowledge-injection.ts +2 -1
  27. package/src/extension/management.ts +8 -3
  28. package/src/extension/notification-sink.ts +17 -0
  29. package/src/extension/register.ts +7 -2
  30. package/src/extension/registration/command-utils.ts +28 -2
  31. package/src/extension/registration/commands/dashboard.ts +11 -1
  32. package/src/extension/registration/commands/manage.ts +36 -19
  33. package/src/extension/registration/commands/run.ts +24 -2
  34. package/src/extension/registration/commands/shared.ts +23 -1
  35. package/src/extension/registration/commands/status.ts +25 -2
  36. package/src/extension/registration/context-builder.ts +8 -2
  37. package/src/extension/registration/health-notify-policy.ts +100 -0
  38. package/src/extension/registration/lazy-configurers.ts +35 -0
  39. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  40. package/src/extension/registration/lifecycle.ts +75 -10
  41. package/src/extension/registration/observability.ts +98 -35
  42. package/src/extension/registration/registration-types.ts +7 -5
  43. package/src/extension/registration/runtime-cleanup.ts +9 -3
  44. package/src/extension/registration/subagent-helpers.ts +38 -0
  45. package/src/extension/registration/subagent-tools.ts +16 -6
  46. package/src/extension/registration/team-tool.ts +10 -3
  47. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  48. package/src/extension/registration/viewers.ts +6 -0
  49. package/src/extension/registration/wire-cross-extension.ts +28 -0
  50. package/src/extension/run-compare.ts +220 -0
  51. package/src/extension/run-export.ts +37 -5
  52. package/src/extension/run-maintenance.ts +155 -5
  53. package/src/extension/team-tool/dispatch/index.ts +3 -2
  54. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  55. package/src/extension/team-tool/goal.ts +4 -1
  56. package/src/extension/team-tool/handle-settings.ts +33 -4
  57. package/src/extension/team-tool/health-monitor.ts +21 -7
  58. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  59. package/src/extension/team-tool/plan.ts +10 -0
  60. package/src/extension/team-tool/routing-hint.ts +63 -0
  61. package/src/extension/team-tool/status.ts +4 -0
  62. package/src/extension/team-tool.ts +52 -6
  63. package/src/extension/webhook-notify.ts +382 -0
  64. package/src/observability/metric-sink.ts +12 -2
  65. package/src/prompt/prompt-runtime.ts +82 -31
  66. package/src/prompt/worker-events-channel.ts +12 -0
  67. package/src/runtime/README.md +1 -1
  68. package/src/runtime/async-runner.ts +87 -1
  69. package/src/runtime/background-runner.ts +313 -234
  70. package/src/runtime/broker/crew-broker.ts +17 -11
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/wait-status-cache.ts +1 -1
  73. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  74. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  75. package/src/runtime/crew-agent-records.ts +337 -45
  76. package/src/runtime/deadletter.ts +43 -1
  77. package/src/runtime/delegate-spawn.ts +5 -1
  78. package/src/runtime/dispatch-batch.ts +72 -5
  79. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  80. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  81. package/src/runtime/model/model-fallback.ts +21 -1
  82. package/src/runtime/model/pi-args.ts +8 -10
  83. package/src/runtime/recovery/crash-recovery.ts +25 -1
  84. package/src/runtime/run-worker.ts +12 -1
  85. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  86. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  87. package/src/runtime/scheduling/scheduler.ts +49 -13
  88. package/src/runtime/scheduling/semaphore.ts +148 -20
  89. package/src/runtime/scratchpad/README.md +1 -1
  90. package/src/runtime/scratchpad/protocol.ts +1 -1
  91. package/src/runtime/settings-store.ts +1 -1
  92. package/src/runtime/skill-instructions.ts +22 -0
  93. package/src/runtime/stale-reconciler.ts +85 -13
  94. package/src/runtime/task-display.ts +1 -1
  95. package/src/runtime/task-runner/pre-execution.ts +26 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  97. package/src/runtime/task-runner.ts +21 -1
  98. package/src/runtime/team-runner.ts +38 -1
  99. package/src/runtime/workspace-lock.ts +4 -1
  100. package/src/schema/config-schema.ts +18 -0
  101. package/src/schema/team-tool-schema.ts +17 -0
  102. package/src/state/atomic-write.ts +53 -0
  103. package/src/state/contracts.ts +109 -0
  104. package/src/state/coordination/locks.ts +191 -33
  105. package/src/state/coordination/mailbox.ts +140 -15
  106. package/src/state/crew-init.ts +87 -12
  107. package/src/state/event-log/cursor.ts +37 -1
  108. package/src/state/event-log/event-log-rotation.ts +72 -7
  109. package/src/state/stores/active-run-registry.ts +13 -1
  110. package/src/state/stores/state-store.ts +112 -22
  111. package/src/state/types.ts +4 -0
  112. package/src/ui/adaptive-card.ts +65 -0
  113. package/src/ui/agents-jobs-browser.ts +70 -64
  114. package/src/ui/card-colors.ts +36 -7
  115. package/src/ui/dashboard-panes/agents-pane.ts +55 -14
  116. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  117. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  118. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  119. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  120. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  121. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  122. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  123. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  124. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  125. package/src/ui/dwf-phase-display.ts +3 -20
  126. package/src/ui/format-helpers.ts +22 -0
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/crew-editor.ts +13 -3
  129. package/src/ui/inline-panel/index.ts +60 -4
  130. package/src/ui/keybinding-map.ts +251 -35
  131. package/src/ui/live-conversation-overlay.ts +180 -47
  132. package/src/ui/live-run-sidebar.ts +134 -55
  133. package/src/ui/mascot.ts +32 -16
  134. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  135. package/src/ui/overlays/confirm-overlay.ts +55 -29
  136. package/src/ui/overlays/help-overlay.ts +108 -53
  137. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  138. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  139. package/src/ui/powerbar-publisher.ts +0 -1
  140. package/src/ui/rail.ts +333 -0
  141. package/src/ui/run-dashboard.ts +193 -79
  142. package/src/ui/run-snapshot-cache.ts +18 -1
  143. package/src/ui/settings-overlay.ts +81 -39
  144. package/src/ui/spinner.ts +26 -2
  145. package/src/ui/terminal-status.ts +7 -1
  146. package/src/ui/theme-adapter.ts +0 -45
  147. package/src/ui/theme-discovery.ts +12 -6
  148. package/src/ui/tool-progress-formatter.ts +128 -9
  149. package/src/ui/tool-renderers/brief-mode.ts +10 -67
  150. package/src/ui/tool-renderers/index.ts +374 -523
  151. package/src/ui/transcript-viewer.ts +30 -12
  152. package/src/ui/widget/index.ts +32 -52
  153. package/src/ui/widget/task-list.ts +64 -32
  154. package/src/ui/widget/widget-formatters.ts +3 -402
  155. package/src/ui/widget/widget-model.ts +28 -7
  156. package/src/ui/widget/widget-renderer.ts +201 -128
  157. package/src/ui/widget/widget-types.ts +0 -2
  158. package/src/utils/incremental-reader.ts +11 -3
  159. package/src/utils/paths.ts +94 -12
  160. package/src/utils/project-markers.ts +40 -0
  161. package/src/utils/visual.ts +0 -4
  162. package/src/worktree/worktree-manager.ts +206 -26
  163. package/workflows/distill.workflow.md +3 -3
  164. package/workflows/fast-fix.workflow.md +1 -1
  165. package/workflows/plan-execute.workflow.md +1 -1
  166. package/workflows/review.workflow.md +1 -1
  167. package/workflows/strict-fast-fix.workflow.md +1 -1
  168. package/docs/migration-v0.4-v0.5.md +0 -208
  169. package/docs/runtime-flow.md +0 -148
  170. package/src/extension/crew-vibes/figures.ts +0 -22
  171. package/src/extension/crew-vibes/font-detect.ts +0 -71
  172. package/src/ui/dynamic-border.ts +0 -35
  173. package/src/ui/loaders.ts +0 -6
  174. package/src/ui/overlay-stack.ts +0 -148
@@ -24,6 +24,7 @@ export type ScheduleChangeEvent =
24
24
  | { type: "removed"; jobId: string; spawnedRunIds?: string[] }
25
25
  | { type: "updated"; job: ScheduledJob }
26
26
  | { type: "fired"; jobId: string; agentId: string; name: string }
27
+ | { type: "skipped"; jobId: string; reason: string }
27
28
  | { type: "error"; jobId: string; error: string };
28
29
 
29
30
  export interface CrewSchedulerOptions {
@@ -152,19 +153,29 @@ export class CrewScheduler {
152
153
  private arm(job: ScheduledJob): void {
153
154
  if (this.timers.has(job.id)) return;
154
155
  if (job.scheduleType === "interval" && job.intervalMs) {
155
- const t = setInterval(() => this.fire(job.id), job.intervalMs);
156
- t.unref();
157
- this.timers.set(job.id, t);
156
+ // 2026-09-22 spawn-storm root cause: this branch used
157
+ // setInterval(fire, intervalMs). Node timers are 32-bit — a LEGAL long
158
+ // interval (e.g. 90d = 7,776,000,000ms > 2^31-1) overflows and Node
159
+ // silently sets the delay to 1ms (TimeoutOverflowWarning): the job then
160
+ // fired every millisecond, and each
161
+ // tick dispatched another run (measured live: ~104 garbage runs + 50+
162
+ // node processes from ONE registered 90-day interval job). armCron()
163
+ // already clamps its hops below the same ceiling — the interval branch
164
+ // was missed. Reuse the identical chained-hop treatment: schedule toward
165
+ // now+intervalMs, clamped; cronTick fires on arrival (its
166
+ // advanceCronNextRun is a cron-only no-op here), and fire()'s internal
167
+ // update() → disarm→arm lifecycle re-arms the next interval.
168
+ const targetMs = this.now().getTime() + job.intervalMs;
169
+ this.setCronTimeout(job.id, targetMs);
158
170
  } else if (job.scheduleType === "once") {
159
171
  const target = new Date(job.schedule).getTime();
160
172
  const delay = target - this.now().getTime();
161
173
  if (delay > 0) {
162
- const t = setTimeout(() => {
163
- this.fire(job.id);
164
- this.update(job.id, { enabled: false });
165
- }, delay);
166
- t.unref();
167
- this.timers.set(job.id, t);
174
+ // Same 32-bit ceiling as the interval branch above: a once job armed
175
+ // > 2^31-1 ms out (e.g. "+30d" — a LEGAL relative spec — or a far ISO
176
+ // timestamp) overflowed setTimeout to a 1ms PREMATURE fire. Chained hops
177
+ // instead; cronTick self-disables once-jobs on arrival (below).
178
+ this.setCronTimeout(job.id, target);
168
179
  } else {
169
180
  this.update(job.id, { enabled: false, lastStatus: "error" });
170
181
  this.emit?.({
@@ -219,6 +230,9 @@ export class CrewScheduler {
219
230
  // hop that just fired is dead, so no timer doubles up.
220
231
  this.fire(jobId);
221
232
  this.advanceCronNextRun(jobId);
233
+ // once-semantics (was inline in arm()'s removed setTimeout): consume the
234
+ // job after its single arrival fire so a re-arm cannot fire it again.
235
+ if (job.scheduleType === "once") this.update(jobId, { enabled: false });
222
236
  }
223
237
 
224
238
  /** After a cron fire, advance the persisted nextRun to the next occurrence
@@ -265,6 +279,18 @@ export class CrewScheduler {
265
279
  const job = this.jobs.get(id);
266
280
  if (!job || !this.executor) return;
267
281
  if (!job.enabled && !force) return;
282
+ // In-flight guard (2026-09-22, storm follow-up): the executor dispatches
283
+ // asynchronously and resets lastStatus (success/error) only on completion,
284
+ // so `running` marks the in-flight window. Without backpressure here, any
285
+ // interval shorter than the run duration stacks one overlapping run per
286
+ // tick (the 1ms overflow loop was the extreme case; fixed below/above).
287
+ // Skips emit a "skipped" event (visible to pi.events consumers; the toast
288
+ // bridge stays silent — a slow executor + short interval would spam).
289
+ // `force` (run-now) is the explicit escape hatch.
290
+ if (job.lastStatus === "running" && !force) {
291
+ this.emit?.({ type: "skipped", jobId: id, reason: "previous dispatch still in flight" });
292
+ return;
293
+ }
268
294
  this.update(id, { lastStatus: "running" });
269
295
  let agentId: string;
270
296
  try {
@@ -297,10 +323,15 @@ export class CrewScheduler {
297
323
  normalized: new Date(Date.now() + ms).toISOString(),
298
324
  };
299
325
  }
300
- // Interval: 5m
301
- const ivl = trimmed.match(/^(\d+)(s|m|h|d)$/);
326
+ // Interval: 5m — "ms" MUST be accepted: handle-schedule builds
327
+ // `${params.interval}ms` from the schema-level numeric `interval` param
328
+ // (ms number), so without an ms unit here every interval schedule died in
329
+ // the parser ("Invalid schedule …") — found live 2026-09-21 when
330
+ // `team action='schedule' interval=3600000` was impossible to satisfy.
331
+ const ivl = trimmed.match(/^(\d+)(ms|s|m|h|d)$/);
302
332
  if (ivl) {
303
- const ms = parseInt(ivl[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[ivl[2] as "s" | "m" | "h" | "d"];
333
+ const ms =
334
+ parseInt(ivl[1], 10) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[ivl[2] as "ms" | "s" | "m" | "h" | "d"];
304
335
  return { type: "interval", intervalMs: ms, normalized: trimmed };
305
336
  }
306
337
  // ISO timestamp
@@ -329,13 +360,18 @@ function parseIntervalMs(s: string): number | undefined {
329
360
  let ms = 0;
330
361
  let remaining = s;
331
362
  const unitMs: Record<string, number> = {
363
+ // "ms" MUST be first in the regex alternation below and present here:
364
+ // handle-schedule builds `${params.interval}ms` from the numeric interval
365
+ // param, so without an ms unit every interval schedule failed to parse
366
+ // (found live 2026-09-21 — `interval=3600000` was unsatisfiable).
367
+ ms: 1,
332
368
  s: 1000,
333
369
  m: 60_000,
334
370
  h: 3_600_000,
335
371
  d: 86_400_000,
336
372
  };
337
373
  while (remaining.length > 0) {
338
- const m = remaining.match(/^(\d+)(s|m|h|d)/);
374
+ const m = remaining.match(/^(\d+)(ms|s|m|h|d)/);
339
375
  if (!m) return undefined;
340
376
  ms += parseInt(m[1], 10) * unitMs[m[2]];
341
377
  remaining = remaining.slice(m[0].length);
@@ -3,11 +3,92 @@
3
3
  *
4
4
  * Adapted from oh-my-pi's `parallel.ts` Semaphore class and
5
5
  * `mapWithConcurrencyLimit` implementation. Provides:
6
- * - Explicit acquire/release Semaphore for concurrency control
7
- * - Fail-fast on first error (via Promise.race)
8
- * - AbortSignal support for graceful cancellation
9
- * - Partial results on abort
6
+ * - Explicit acquire/release Semaphore for concurrency control, with an
7
+ * OPTIONAL AbortSignal on acquire() (RR-014 / F15: a queued waiter settles
8
+ * promptly when its signal fires — it never waits out the current slot
9
+ * holder, and never consumes a slot once cancelled)
10
+ * - Fail-fast on first error (via Promise.race) — mapWithFailFast below
11
+ * - AbortSignal support for graceful cancellation — mapWithFailFast below
12
+ * - Partial results on abort — mapWithFailFast below
13
+ *
14
+ * RR-014 / F15 defect history: acquire() previously took NO AbortSignal, so a
15
+ * cancelled waiter stayed pending until the slot holder released, and then
16
+ * "received" a slot it could never use (verified probe: acquire() returned
17
+ * with signal.aborted === true). The signal-aware acquire below closes that:
18
+ * aborted waiters are eagerly removed from the queue and REJECT with
19
+ * SemaphoreAbortedError; #current never changes for an aborted acquire.
20
+ */
21
+
22
+ /**
23
+ * Rejection reason for {@link Semaphore.acquire} when the caller's AbortSignal
24
+ * fires before a slot is granted (RR-014 / F15). Distinguishable via
25
+ * `name === "SemaphoreAbortedError"` — an aborted acquire never bare-resolves.
26
+ */
27
+ export class SemaphoreAbortedError extends Error {
28
+ constructor() {
29
+ super("Semaphore acquire aborted: the caller's AbortSignal fired before a slot was granted");
30
+ this.name = "SemaphoreAbortedError";
31
+ }
32
+ }
33
+
34
+ /** Lifecycle outcome of a queued waiter. */
35
+ type WaiterOutcome = "granted" | "aborted";
36
+
37
+ /**
38
+ * Queue entry WITH identity (RR-014 / F15). The pre-fix queue stored bare
39
+ * resolve functions: an aborted waiter could not be referenced or removed, so
40
+ * it stayed pending until a release() handed it a slot it could never use.
41
+ * A record with an idempotent settle() can detach itself from its abort
42
+ * listener exactly once no matter how the abort↔handoff race resolves.
43
+ */
44
+ interface Waiter {
45
+ /** Resolves on "granted"; rejects with SemaphoreAbortedError on "aborted". */
46
+ readonly promise: Promise<void>;
47
+ /** Whether settle() already ran (first call wins). */
48
+ readonly settled: boolean;
49
+ /**
50
+ * Settle this waiter exactly once and detach its abort listener.
51
+ * - "granted" → resolve: the waiter now OWNS a slot and must release().
52
+ * - "aborted" → reject: the waiter never owned a slot.
53
+ */
54
+ settle(kind: WaiterOutcome): void;
55
+ }
56
+
57
+ /**
58
+ * Create a waiter and attach its abort listener on the CALLER'S signal
59
+ * (never on a derived signal — a listener on AbortSignal.any() cannot be
60
+ * removed from the original). `onAbort` runs synchronously when the signal
61
+ * fires; the semaphore uses it to remove the waiter from the queue eagerly.
10
62
  */
63
+ function createWaiter(signal: AbortSignal | undefined, onAbort: () => void): Waiter {
64
+ let resolve!: () => void;
65
+ let reject!: (error: Error) => void;
66
+ const promise = new Promise<void>((res, rej) => {
67
+ resolve = res;
68
+ reject = rej;
69
+ });
70
+ let isSettled = false;
71
+ let detachListener: (() => void) | undefined;
72
+ if (signal) {
73
+ const listener = () => onAbort();
74
+ signal.addEventListener("abort", listener, { once: true });
75
+ detachListener = () => signal.removeEventListener("abort", listener);
76
+ }
77
+ return {
78
+ promise,
79
+ get settled() {
80
+ return isSettled;
81
+ },
82
+ settle(kind: WaiterOutcome) {
83
+ if (isSettled) return;
84
+ isSettled = true;
85
+ detachListener?.();
86
+ detachListener = undefined;
87
+ if (kind === "granted") resolve();
88
+ else reject(new SemaphoreAbortedError());
89
+ },
90
+ };
91
+ }
11
92
 
12
93
  /**
13
94
  * Simple counting semaphore for limiting concurrency across independently-scheduled async work.
@@ -15,7 +96,7 @@
15
96
  export class Semaphore {
16
97
  #max: number;
17
98
  #current = 0;
18
- #queue: Array<() => void> = [];
99
+ #queue: Waiter[] = [];
19
100
  // FIX (Round 15): Cap the waiter queue to prevent unbounded memory growth
20
101
  // if the semaphore is held for a long period and many tasks accumulate.
21
102
  static readonly MAX_QUEUE = 10_000;
@@ -24,9 +105,44 @@ export class Semaphore {
24
105
  this.#max = Math.max(1, max);
25
106
  }
26
107
 
27
- async acquire(): Promise<void> {
108
+ /**
109
+ * Acquire a slot, optionally abortable (RR-014 / F15).
110
+ *
111
+ * - No signal → unchanged semantics: blocks until a slot frees.
112
+ * - Signal already aborted at entry → rejects immediately (fail-closed):
113
+ * no slot taken, nothing enqueued. Abort listeners never re-fire for an
114
+ * already-aborted signal, so such a waiter could never be removed later.
115
+ * - Signal fires while WAITING → the waiter is removed from the queue
116
+ * EAGERLY (synchronously inside the abort listener) and rejects with
117
+ * SemaphoreAbortedError. It never consumes a slot: #current is untouched.
118
+ *
119
+ * Abort↔handoff race resolves to exactly one consistent state because
120
+ * every #queue/#current mutation is synchronous (no await between them)
121
+ * and settle() is idempotent (first call wins, and it detaches the abort
122
+ * listener):
123
+ * - abort wins → the waiter was spliced out of the queue, so release()
124
+ * cannot hand it a slot; the freed slot reaches the next ALIVE waiter
125
+ * (or decrements #current). Capacity intact.
126
+ * - grant wins → settle("granted") detached the listener, so a later
127
+ * abort is a no-op at semaphore level; the caller observes
128
+ * signal.aborted and releases normally (runChildPi's pre-spawn guard
129
+ * returns kind "aborted" without spawning, so withWorkerSlot's finally
130
+ * releases the slot within a microtask of the grant).
131
+ */
132
+ async acquire(signal?: AbortSignal): Promise<void> {
133
+ if (signal?.aborted) {
134
+ return Promise.reject(new SemaphoreAbortedError());
135
+ }
28
136
  if (this.#current < this.#max) {
29
137
  this.#current++;
138
+ // Re-check the abort↔grant window before resolving. The path above
139
+ // is synchronous so the flag cannot flip today; this guards against
140
+ // a future await being inserted here (belt-and-braces — concurrency
141
+ // primitive). Never resolve a granted acquire for an aborted signal.
142
+ if (signal?.aborted) {
143
+ this.#current--;
144
+ return Promise.reject(new SemaphoreAbortedError());
145
+ }
30
146
  return;
31
147
  }
32
148
  // FIX (Round 15): Reject when the waiter queue is full. The previous
@@ -38,23 +154,35 @@ export class Semaphore {
38
154
  new Error(`Semaphore queue full: ${this.#queue.length} waiters (max ${Semaphore.MAX_QUEUE}); cannot acquire slot`),
39
155
  );
40
156
  }
41
- const { promise, resolve } = (() => {
42
- let res: () => void;
43
- const p = new Promise<void>((r) => {
44
- res = r;
45
- });
46
- return { promise: p, resolve: res! };
47
- })();
48
- this.#queue.push(resolve);
49
- return promise;
157
+ const waiter = createWaiter(signal, () => {
158
+ // Eager removal, synchronously inside the abort listener: the waiter
159
+ // leaves the queue BEFORE settling. A release() that already
160
+ // shift()ed it (grant won the race) finds settle() idempotent; a
161
+ // release() that has not cannot hand a slot to a dead waiter.
162
+ // #current is untouched — this waiter never owned a slot.
163
+ const index = this.#queue.indexOf(waiter);
164
+ if (index >= 0) this.#queue.splice(index, 1);
165
+ waiter.settle("aborted");
166
+ });
167
+ this.#queue.push(waiter);
168
+ return waiter.promise;
50
169
  }
51
170
 
52
171
  release(): void {
53
- const next = this.#queue.shift();
54
- if (next) {
55
- next();
56
- } else if (this.#current > 0) {
57
- this.#current--;
172
+ // Hand the freed slot to the first ALIVE waiter (FIFO). A settled entry
173
+ // still sitting in the queue is impossible with eager removal but is
174
+ // skipped defensively — a slot must never burn on a dead waiter. Handoff
175
+ // TRANSFERS ownership: #current is NOT decremented; only when no alive
176
+ // waiter remains does it decrement.
177
+ while (true) {
178
+ const next = this.#queue.shift();
179
+ if (next === undefined) {
180
+ if (this.#current > 0) this.#current--;
181
+ return;
182
+ }
183
+ if (next.settled) continue;
184
+ next.settle("granted");
185
+ return;
58
186
  }
59
187
  // Guard: over-release is a no-op to prevent #current going negative
60
188
  }
@@ -125,7 +125,7 @@ attempt N+1 worker (scratchpad-lifecycle.ts)
125
125
  is a Phase 2.5/3 hardening if artifacts ever land in a shared location.
126
126
 
127
127
  > **REMOVED (2026-08-12):** `src/runtime/scratchpad/snapshot-hmac.ts` and its
128
- > test were deleted. Decision (P3 of `docs/rlm-fixes-implementation-plan.md`):
128
+ > test were deleted. Decision (P3 of `docs/archive/rlm-fixes-implementation-plan.md`):
129
129
  > the HMAC threat is double-conditional — it only materializes when scratchpad
130
130
  > has adoption (>0 cells; today: 0/83 runs) AND snapshots move to a
131
131
  > shared/networked store (today: same-uid dev-machine). Hardening a
@@ -46,7 +46,7 @@ export interface GuestToHost {
46
46
  };
47
47
  pong: { type: "pong"; id: string };
48
48
  host_request: {
49
- // RESERVED for future host bridge (rlm-deep-review-2026-08-12.md §5.2F /
49
+ // RESERVED for future host bridge (docs/archive/rlm-deep-review-2026-08-12.md §5.2F /
50
50
  // J2): guest-side cells would request host services (tools.read,
51
51
  // tools.grep) so data enters the namespace WITHOUT crossing the
52
52
  // transcript — pi-rlm's core token-saving value proposition. Declared
@@ -112,7 +112,7 @@ function readSettingsFile(filePath: string): CrewSettings {
112
112
  * API — never this function. Kept exported for back-compat reads/tests;
113
113
  * adding a new consumer that writes into loadedConfig re-introduces the
114
114
  * fixed P1 bypass (see docs/decisions/2026-08-15-schema-driven-sanitize.md
115
- * and the Wave 2B execution log in docs/refactor-plan.md).
115
+ * and the Wave 2B execution log in docs/archive/refactor-plan.md).
116
116
  */
117
117
  export function loadCrewSettings(cwd: string = process.cwd(), globalFile: string = globalPath()): CrewSettings {
118
118
  return {
@@ -7,6 +7,7 @@ import { packageRoot } from "../utils/paths.ts";
7
7
  import { isSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
8
8
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
9
9
  import { CONFIDENCE_THRESHOLDS, getWeightedSkillsForRole, registerSkillEffectivenessHooks } from "./skill-effectiveness.ts";
10
+ import { promptSkillMode } from "./task-runner/prompt-builder.ts";
10
11
 
11
12
  const PACKAGE_SKILLS_DIR = path.join(packageRoot(), "skills");
12
13
 
@@ -324,6 +325,10 @@ export function renderSkillInstructions(
324
325
  const names = allNames.slice(0, MAX_SELECTED_SKILLS);
325
326
  const overflowCount = Math.max(0, allNames.length - names.length);
326
327
  if (names.length === 0) return { names, paths: [], block: "" };
328
+ // SR-02 phase 2: "index" mode (default) injects compact entries instead of
329
+ // full skill bodies — skills were 40-50% of measured worker prompts while a
330
+ // Path pointer + read tool gives the worker the SAME information on demand.
331
+ const mode = promptSkillMode();
327
332
  const sections: string[] = [];
328
333
  const skillPaths: string[] = [];
329
334
  let total = 0;
@@ -379,6 +384,23 @@ export function renderSkillInstructions(
379
384
  ]
380
385
  .filter(Boolean)
381
386
  .join("\n");
387
+ if (mode === "index") {
388
+ // Index entry: the description (frontmatter) says WHEN the skill
389
+ // applies; the Path says WHERE to read it. The worker loads the full
390
+ // SKILL.md only when the task matches — same information, on demand.
391
+ const entry = [
392
+ `## ${safeName}`,
393
+ description ? `Description: ${description}${confidenceNote}` : undefined,
394
+ `Source: ${source}`,
395
+ `Path: ${path.dirname(loaded.path)}`,
396
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: ${Path} refers to the Path field printed above, not a JS interpolation
397
+ "When this skill matches your task, FIRST read ${Path}/SKILL.md and follow it.",
398
+ ]
399
+ .filter(Boolean)
400
+ .join("\n");
401
+ if (!pushSection(entry)) omittedCount += 1;
402
+ continue;
403
+ }
382
404
  const rawContent = loaded.compacted;
383
405
  // Wrap skill content with provenance markers to help LLMs distinguish skill instructions
384
406
  const wrappedContent = `<!-- skill: ${safeName} -->\n${rawContent}\n<!-- end-skill: ${safeName} -->`;
@@ -1,17 +1,29 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { getCrewEnv } from "../config/env-vars.ts";
4
5
  import { errors } from "../errors.ts";
5
6
  import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
6
7
  import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
7
8
  import { loadManifestWithRecovery, loadTasksWithRecovery, saveRunManifest } from "../state/stores/state-store.ts";
8
9
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
9
10
  import { logInternalError } from "../utils/internal-error.ts";
11
+ import { findRunStateDir } from "../utils/paths.ts";
10
12
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
11
13
  import { checkProcessLiveness } from "./process-status.ts";
12
14
 
13
15
  /** Age threshold for orphaned temp directory cleanup: 1 hour. */
14
16
  const ORPHAN_TEMP_DIR_AGE_THRESHOLD_MS = 60 * 60 * 1000;
17
+ /** A `.cleanup-in-progress` sentinel older than this is abandoned — its owner
18
+ * died mid-cleanup (session kill between sentinel-create and dir-delete) and
19
+ * without reclamation the workspace is frozen out of the cleanup cycle
20
+ * FOREVER (EEXIST → skip, no TTL). Live 2026-09-26: 42 frozen workspaces. */
21
+ const SENTINEL_STALE_RECLAIM_MS = 10 * 60 * 1000;
22
+ /** Assumed production cadence of the temp-workspace reconcile tick (60s —
23
+ * see observability.ts tempReconcileTimer). Only used to derive the
24
+ * stateless round-robin batch index; a different real cadence still cycles
25
+ * through every batch, just with different pacing. */
26
+ const ORPHAN_RECONCILE_TICK_MS = 60_000;
15
27
  /** Defense-in-depth: cap the number of /tmp/pi-crew-* entries processed per
16
28
  * reconcile tick. With a few thousand accumulated dirs, processing them all
17
29
  * synchronously can block the main thread for many seconds, causing the
@@ -284,7 +296,7 @@ function isTaskHeartbeatStale(task: TeamTaskState, now: number): boolean {
284
296
  const taskPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
285
297
  const pidAlive = taskPid ? checkProcessLiveness(taskPid).alive : false;
286
298
  if (taskPid && pidAlive) return false;
287
- if (process.env.PI_CREW_DEBUG_STALE === "1") {
299
+ if (getCrewEnv("PI_CREW_DEBUG_STALE") === "1") {
288
300
  // F1 forensic (battery 2026-09-10): sidecar log of every STALE verdict so
289
301
  // live repros can show exactly what the reconciler saw (pid present? alive?
290
302
  // elapsed?) — the reconciler may run in ANY host process, hence a fixed
@@ -568,18 +580,38 @@ export function reconcileOrphanedTempWorkspaces(
568
580
  const scanBatch = options?.scanBatchSize ?? ORPHAN_TEMP_SCAN_BATCH_SIZE;
569
581
  const candidates = entries
570
582
  .filter((e) => e.isDirectory() && e.name.startsWith("pi-crew-"))
571
- .sort((a, b) => a.name.localeCompare(b.name))
572
- .slice(0, scanBatch);
573
- for (const entry of candidates) {
583
+ .sort((a, b) => a.name.localeCompare(b.name));
584
+ // STATELESS ROUND-ROBIN across ticks (Bug B, live 2026-09-26): the old
585
+ // `slice(0, scanBatch)` visited only the alphabetically-first batch, so a
586
+ // cluster of uncleanable dirs (frozen sentinels / fresh sentinels /
587
+ // waiting runs) starved everything behind it — 42 frozen sentinels in the
588
+ // `agent-stale-wakeup-test-*` cluster (alphabetically first) blocked ~3.4k
589
+ // dirs from EVER being scanned. Each tick now takes the NEXT slice,
590
+ // derived deterministically from `now` (production cadence is 60s/tick —
591
+ // see observability.ts tempReconcileTimer), so every batch is visited
592
+ // within `batches` ticks without any persisted state. Deterministic under
593
+ // the injected `now` that tests already use.
594
+ const batches = Math.max(1, Math.ceil(candidates.length / scanBatch));
595
+ const batchIdx = Math.floor(now / ORPHAN_RECONCILE_TICK_MS) % batches;
596
+ const selected = candidates.slice(batchIdx * scanBatch, batchIdx * scanBatch + scanBatch);
597
+ for (const entry of selected) {
574
598
  if (!entry.isDirectory() || !entry.name.startsWith("pi-crew-")) continue;
575
599
  const workspaceDir = path.join(tmpDir, entry.name);
576
- const crewDir = path.join(workspaceDir, ".crew");
577
- if (!fs.existsSync(crewDir)) continue;
578
- const stateRunsDir = path.join(crewDir, "state", "runs");
579
- if (!fs.existsSync(stateRunsDir)) continue;
600
+ // RR-020 Fix 3: accept BOTH supported run-state layouts — `<dir>/.crew/
601
+ // state/runs` and the `.pi`-based `<dir>/.pi/teams/state/runs`. Only the
602
+ // `.crew` layout used to be seen here, so a temp workspace with live
603
+ // `.pi/teams` run state was invisible to the reconciler (and then
604
+ // deleted as debris by the legacy cleanup sweep).
605
+ const stateRunsDir = findRunStateDir(workspaceDir);
606
+ if (!stateRunsDir) continue;
580
607
  let hasRunning = false;
581
608
  try {
582
- for (const runDir of fs.readdirSync(stateRunsDir)) {
609
+ // Cold-verify F-2: Dirent.isDirectory() is FALSE for a symlink-to-dir
610
+ // (withFileTypes does not follow), so a planted `runs/<runId>` symlink
611
+ // is skipped instead of being read/written out-of-tree.
612
+ for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
613
+ if (!entry.isDirectory()) continue;
614
+ const runDir = entry.name;
583
615
  const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
584
616
  const tasksPath = path.join(stateRunsDir, runDir, "tasks.json");
585
617
  if (!fs.existsSync(manifestPath) || !fs.existsSync(tasksPath)) continue;
@@ -700,14 +732,50 @@ export function reconcileOrphanedTempWorkspaces(
700
732
  fs.closeSync(sentinelFd);
701
733
  atomicWriteFile(sentinelPath, JSON.stringify({ startedAt: now }));
702
734
  } catch {
703
- // Sentinel already exists (another cleanup in progress) — skip
704
- canCleanup = false;
735
+ // Sentinel already exists. Either another cleanup is genuinely in
736
+ // progress (it holds the sentinel for seconds at most) OR a previous
737
+ // process died mid-cleanup and ABANDONED it — the frozen-workspace
738
+ // bug (no TTL on the skip). Reclaim sentinels older than
739
+ // SENTINEL_STALE_RECLAIM_MS and retry the exclusive create once;
740
+ // losing the retry race means someone else reclaimed first — skip.
741
+ let sentinelAgeMs = Number.NaN;
742
+ try {
743
+ sentinelAgeMs = now - fs.statSync(sentinelPath).mtimeMs;
744
+ } catch {
745
+ /* sentinel vanished — retry create below */
746
+ }
747
+ if (Number.isFinite(sentinelAgeMs) && sentinelAgeMs <= SENTINEL_STALE_RECLAIM_MS) {
748
+ canCleanup = false; // fresh sentinel — a live cleanup owns it
749
+ } else {
750
+ try {
751
+ fs.unlinkSync(sentinelPath); // stale/absent — reclaim
752
+ } catch {
753
+ /* already gone */
754
+ }
755
+ try {
756
+ const retryFd = fs.openSync(sentinelPath, "wx");
757
+ fs.closeSync(retryFd);
758
+ atomicWriteFile(sentinelPath, JSON.stringify({ startedAt: now, reclaimedFromStale: true }));
759
+ } catch {
760
+ canCleanup = false; // lost the reclaim race — skip
761
+ }
762
+ }
705
763
  }
706
764
  }
707
765
  if (canCleanup) {
708
766
  if (fs.existsSync(stateRunsDir)) {
709
767
  try {
710
- for (const runDir of fs.readdirSync(stateRunsDir)) {
768
+ // Cold-verify round 5 (E1): a string readdir FOLLOWS a symlinked
769
+ // `<runId>` entry, and loadManifestWithRecovery on a corrupt manifest
770
+ // then QUARANTINES it — renameSync resolves through the symlinked
771
+ // dir and renamed a file OUTSIDE the scanned tree (reproduced even
772
+ // with cleanupOrphanedTempDirs:false, fired by the production
773
+ // tempReconcileTimer every session). Dirent.isDirectory() is false
774
+ // for a symlink-to-dir, so planted entries are skipped. Deletion
775
+ // itself was already safe (rmSync unlinks symlinks, never follows).
776
+ for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
777
+ if (!entry.isDirectory()) continue;
778
+ const runDir = entry.name;
711
779
  const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
712
780
  if (!fs.existsSync(manifestPath)) continue;
713
781
  const manifest = loadManifestWithRecovery(manifestPath, runDir);
@@ -733,7 +801,11 @@ export function reconcileOrphanedTempWorkspaces(
733
801
  let stillClean = true;
734
802
  if (fs.existsSync(stateRunsDir)) {
735
803
  try {
736
- for (const runDir of fs.readdirSync(stateRunsDir)) {
804
+ // Cold-verify round 5 (E1): same Dirent guard as the first gate —
805
+ // quarantine-rename through a symlinked `<runId>` wrote outside.
806
+ for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
807
+ if (!entry.isDirectory()) continue;
808
+ const runDir = entry.name;
737
809
  const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
738
810
  if (!fs.existsSync(manifestPath)) continue;
739
811
  const manifest = loadManifestWithRecovery(manifestPath, runDir);
@@ -47,6 +47,6 @@ export function formatTaskGraphLines(tasks: TeamTaskState[]): string[] {
47
47
  ? "⚠"
48
48
  : "◦";
49
49
  const wait = waitingReason(task, tasks);
50
- return `- ${icon} ${task.id} [${task.status}] ${task.role}->${task.agent}${wait && wait !== "ready" ? ` (${wait})` : ""}`;
50
+ return `- ${icon} ${task.id} [${task.status}] ${task.role}▸${task.agent}${wait && wait !== "ready" ? ` (${wait})` : ""}`;
51
51
  });
52
52
  }
@@ -38,7 +38,7 @@ import { buildTaskPacket } from "../task-packet.ts";
38
38
  // this module at runtime; we only need the TaskRunnerInput type here).
39
39
  import type { TaskRunnerInput } from "../task-runner.ts";
40
40
  import { DEFAULT_YIELD_CONFIG } from "../yield-handler.ts";
41
- import { coordinationBridgeInstructions, renderTaskPrompt } from "./prompt-builder.ts";
41
+ import { coordinationBridgeInstructions, estimateTokens, promptBreakdownEnabled, renderTaskPrompt } from "./prompt-builder.ts";
42
42
  import { checkpointTask, persistSingleTaskUpdate, updateTask } from "./state-helpers.ts";
43
43
 
44
44
  /** The stream bridge handle created by registerStreamBridge. */
@@ -324,7 +324,7 @@ export async function prepareTaskExecutionContext(
324
324
  }
325
325
  }
326
326
 
327
- const promptResult = await renderTaskPrompt(manifest, input.step, task, input.agent, skillBlock);
327
+ const promptResult = await renderTaskPrompt(manifest, input.step, task, input.agent, skillBlock, undefined, skillNames ?? []);
328
328
  let prompt = promptResult.full;
329
329
 
330
330
  // Inject deterministic pre-step output into prompt
@@ -340,6 +340,30 @@ export async function prepareTaskExecutionContext(
340
340
  content: `${prompt}\n`,
341
341
  producer: task.id,
342
342
  });
343
+ // SR-02 phase 1: per-section breakdown artifact (opt-in via
344
+ // PI_CREW_PROMPT_BREAKDOWN=1). Adds the SYSTEM-side pieces the prompt
345
+ // builder cannot see (agent definition, skills, pre-step output).
346
+ if (promptBreakdownEnabled()) {
347
+ const sections: Record<string, number> = {
348
+ "system.agentDefinition": input.agent?.systemPrompt?.length ?? 0,
349
+ ...(promptResult.sections ?? {}),
350
+ "dynamic.preStepOutput": preStepOutput?.length ?? 0,
351
+ };
352
+ writeArtifact(manifest.artifactsRoot, {
353
+ kind: "metadata",
354
+ relativePath: `metadata/${task.id}.prompt-breakdown.json`,
355
+ content: `${JSON.stringify(
356
+ Object.fromEntries(
357
+ Object.entries(sections)
358
+ .filter(([, chars]) => chars > 0)
359
+ .map(([name, chars]) => [name, { chars, estTokens: estimateTokens(chars) }]),
360
+ ),
361
+ null,
362
+ 2,
363
+ )}\n`,
364
+ producer: "prompt-breakdown",
365
+ });
366
+ }
343
367
 
344
368
  const collectedJsonEvents: Record<string, unknown>[] | undefined = collectYieldEvents ? [] : undefined;
345
369