pi-crew 0.9.57 → 0.9.59

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 (66) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/index.mjs +22609 -21373
  3. package/package.json +1 -1
  4. package/skills/distill-software/SKILL.md +7 -1
  5. package/src/agents/discover-agents.ts +6 -0
  6. package/src/config/config.ts +13 -2
  7. package/src/config/markers.ts +9 -1
  8. package/src/extension/action-suggestions.ts +1 -1
  9. package/src/extension/async-notifier.ts +13 -5
  10. package/src/extension/registration/context-builder.ts +5 -2
  11. package/src/extension/registration/crash-recovery-cache.ts +2 -2
  12. package/src/extension/registration/lazy-configurers.ts +1 -1
  13. package/src/extension/registration/lifecycle-handlers.ts +25 -10
  14. package/src/extension/registration/observability.ts +8 -4
  15. package/src/extension/registration/registration-types.ts +2 -2
  16. package/src/extension/registration/runtime-cleanup.ts +10 -2
  17. package/src/extension/registration/subagent-manager-setup.ts +9 -4
  18. package/src/extension/registration/subagent-tools.ts +15 -3
  19. package/src/extension/run-import.ts +19 -2
  20. package/src/extension/run-maintenance.ts +25 -9
  21. package/src/extension/session-summary.ts +5 -0
  22. package/src/extension/team-tool/destructive-gate.ts +12 -6
  23. package/src/extension/team-tool/health-monitor.ts +7 -5
  24. package/src/extension/team-tool/intent-policy.ts +9 -0
  25. package/src/extension/team-tool/lifecycle-actions.ts +27 -4
  26. package/src/extension/team-tool/run-deadline.ts +7 -2
  27. package/src/extension/team-tool/run.ts +23 -4
  28. package/src/extension/team-tool/status.ts +2 -2
  29. package/src/extension/team-tool.ts +126 -51
  30. package/src/runtime/background-runner.ts +36 -2
  31. package/src/runtime/delivery-coordinator.ts +24 -3
  32. package/src/runtime/foreground-watchdog.ts +2 -2
  33. package/src/runtime/model/model-fallback.ts +3 -1
  34. package/src/runtime/model/provider-extensions.ts +36 -0
  35. package/src/runtime/model/runtime-warmup.ts +41 -0
  36. package/src/runtime/peer-dep.ts +35 -8
  37. package/src/runtime/recovery/crash-recovery.ts +34 -4
  38. package/src/runtime/skill-instructions.ts +1 -1
  39. package/src/runtime/stale-reconciler.ts +11 -19
  40. package/src/runtime/subagent-manager.ts +15 -6
  41. package/src/runtime/task-packet.ts +26 -4
  42. package/src/runtime/task-runner/run-projection.ts +4 -1
  43. package/src/runtime/team-runner.ts +94 -72
  44. package/src/runtime/verification/completion-guard.ts +10 -1
  45. package/src/schema/team-tool-schema.ts +152 -38
  46. package/src/skills/validate.ts +28 -2
  47. package/src/state/atomic-write.ts +23 -12
  48. package/src/state/contracts.ts +1 -0
  49. package/src/state/coordination/locks.ts +20 -7
  50. package/src/state/coordination/mailbox.ts +20 -2
  51. package/src/state/gitignore-manager.ts +5 -1
  52. package/src/state/stores/artifact-store.ts +5 -5
  53. package/src/state/stores/run-cache.ts +9 -1
  54. package/src/state/stores/state-store.ts +64 -10
  55. package/src/ui/deploy-bundled-themes.ts +11 -0
  56. package/src/ui/powerbar-publisher.ts +31 -6
  57. package/src/ui/run-dashboard.ts +7 -1
  58. package/src/ui/syntax-highlight.ts +31 -12
  59. package/src/ui/widget/index.ts +9 -1
  60. package/src/ui/widget/widget-model.ts +1 -1
  61. package/src/ui/widget/widget-types.ts +3 -0
  62. package/src/utils/env-filter.ts +25 -11
  63. package/src/utils/paths.ts +20 -1
  64. package/src/utils/redaction.ts +22 -1
  65. package/src/utils/session-utils.ts +42 -19
  66. package/src/worktree/worktree-manager.ts +2 -2
@@ -1,11 +1,16 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
3
3
  import { isDisplayActiveRun } from "../runtime/process-status.ts";
4
+ import { extractSessionId } from "../utils/session-utils.ts";
4
5
  import { listRuns } from "./run-index.ts";
5
6
 
6
7
  export function notifyActiveRuns(ctx: ExtensionContext): void {
8
+ const sid = extractSessionId(ctx);
7
9
  const active = listRuns(ctx.cwd)
8
10
  .filter((run) => {
11
+ // Vector #11: never surface another session's runs in the active-runs
12
+ // toast — session B must not advertise session A's in-flight runs.
13
+ if (sid && run.ownerSessionId && run.ownerSessionId !== sid) return false;
9
14
  if (run.status !== "queued" && run.status !== "planning" && run.status !== "running") return false;
10
15
  // Use the same display filter as the widget/powerbar — runs without
11
16
  // real agent evidence (e.g. integration test fixtures) must not appear.
@@ -11,10 +11,11 @@
11
11
  *
12
12
  * Rules (in order):
13
13
  * 1. Non-team / non-destructive actions → allowed (caller pre-filters, but safe).
14
- * 2. `cleanup` with `dryRun=true` → ALWAYS allowed (a preview writes nothing,
15
- * so gating it would block users from previewing what cleanup would do —
16
- * this was a UX bug: team action=cleanup dryRun=true returned "requires
17
- * confirm=true" even though it changed no files).
14
+ * 2. `cleanup`/`prune` with `dryRun=true` → ALWAYS allowed (a preview writes
15
+ * nothing, so gating it would block users from previewing what the action
16
+ * would do — this was a UX bug: team action=cleanup dryRun=true returned
17
+ * "requires confirm=true" even though it changed no files; prune gained a
18
+ * true dryRun preview in the prune-honor-dryRun fix).
18
19
  * 3. `confirm=true` on the input → allowed (explicit user intent).
19
20
  * 4. `delete` with `force=true` → allowed (force bypasses reference checks).
20
21
  * 5. Otherwise → blocked with a reason telling the user what to pass.
@@ -43,8 +44,13 @@ export interface TeamToolInputLike {
43
44
  */
44
45
  export function shouldBlockDestructiveTeamAction(action: string | undefined, input: TeamToolInputLike): string | undefined {
45
46
  if (!action || !DESTRUCTIVE_TEAM_ACTIONS.has(action)) return undefined;
46
- // dryRun cleanup is a PREVIEW (no writes) — never needs confirm.
47
- if (action === "cleanup" && input.dryRun === true) return undefined;
47
+ // dryRun is a PREVIEW (no writes) — never needs confirm. Only allow the
48
+ // bypass for actions whose handlers actually honor dryRun as non-destructive:
49
+ // - cleanup (handleProjectCleanup/handleUserCleanup branch on dryRun)
50
+ // - prune (pruneFinishedRuns skips deletion/worktree-cleanup/audit when
51
+ // dryRun; handlePrune bypasses confirm)
52
+ // forget/delete have no dryRun path (they always mutate), so they stay gated.
53
+ if ((action === "cleanup" || action === "prune") && input.dryRun === true) return undefined;
48
54
  if (input.confirm === true) return undefined;
49
55
  const forceBypassesReferenceChecks = action === "delete" && input.force === true;
50
56
  if (forceBypassesReferenceChecks) return undefined;
@@ -7,6 +7,7 @@ import * as path from "node:path";
7
7
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
8
8
  import { hasStaleAsyncProcess, isActiveRunStatus, isLikelyOrphanedActiveRun } from "../../runtime/process-status.ts";
9
9
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
10
+ import { loadTasksWithRecovery } from "../../state/stores/state-store.ts";
10
11
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
11
12
  import { listRuns } from "../run-index.ts";
12
13
  import type { PiTeamsToolResult } from "../tool-result.ts";
@@ -63,12 +64,13 @@ export const STUCK_TASK_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
63
64
  * Read tasks.json for a given run's stateRoot. Returns empty array on error.
64
65
  */
65
66
  function readRunTasks(stateRoot: string): TeamTaskState[] {
67
+ // NEW-R2: use corruption recovery instead of bare JSON.parse. A corrupt
68
+ // tasks.json previously returned [] → stuck/crashed workers invisible to the
69
+ // health monitor. Now: corrupt → quarantine + reconstruct from events.jsonl.
66
70
  const tasksPath = path.join(stateRoot, "tasks.json");
67
- try {
68
- return JSON.parse(fs.readFileSync(tasksPath, "utf-8")) as TeamTaskState[];
69
- } catch {
70
- return [];
71
- }
71
+ const eventsPath = path.join(stateRoot, "events.jsonl");
72
+ const runId = path.basename(stateRoot);
73
+ return loadTasksWithRecovery(tasksPath, eventsPath, runId);
72
74
  }
73
75
 
74
76
  /**
@@ -31,6 +31,15 @@ export function enforceDestructiveIntent(
31
31
  config: PiTeamsConfig | undefined,
32
32
  ): PiTeamsToolResult | undefined {
33
33
  if (!shouldRequireIntent(config)) return undefined;
34
+ // DI-4: The cleanup bypass for non-forced cleanup is INTENTIONAL — do NOT
35
+ // "fix" this into a regression. Non-forced cleanup (`cleanup` without
36
+ // `force: true`) only removes STALE artifacts and preserves dirty worktrees
37
+ // (see cleanupRunWorktrees: dirty worktrees are snapshotted + preserved
38
+ // unless force=true, and project-level cleanup only removes the AGENTS.md
39
+ // guidance block without touching `.crew/`). The destructive path (forced
40
+ // cleanup) still requires intent. Requiring intent for the non-forced,
41
+ // stale-only path would break routine maintenance flows (e.g. periodic
42
+ // stale-artifact reaping) that must not need a per-call intent.
34
43
  if (action === "cleanup" && params.force !== true) return undefined;
35
44
  if (intentFromConfig(params.config)) return undefined;
36
45
  const label = DESTRUCTIVE_ACTION_LABELS[action];
@@ -10,6 +10,7 @@ import { loadRunManifestById } from "../../state/stores/state-store.ts";
10
10
  import { logInternalError } from "../../utils/internal-error.ts";
11
11
  import { projectCrewRoot, userCrewRoot, userPiRoot } from "../../utils/paths.ts";
12
12
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
13
+ import { sleep } from "../../utils/sleep.ts";
13
14
  import { cleanupRunWorktrees } from "../../worktree/cleanup.ts";
14
15
  import { listImportedRuns } from "../import-index.ts";
15
16
  import { exportRunBundle } from "../run-export.ts";
@@ -146,7 +147,12 @@ export async function handleExport(params: TeamToolParamsValue, ctx: TeamContext
146
147
  export async function handlePrune(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
147
148
  const intentError = enforceDestructiveIntent("prune", params, ctx.config);
148
149
  if (intentError) return intentError;
149
- if (!params.confirm)
150
+ // dryRun:true is a non-destructive preview (no deletion) and is always allowed
151
+ // without confirm — mirrors handleCleanup. Without this, a caller requesting a
152
+ // dry-run preview with confirm:false was forced to set confirm:true, and the
153
+ // handler then ignored dryRun entirely and DELETED runs (data-safety bug).
154
+ const dryRun = params.dryRun === true;
155
+ if (!params.confirm && !dryRun)
150
156
  return result(
151
157
  paramRequired("prune", "confirm: true", "{ action: 'prune', confirm: true }"),
152
158
  { action: "prune", status: "error" },
@@ -158,6 +164,7 @@ export async function handlePrune(params: TeamToolParamsValue, ctx: TeamContext)
158
164
  const pruned = pruneFinishedRuns(ctx.cwd, keep, {
159
165
  intent,
160
166
  signal: ctx.signal,
167
+ dryRun,
161
168
  });
162
169
  // Fire hook once with all removed run IDs for batch visibility
163
170
  if (pruned.removed.length > 0) {
@@ -176,11 +183,13 @@ export async function handlePrune(params: TeamToolParamsValue, ctx: TeamContext)
176
183
  }
177
184
  return result(
178
185
  [
179
- `Pruned finished pi-crew runs.`,
186
+ `${dryRun ? "Preview: " : ""}Pruned finished pi-crew runs.`,
180
187
  `Kept: ${pruned.kept.length}`,
181
- `Removed: ${pruned.removed.length}`,
188
+ `${dryRun ? "Would remove" : "Removed"}: ${pruned.removed.length}`,
182
189
  ...(pruned.auditPath ? [`Audit: ${pruned.auditPath}`] : []),
183
- ...(pruned.removed.length ? ["Removed runs:", ...pruned.removed.map((runId) => `- ${runId}`)] : []),
190
+ ...(pruned.removed.length
191
+ ? [`${dryRun ? "Would remove" : "Removed"} runs:`, ...pruned.removed.map((runId) => `- ${runId}`)]
192
+ : []),
184
193
  ].join("\n"),
185
194
  { action: "prune", status: "ok", intent },
186
195
  );
@@ -282,6 +291,20 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
282
291
  const resolvedArtifactsRoot = resolveRealContainedPath(crewRoot, loaded.manifest.artifactsRoot);
283
292
  fs.rmSync(resolvedStateRoot, { recursive: true, force: true });
284
293
  fs.rmSync(resolvedArtifactsRoot, { recursive: true, force: true });
294
+ // FORGET-RESURRECT (observed 2026-08-04): the SIGTERM above is fire-and-forget
295
+ // (killProcessPid returns void; no await on process-group exit). A still-alive
296
+ // background runner/worker can land one final heartbeat write (15s interval)
297
+ // AFTER the first rmSync — atomicWriteFile mkdirSync(recursive) RE-CREATES the
298
+ // state dir, resurrecting heartbeat.json in a dir we just forgot. Drain briefly
299
+ // (grace period for in-flight writes) then re-remove any resurrected residue.
300
+ // A 250ms window catches the common in-flight write race; a still-running
301
+ // (un-killable) process could resurrect later, but that is a separate leak
302
+ // (zombie process) that the stale-reconciler re-prunes.
303
+ if (fs.existsSync(resolvedStateRoot) || fs.existsSync(resolvedArtifactsRoot)) {
304
+ await sleep(250);
305
+ fs.rmSync(resolvedStateRoot, { recursive: true, force: true });
306
+ fs.rmSync(resolvedArtifactsRoot, { recursive: true, force: true });
307
+ }
285
308
  return result(
286
309
  [
287
310
  `Forgot run ${loaded.manifest.runId}.`,
@@ -13,6 +13,8 @@ export interface RunDeadline {
13
13
  deadlineMs: number;
14
14
  /** Underlying controller — callers may link additional parent signals. */
15
15
  controller: AbortController;
16
+ /** RC-02: the deadline timer handle — callers clear it on normal completion. */
17
+ timer: NodeJS.Timeout | undefined;
16
18
  }
17
19
 
18
20
  /** Fallback deadline when no config or param override is available: 1 hour. */
@@ -58,9 +60,12 @@ export function resolveRunDeadline<T extends object>(
58
60
  else ctx.signal.addEventListener("abort", () => controller.abort(), { once: true });
59
61
  }
60
62
  // Arm the deadline timer (unref'd so it never blocks process exit).
63
+ // RC-02: expose the timer so callers can clearTimeout on normal completion —
64
+ // otherwise every run leaves a dangling 1h timer retaining ctx/params in closure.
65
+ let timer: NodeJS.Timeout | undefined;
61
66
  if (deadlineMs > 0) {
62
- const timer = setTimeout(() => controller.abort(), deadlineMs);
67
+ timer = setTimeout(() => controller.abort(), deadlineMs);
63
68
  timer.unref?.();
64
69
  }
65
- return { signal: controller.signal, deadlineMs, controller };
70
+ return { signal: controller.signal, deadlineMs, controller, timer };
66
71
  }
@@ -32,7 +32,7 @@ async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Pro
32
32
 
33
33
  import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
34
34
  import { resolveCrewRuntime, runtimeResolutionState } from "../../runtime/model/runtime-resolver.ts";
35
- import { appendEventAsync, readEvents } from "../../state/event-log/event-log.ts";
35
+ import { appendEventAsync, readEventsCursor } from "../../state/event-log/event-log.ts";
36
36
  import type { RunMetrics } from "../../state/stores/run-metrics.ts";
37
37
  import type { RuntimeResolutionState, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
38
38
 
@@ -110,7 +110,7 @@ function scheduleBackgroundEarlyExitGuard(cwd: string, runId: string, pid: numbe
110
110
  if (!loaded || !isActiveRunStatus(loaded.manifest.status)) return;
111
111
  if (hasAsyncStartMarker(loaded.manifest)) return;
112
112
  if (
113
- readEvents(loaded.manifest.eventsPath).some(
113
+ readEventsCursor(loaded.manifest.eventsPath).events.some(
114
114
  (event) => event.type === "async.started" || event.type === "async.completed" || event.type === "async.failed",
115
115
  )
116
116
  )
@@ -704,6 +704,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
704
704
  );
705
705
  } finally {
706
706
  unregisterActiveRun(dwfManifest.runId);
707
+ clearTimeout(dwfDeadline.timer); // RC-02
707
708
  }
708
709
  }
709
710
 
@@ -863,6 +864,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
863
864
  // Wait for the async run to complete and return actual results.
864
865
  try {
865
866
  const completed = await waitForRun(updatedManifest.runId, resolvedCtx.cwd, { timeoutMs: asyncDeadline.deadlineMs });
867
+ clearTimeout(asyncDeadline.timer); // RC-02
866
868
  return formatRunResult(completed.manifest, {
867
869
  tasks: completed.tasks,
868
870
  metrics: collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId),
@@ -943,12 +945,22 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
943
945
  // CORE-8: unified deadline — resolves params > config > 1h default.
944
946
  const fgDeadline = resolveRunDeadline(ctx, params, executedConfig);
945
947
  ctx.onRunStarted?.(updatedManifest.runId);
948
+ const fgSignal = fgDeadline.signal;
949
+ let fgAbortListener: (() => void) | undefined;
950
+ let fgCallbackSignal: AbortSignal | undefined;
946
951
  ctx.startForegroundRun(async (signal) => {
947
952
  // Link the foreground-run callback signal to the deadline controller
948
953
  // so cancel-via-abortForegroundRun propagates to executeTeamRun.
949
- if (signal && signal !== fgDeadline.signal) {
954
+ fgCallbackSignal = signal;
955
+ if (signal && signal !== fgSignal) {
950
956
  if (signal.aborted) fgDeadline.controller.abort();
951
- else signal.addEventListener("abort", () => fgDeadline.controller.abort(), { once: true });
957
+ else {
958
+ // RC-03: keep the ref so we can removeEventListener on completion (the
959
+ // {once:true} alone leaks on the success path — the listener stays
960
+ // attached to the long-lived callback signal if the deadline never fires).
961
+ fgAbortListener = () => fgDeadline.controller.abort();
962
+ signal.addEventListener("abort", fgAbortListener, { once: true });
963
+ }
952
964
  }
953
965
  try {
954
966
  await executeTeamRun({
@@ -979,6 +991,11 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
979
991
  });
980
992
  } finally {
981
993
  unregisterActiveRun(updatedManifest.runId);
994
+ // RC-02/03: stop the deadline timer once the run's executeTeamRun completes
995
+ // (success or error). clearTimeout is idempotent — safe if already fired.
996
+ clearTimeout(fgDeadline.timer);
997
+ // Detach the abort listener from the callback signal (RC-03).
998
+ if (fgCallbackSignal && fgAbortListener) fgCallbackSignal.removeEventListener("abort", fgAbortListener);
982
999
  }
983
1000
  }, updatedManifest.runId);
984
1001
 
@@ -1047,6 +1064,8 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
1047
1064
  });
1048
1065
  } finally {
1049
1066
  unregisterActiveRun(updatedManifest.runId);
1067
+ // RC-02: clear the inline-path deadline timer (idempotent).
1068
+ clearTimeout(inlineDeadline.timer);
1050
1069
  }
1051
1070
  return formatRunResult(executed.manifest, {
1052
1071
  tasks: executed.tasks,
@@ -9,7 +9,7 @@ import { formatTaskGraphLines, waitingReason } from "../../runtime/task-display.
9
9
  import { verifyTaskCompletion } from "../../runtime/verification/completion-guard.ts";
10
10
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
11
11
  import { readDeliveryState, readMailbox } from "../../state/coordination/mailbox.ts";
12
- import { appendEvent, readEvents } from "../../state/event-log/event-log.ts";
12
+ import { appendEvent, readEventsCursor } from "../../state/event-log/event-log.ts";
13
13
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
14
14
  import { aggregateUsage, formatCost, formatUsage } from "../../state/usage.ts";
15
15
  import { formatDuration } from "../../ui/format-helpers.ts";
@@ -64,7 +64,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
64
64
  const counts = new Map<string, number>();
65
65
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
66
66
  const phaseProgress = computePhaseProgress(tasks);
67
- const allEvents = readEvents(manifest.eventsPath);
67
+ const { events: allEvents } = readEventsCursor(manifest.eventsPath);
68
68
  const events = allEvents.slice(-8);
69
69
  // P1-8: pre-build the ack-timeout requestId set once (was O(events × messages)
70
70
  // via allEvents.some() inside the mailbox loop).
@@ -185,6 +185,51 @@ function artifactKey(artifact: ArtifactDescriptor): string {
185
185
  return `${artifact.kind}:${artifact.path}`;
186
186
  }
187
187
 
188
+ /** Optional numeric params that accept the empty-string unset marker AND
189
+ * stringified numbers in their schema (Union with Literal("") + a numeric-string
190
+ * pattern branch). pi-ai's tool-argument coercion sometimes stringifies a
191
+ * numeric value (e.g. interval:0 → "0") when the schema Union has a
192
+ * string-literal branch, so the schema accepts the string form (Value.Check
193
+ * passes) but handlers expect real numbers (TeamToolParamsValue types them as
194
+ * number). This converts the string form back to a number — and treats "" as
195
+ * unset (deleted) — before any domain router reads the param. */
196
+ const LOOSE_NUMERIC_PARAM_KEYS = ["interval", "budgetWarning", "budgetAbort", "tokenBudget", "replyDeadline"] as const;
197
+
198
+ function normalizeLooseNumericFields(params: TeamToolParamsValue): TeamToolParamsValue {
199
+ let mutated = false;
200
+ const out: Record<string, unknown> = { ...params };
201
+ for (const key of LOOSE_NUMERIC_PARAM_KEYS) {
202
+ const v = out[key];
203
+ if (v === undefined || v === null) continue;
204
+ if (v === "") {
205
+ delete out[key];
206
+ mutated = true;
207
+ continue;
208
+ }
209
+ if (typeof v === "string") {
210
+ const n = Number(v);
211
+ if (Number.isFinite(n)) {
212
+ out[key] = n;
213
+ mutated = true;
214
+ } else {
215
+ delete out[key];
216
+ mutated = true;
217
+ }
218
+ }
219
+ }
220
+ // `once` is Union([Boolean, String, Number]); normalize its string form too.
221
+ if (typeof out.once === "string") {
222
+ if (out.once === "false") out.once = false;
223
+ else if (out.once === "true") out.once = true;
224
+ else {
225
+ const n = Number(out.once);
226
+ if (Number.isFinite(n)) out.once = n;
227
+ }
228
+ mutated = true;
229
+ }
230
+ return mutated ? (out as TeamToolParamsValue) : params;
231
+ }
232
+
188
233
  async function recoverCheckpointedTasks(
189
234
  manifest: TeamRunManifest,
190
235
  tasks: TeamTaskState[],
@@ -296,7 +341,14 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
296
341
  const workflow =
297
342
  direct?.workflow ?? allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.workflow);
298
343
  if (!workflow) return result(`Workflow '${loaded.manifest.workflow}' not found.`, { action: "resume", status: "error" }, true);
299
- return await withRunLock(loaded.manifest, async () => {
344
+ // LOCK-2 (Round 2): Lock held only for recovery + reset (the read-modify-write
345
+ // critical section). executeTeamRun runs OUTSIDE the lock — it uses
346
+ // team-runner's own per-operation withRunLock calls (mergeUnitResult,
347
+ // handleFailedTask merge, finalizeRun), mirroring handleRun (which never wraps
348
+ // executeTeamRun in an outer lock). Holding the resume lock across the
349
+ // minutes-long executeTeamRun would let any process steal the lock after
350
+ // DEFAULT_LOCKS.staleMs (30s) — see defaults.ts.
351
+ const decision = await withRunLock(loaded.manifest, async () => {
300
352
  // R2: re-read inside the lock so recovery + resetTasks reflect committed
301
353
  // state, not the pre-lock snapshot. Between the pre-lock load and lock
302
354
  // acquisition a task may have been cancelled (stale-reconciler) or
@@ -354,23 +406,26 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
354
406
  message: blocked.summary,
355
407
  data: { runtime, action: "resume" },
356
408
  });
357
- return result(
358
- [
359
- `Blocked resume for pi-crew run ${blocked.runId}: real subagent workers are disabled.`,
360
- `Runtime: ${runtime.kind} (requested ${runtime.requestedMode})`,
361
- runtime.reason ?? "Child worker execution is disabled.",
362
- "",
363
- "To resume effective subagents, remove executeWorkers=false / PI_CREW_EXECUTE_WORKERS=0 / PI_TEAMS_EXECUTE_WORKERS=0 or set runtime.mode=child-process.",
364
- "Use runtime.mode=scaffold only for explicit dry-run prompt/artifact generation.",
365
- ].join("\n"),
366
- {
367
- action: "resume",
368
- status: "error",
369
- runId: blocked.runId,
370
- artifactsRoot: blocked.artifactsRoot,
371
- },
372
- true,
373
- );
409
+ return {
410
+ kind: "blocked" as const,
411
+ payload: result(
412
+ [
413
+ `Blocked resume for pi-crew run ${blocked.runId}: real subagent workers are disabled.`,
414
+ `Runtime: ${runtime.kind} (requested ${runtime.requestedMode})`,
415
+ runtime.reason ?? "Child worker execution is disabled.",
416
+ "",
417
+ "To resume effective subagents, remove executeWorkers=false / PI_CREW_EXECUTE_WORKERS=0 / PI_TEAMS_EXECUTE_WORKERS=0 or set runtime.mode=child-process.",
418
+ "Use runtime.mode=scaffold only for explicit dry-run prompt/artifact generation.",
419
+ ].join("\n"),
420
+ {
421
+ action: "resume",
422
+ status: "error",
423
+ runId: blocked.runId,
424
+ artifactsRoot: blocked.artifactsRoot,
425
+ },
426
+ true,
427
+ ),
428
+ };
374
429
  }
375
430
  const resetTasks = recovered.tasks.map((task) =>
376
431
  task.status === "failed" || task.status === "cancelled" || task.status === "skipped" || task.status === "running"
@@ -413,42 +468,59 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
413
468
  });
414
469
  const executeWorkers = runtime.kind !== "scaffold";
415
470
  const resumeSkillOverride = normalizeSkillOverride(params.skill) ?? runtimeManifest.skillOverride;
416
- const executed = await executeTeamRun({
417
- manifest: runtimeManifest,
418
- tasks: resetTasks,
419
- team,
420
- workflow,
421
- agents,
471
+
472
+ return {
473
+ kind: "execute" as const,
474
+ runtimeManifest,
475
+ resetTasks,
422
476
  executeWorkers,
423
- limits: executedConfig.limits,
477
+ resumeSkillOverride,
424
478
  runtime,
425
- runtimeConfig: executedConfig.runtime,
426
- parentContext: buildParentContext(ctx),
427
- parentModel: ctx.model,
428
- modelRegistry: ctx.modelRegistry,
429
- modelOverride: params.model,
430
- skillOverride: resumeSkillOverride,
431
- signal: ctx.signal,
432
- reliability: executedConfig.reliability,
433
- metricRegistry: ctx.metricRegistry,
434
- workspaceId: ctx.sessionId ?? ctx.cwd,
435
- });
436
- return result(
437
- [
438
- `Resumed run ${executed.manifest.runId}.`,
439
- `Status: ${executed.manifest.status}`,
440
- `Tasks: ${executed.tasks.length}`,
441
- `Artifacts: ${executed.manifest.artifactsRoot}`,
442
- ].join("\n"),
443
- {
444
- action: "resume",
445
- status: executed.manifest.status === "failed" ? "error" : "ok",
446
- runId: executed.manifest.runId,
447
- artifactsRoot: executed.manifest.artifactsRoot,
448
- },
449
- executed.manifest.status === "failed",
450
- );
479
+ executedConfig,
480
+ };
481
+ });
482
+
483
+ // Lock is now RELEASED. executeTeamRun runs without holding the resume
484
+ // lock — it uses team-runner's own per-operation withRunLock calls
485
+ // (mergeUnitResult, handleFailedTask merge, finalizeRun), mirroring
486
+ // handleRun (which never wraps executeTeamRun in an outer lock).
487
+ if (decision.kind === "blocked") return decision.payload;
488
+
489
+ const executed = await executeTeamRun({
490
+ manifest: decision.runtimeManifest,
491
+ tasks: decision.resetTasks,
492
+ team,
493
+ workflow,
494
+ agents,
495
+ executeWorkers: decision.executeWorkers,
496
+ limits: decision.executedConfig.limits,
497
+ runtime: decision.runtime,
498
+ runtimeConfig: decision.executedConfig.runtime,
499
+ parentContext: buildParentContext(ctx),
500
+ parentModel: ctx.model,
501
+ modelRegistry: ctx.modelRegistry,
502
+ modelOverride: params.model,
503
+ skillOverride: decision.resumeSkillOverride,
504
+ signal: ctx.signal,
505
+ reliability: decision.executedConfig.reliability,
506
+ metricRegistry: ctx.metricRegistry,
507
+ workspaceId: ctx.sessionId ?? ctx.cwd,
451
508
  });
509
+ return result(
510
+ [
511
+ `Resumed run ${executed.manifest.runId}.`,
512
+ `Status: ${executed.manifest.status}`,
513
+ `Tasks: ${executed.tasks.length}`,
514
+ `Artifacts: ${executed.manifest.artifactsRoot}`,
515
+ ].join("\n"),
516
+ {
517
+ action: "resume",
518
+ status: executed.manifest.status === "failed" ? "error" : "ok",
519
+ runId: executed.manifest.runId,
520
+ artifactsRoot: executed.manifest.artifactsRoot,
521
+ },
522
+ executed.manifest.status === "failed",
523
+ );
452
524
  }
453
525
 
454
526
  export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
@@ -626,6 +698,9 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
626
698
  // a missing action defaulted to "list" at the facade but the router read
627
699
  // params.action=undefined → "Unhandled status-domain action: undefined".
628
700
  params = { ...params, action: params.action ?? "list" };
701
+ // Coerce stringified-numeric params (pi-ai coercion artifact) back to real
702
+ // numbers before domain routers read them. See normalizeLooseNumericFields.
703
+ params = normalizeLooseNumericFields(params);
629
704
  const action = params.action ?? "list";
630
705
  const domain = domainForAction(action);
631
706
  switch (domain) {
@@ -110,6 +110,38 @@ function argValue(name: string): string | undefined {
110
110
  return process.argv[index + 1];
111
111
  }
112
112
 
113
+ /**
114
+ * Signals the catch-all loop registers that do NOT terminate the background
115
+ * runner (SIGWINCH = terminal resize, SIGPIPE = closed pipe, SIGCONT etc.).
116
+ * Logging these as fatal `async.failed` poisons dead-run detection:
117
+ * async-notifier's isAsyncTerminalEvent treats async.failed as terminal, so a
118
+ * single benign signal (e.g. a terminal resize) would permanently disable
119
+ * markDeadAsyncRunIfNeeded for the run. Benign signals are logged as
120
+ * non-terminal `async.signal` instead.
121
+ */
122
+ export const BENIGN_SIGNALS = new Set([
123
+ "SIGWINCH",
124
+ "SIGPIPE",
125
+ "SIGCONT",
126
+ "SIGTSTP",
127
+ "SIGTTIN",
128
+ "SIGTTOU",
129
+ "SIGURG",
130
+ "SIGPROF",
131
+ "SIGVTALRM",
132
+ "SIGALRM",
133
+ "SIGIO",
134
+ "SIGPWR",
135
+ ]);
136
+
137
+ /**
138
+ * Classify a caught signal as terminal (`async.failed` — the process is
139
+ * dying) or benign (`async.signal` — the process keeps running).
140
+ */
141
+ export function signalEventType(sig: string): "async.signal" | "async.failed" {
142
+ return BENIGN_SIGNALS.has(sig) ? "async.signal" : "async.failed";
143
+ }
144
+
113
145
  /**
114
146
  * Fire-and-forget event log for signal handlers. Extracted to module level
115
147
  * (from inside main()) so the exported SIGINT handler installer (test seam)
@@ -119,10 +151,12 @@ function argValue(name: string): string | undefined {
119
151
  function signalLog(sig: string, eventsPath: string): void {
120
152
  const runId = argValue("--run-id");
121
153
  if (runId && eventsPath) {
154
+ const type = signalEventType(sig);
122
155
  appendEventFireAndForget(eventsPath, {
123
- type: "async.failed",
156
+ type,
124
157
  runId,
125
- message: `Background runner received ${sig} — exiting.`,
158
+ // Benign signals don't exit the runner — don't claim they do.
159
+ message: type === "async.failed" ? `Background runner received ${sig} — exiting.` : `Background runner received ${sig}.`,
126
160
  data: { signal: sig, pid: process.pid },
127
161
  });
128
162
  }
@@ -7,6 +7,11 @@ export interface PendingDelivery {
7
7
  timestamp: number;
8
8
  type: "result" | "notification" | "steer";
9
9
  generation?: number;
10
+ /** Pi session that owns the underlying run. Set at enqueue time; a delivery
11
+ * whose ownerSessionId differs from the currently active session is parked
12
+ * (not flushed) so queued results never leak into the wrong session after an
13
+ * in-process session switch (vector #12). Absent = ownerless/legacy (always flush). */
14
+ ownerSessionId?: string;
10
15
  }
11
16
 
12
17
  export interface DeliveryCoordinatorDeps {
@@ -28,6 +33,9 @@ export class DeliveryCoordinator {
28
33
  private readonly deps: DeliveryCoordinatorDeps;
29
34
  private ttlTimer: ReturnType<typeof setInterval> | undefined;
30
35
  private timerStarted = false;
36
+ /** The session id passed to the most recent activate(); used by flushQueuedResults
37
+ * to park deliveries owned by a different session (vector #12). */
38
+ private activeSessionId?: string;
31
39
 
32
40
  constructor(deps: DeliveryCoordinatorDeps) {
33
41
  this.deps = deps;
@@ -35,6 +43,7 @@ export class DeliveryCoordinator {
35
43
 
36
44
  activate(sessionId: string): void {
37
45
  this.active = true;
46
+ this.activeSessionId = sessionId;
38
47
  this.flushQueuedResults();
39
48
  }
40
49
 
@@ -51,7 +60,7 @@ export class DeliveryCoordinator {
51
60
  return this.pending.length;
52
61
  }
53
62
 
54
- deliverResult(runId: string, result: unknown): void {
63
+ deliverResult(runId: string, result: unknown, ownerSessionId?: string): void {
55
64
  if (this.active && this.deps.emit) {
56
65
  try {
57
66
  this.deps.emit("pi-crew:run-result", result);
@@ -66,10 +75,11 @@ export class DeliveryCoordinator {
66
75
  payload: result,
67
76
  timestamp: Date.now(),
68
77
  type: "result",
78
+ ownerSessionId,
69
79
  });
70
80
  }
71
81
 
72
- deliverNotification(notification: NotificationDescriptor): void {
82
+ deliverNotification(notification: NotificationDescriptor, ownerSessionId?: string): void {
73
83
  let delivered = false;
74
84
  if (this.active && this.deps.sendFollowUp) {
75
85
  try {
@@ -95,10 +105,11 @@ export class DeliveryCoordinator {
95
105
  payload: notification,
96
106
  timestamp: Date.now(),
97
107
  type: "notification",
108
+ ownerSessionId,
98
109
  });
99
110
  }
100
111
 
101
- deliverSteer(runId: string, message: string): void {
112
+ deliverSteer(runId: string, message: string, ownerSessionId?: string): void {
102
113
  if (this.active && this.deps.sendWakeUp) {
103
114
  try {
104
115
  this.deps.sendWakeUp(message);
@@ -113,6 +124,7 @@ export class DeliveryCoordinator {
113
124
  payload: message,
114
125
  timestamp: Date.now(),
115
126
  type: "steer",
127
+ ownerSessionId,
116
128
  });
117
129
  }
118
130
 
@@ -127,6 +139,15 @@ export class DeliveryCoordinator {
127
139
  try {
128
140
  const retryLater: PendingDelivery[] = [];
129
141
  for (const delivery of batch) {
142
+ // Vector #12: park deliveries owned by a session other than the one
143
+ // currently active. They must never flush into the wrong session after
144
+ // an in-process session switch. Re-queued as-is (generation preserved)
145
+ // so the existing stale-steer check still applies when the owning
146
+ // session becomes active again.
147
+ if (delivery.ownerSessionId && this.activeSessionId && delivery.ownerSessionId !== this.activeSessionId) {
148
+ retryLater.push(delivery);
149
+ continue;
150
+ }
130
151
  if (delivery.type === "steer" && delivery.generation !== undefined && delivery.generation !== this.generation) {
131
152
  logInternalError("delivery-coordinator.flush.stale", undefined, `runId=${delivery.runId} type=${delivery.type}`);
132
153
  continue;
@@ -41,8 +41,8 @@ export function stopWatchdog(runId: string): void {
41
41
  }
42
42
  }
43
43
 
44
- /** @internal Stop all active watchdogs. Called on session shutdown. */
45
- function stopAllWatchdogs(): void {
44
+ /** @internal Stop all active watchdogs. Called on session shutdown (RC-01). */
45
+ export function stopAllWatchdogs(): void {
46
46
  for (const [runId, timer] of activeWatchdogs) {
47
47
  clearTimeout(timer);
48
48
  }