pi-crew 0.11.24 → 0.11.26

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 (112) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -2
  3. package/agents/designer.md +23 -0
  4. package/agents/orchestrator.md +17 -0
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +568 -559
  7. package/{workflows/chain.workflow.md → docs/chain-workflow.md} +8 -0
  8. package/docs/dynamic-workflows.md +23 -0
  9. package/docs/publishing.md +34 -5
  10. package/docs/troubleshooting.md +50 -2
  11. package/docs/usage.md +7 -2
  12. package/install.mjs +2 -1
  13. package/package.json +4 -2
  14. package/skills/read-only-explorer/SKILL.md +15 -5
  15. package/skills/real-test-pi-crew/SKILL.md +7 -7
  16. package/src/config/config-validation.ts +58 -0
  17. package/src/config/config.ts +90 -5
  18. package/src/config/env-vars.ts +14 -7
  19. package/src/config/role-tools.ts +7 -5
  20. package/src/config/types.ts +4 -1
  21. package/src/extension/crew-cleanup.ts +6 -0
  22. package/src/extension/project-init.ts +19 -1
  23. package/src/extension/register.ts +68 -30
  24. package/src/extension/registration/artifact-cleanup.ts +52 -11
  25. package/src/extension/registration/commands/manage.ts +9 -2
  26. package/src/extension/registration/hook-registration.ts +3 -0
  27. package/src/extension/registration/lazy-configurers.ts +2 -0
  28. package/src/extension/registration/lifecycle-handlers.ts +79 -18
  29. package/src/extension/registration/render-loop.ts +27 -2
  30. package/src/extension/registration/tool-loop-guard.ts +5 -3
  31. package/src/extension/registration/verify-gate-tool.ts +7 -3
  32. package/src/extension/team-tool/api/read.ts +12 -3
  33. package/src/extension/team-tool/cancel.ts +11 -0
  34. package/src/extension/team-tool/config-patch.ts +56 -11
  35. package/src/extension/team-tool/destructive-gate.ts +72 -12
  36. package/src/extension/team-tool/dispatch/manage.ts +9 -1
  37. package/src/extension/team-tool/handle-schedule.ts +38 -4
  38. package/src/extension/team-tool/handle-settings.ts +76 -6
  39. package/src/extension/team-tool/health-monitor.ts +14 -2
  40. package/src/extension/team-tool/lifecycle-actions.ts +28 -10
  41. package/src/extension/team-tool/respond.ts +12 -5
  42. package/src/extension/team-tool/run-deadline.ts +29 -2
  43. package/src/extension/team-tool/run.ts +11 -0
  44. package/src/extension/team-tool/status.ts +64 -8
  45. package/src/extension/team-tool/workflow-manage.ts +5 -1
  46. package/src/extension/team-tool.ts +3 -0
  47. package/src/i18n.ts +18 -14
  48. package/src/prompt/inbox-poll.ts +14 -2
  49. package/src/prompt/message-tool.ts +1 -1
  50. package/src/prompt/prompt-runtime.ts +25 -3
  51. package/src/prompt/scratchpad-lifecycle.ts +14 -13
  52. package/src/prompt/surface-worker.ts +35 -9
  53. package/src/runtime/broker/crew-broker.ts +113 -111
  54. package/src/runtime/broker/delegate/delegate-abort.ts +118 -0
  55. package/src/runtime/broker/delegate/delegate-event.ts +3 -1
  56. package/src/runtime/broker/delegate/delegate-finalize.ts +179 -0
  57. package/src/runtime/broker/protocol/wait-resolve.ts +71 -5
  58. package/src/runtime/budget-enforcement.ts +26 -6
  59. package/src/runtime/chain-parser.ts +5 -3
  60. package/src/runtime/chain-runner.ts +41 -22
  61. package/src/runtime/child-pi/mock-fixtures.ts +12 -5
  62. package/src/runtime/compaction/tool-output-pruner.ts +8 -3
  63. package/src/runtime/delegate-spawn.ts +61 -8
  64. package/src/runtime/dispatch-batch.ts +10 -4
  65. package/src/runtime/effectiveness.ts +31 -3
  66. package/src/runtime/goal-workflow/plan-templates.ts +2 -2
  67. package/src/runtime/live-session/live-agent-control.ts +89 -6
  68. package/src/runtime/live-session/live-agent-manager.ts +94 -10
  69. package/src/runtime/live-session/live-session-runtime.ts +9 -2
  70. package/src/runtime/model/model-fallback.ts +6 -9
  71. package/src/runtime/model/pi-args.ts +3 -3
  72. package/src/runtime/model/runtime-policy.ts +4 -2
  73. package/src/runtime/model/runtime-resolver.ts +6 -2
  74. package/src/runtime/process/orphan-reaper.ts +143 -0
  75. package/src/runtime/process-status.ts +38 -16
  76. package/src/runtime/resilient-edit.ts +170 -93
  77. package/src/runtime/role-permission.ts +3 -1
  78. package/src/runtime/run-tracker.ts +9 -1
  79. package/src/runtime/sanitize/worker-text.ts +98 -0
  80. package/src/runtime/scheduling/run-coalesced-task-group.ts +82 -28
  81. package/src/runtime/spawn-policy.ts +190 -8
  82. package/src/runtime/stale-reconciler.ts +36 -1
  83. package/src/runtime/subagent-manager.ts +29 -0
  84. package/src/runtime/surface/herdr-provider.ts +31 -1
  85. package/src/runtime/task-output-context.ts +58 -11
  86. package/src/runtime/task-runner/post-execution.ts +150 -0
  87. package/src/runtime/task-runner/prompt-builder.ts +41 -8
  88. package/src/runtime/task-runner.ts +16 -3
  89. package/src/runtime/verification/classifier-pre-gate.ts +27 -6
  90. package/src/runtime/verification/verify-gate.ts +153 -30
  91. package/src/state/atomic-write.ts +73 -4
  92. package/src/state/contracts.ts +7 -0
  93. package/src/state/coordination/mailbox.ts +40 -11
  94. package/src/state/event-log/event-log-rotation.ts +28 -6
  95. package/src/state/event-log/poison-marker.ts +16 -1
  96. package/src/state/stores/artifact-store.ts +181 -6
  97. package/src/state/stores/manifest-io.ts +126 -8
  98. package/src/state/stores/state-store.ts +30 -31
  99. package/src/state/types.ts +13 -0
  100. package/src/state/usage.ts +41 -0
  101. package/src/ui/run-dashboard.ts +3 -3
  102. package/src/ui/settings-overlay.ts +19 -1
  103. package/src/utils/internal-error.ts +5 -1
  104. package/src/utils/timer-limits.ts +30 -0
  105. package/src/utils/token-counter.ts +47 -0
  106. package/src/workflows/discover-workflows.ts +9 -1
  107. package/src/worktree/worktree-manager.ts +128 -18
  108. package/workflows/default.workflow.md +1 -1
  109. package/workflows/fast-fix.workflow.md +1 -1
  110. package/workflows/plan-execute.workflow.md +1 -1
  111. package/workflows/review.workflow.md +1 -1
  112. package/workflows/strict-fast-fix.workflow.md +1 -1
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { removeGuidance } from "../../config/markers.ts";
4
4
  import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
5
+ import { abortRunDelegates } from "../../runtime/broker/delegate/delegate-abort.ts";
5
6
  import { killProcessPid } from "../../runtime/child-pi/child-pi.ts";
6
7
  import { terminateLiveAgentsForRun } from "../../runtime/live-session/live-agent-manager.ts";
7
8
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
@@ -315,6 +316,12 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
315
316
  // Without this, forgetting a running run orphans those processes (separate
316
317
  // process groups via setsid) which keep executing and consuming tokens after
317
318
  // their state directory is gone. Mirrors handleCancel's cleanup.
319
+ // M4-R1(c) (fix-wave 6 residual, M3-A / R43-N3): the in-flight DELEGATE
320
+ // grandchildren too — they live in the broker's abort registry (own
321
+ // process-local lifetime) and would otherwise keep spending against a state
322
+ // directory this call is about to delete. Must run before the fs.rmSync
323
+ // calls below. Sync + never throws (0 when no broker aborter is installed).
324
+ abortRunDelegates(loaded.manifest.runId, "run-forgotten");
318
325
  await terminateLiveAgentsForRun(loaded.manifest.runId, "cancelled", appendEvent, loaded.manifest.eventsPath);
319
326
  const asyncPid = loaded.manifest.async?.pid;
320
327
  if (asyncPid !== undefined && asyncPid > 0) {
@@ -529,23 +536,36 @@ function dryRunRemovedIds(guidancePath: string): string[] {
529
536
  * - NEVER removes user-authored agent files (`~/.pi/agent/agents/*.md`)
530
537
  * because pi-crew cannot tell which were user-created vs test-copied —
531
538
  * only the timestamped `.bak-*` backups (clearly pi-crew test junk).
532
- * - The state dir removal requires force=true by default is NOT required
533
- * (it's pi-crew's own runtime cache, regenerable), but config.json removal
534
- * IS gated on force=true (it may hold user-customized settings).
535
- * - dryRun=true previews without writing.
539
+ * - F13-B: the user state dir is NOT removed on `confirm` alone. It holds run
540
+ * history/artifacts for the whole user tier, so removal is gated on an
541
+ * explicit `force: true` — parity with the global config below and with
542
+ * project-scope `.crew/` removal (`force` gate). Without force the state dir
543
+ * is reported and preserved.
544
+ * - dryRun=true previews without writing (and always states the force gate).
536
545
  */
537
546
  function handleUserCleanup(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
538
547
  const dryRun = params.dryRun === true;
539
548
  const force = params.force === true;
540
549
  const lines: string[] = ["User-scope cleanup for pi-crew:"];
541
550
 
542
- // 1. pi-crew user state dir (~/.pi/agent/extensions/pi-crew/) — always safe
543
- // to remove (regenerable runtime cache: artifacts + state). This is the
544
- // bulk of the "user-level junk".
551
+ // 1. pi-crew user state dir (~/.pi/agent/extensions/pi-crew/) — run history +
552
+ // artifacts for the user tier. F13-B: force-gated (was confirm-only, which
553
+ // let a routine `cleanup scope=user` wipe the whole user state dir).
545
554
  const crewStateDir = userCrewRoot();
546
555
  lines.push(`pi-crew user state dir (${crewStateDir}):`);
547
556
  let crewStateBytes = 0;
548
- if (fs.existsSync(crewStateDir)) {
557
+ if (!fs.existsSync(crewStateDir)) {
558
+ lines.push(" - (not present — nothing to do)");
559
+ } else if (!force) {
560
+ try {
561
+ crewStateBytes = dirSize(crewStateDir);
562
+ } catch {
563
+ /* best-effort size */
564
+ }
565
+ lines.push(
566
+ ` - ${dryRun ? "would preserve" : "preserved"}: ${crewStateDir} (${formatBytes(crewStateBytes)}) — holds run history/artifacts; pass force: true to remove`,
567
+ );
568
+ } else {
549
569
  try {
550
570
  crewStateBytes = dirSize(crewStateDir);
551
571
  } catch {
@@ -559,8 +579,6 @@ function handleUserCleanup(params: TeamToolParamsValue, ctx: TeamContext): PiTea
559
579
  }
560
580
  }
561
581
  lines.push(` - ${dryRun ? "would remove" : "removed"}: ${crewStateDir} (${formatBytes(crewStateBytes)})`);
562
- } else {
563
- lines.push(" - (not present — nothing to do)");
564
582
  }
565
583
 
566
584
  // 2. pi-crew global config (~/.pi/agent/pi-crew.json) — gated on force=true
@@ -1,4 +1,5 @@
1
1
  import { renderAskAnswer } from "../../prompt/prompt-runtime.ts";
2
+ import { repointWaitState } from "../../runtime/broker/protocol/wait-resolve.ts";
2
3
  import { readCrewAgents, recordFromTask, saveCrewAgents } from "../../runtime/crew-agent-records.ts";
3
4
  import { isWaitingWorkerAlive } from "../../runtime/dispatch-batch.ts";
4
5
  import { checkProcessLiveness } from "../../runtime/process-status.ts";
@@ -243,12 +244,18 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
243
244
  let manifest = fresh.manifest;
244
245
  if (requeuedIds.length > 0) {
245
246
  saveRunTasks(fresh.manifest, updatedTasks);
246
- // WP-2 review round 1 (P3): clear manifest.waitState when its park was
247
- // requeued — mirror the broker's clearWaitState guard (taskId match),
248
- // else the stale pointer shields the run from staleness repair for
249
- // the full 24h TTL and leaves a stale marker in the manifest/UI.
247
+ // WP-2 review round 1 (P3) + M4-R1(b) (fix-wave 6 residual): when the slot
248
+ // pointed at a REQUEUED park, scoped-forget it — but never by blind
249
+ // clearing. Another task may still be parked and this single slot is the
250
+ // only thing shielding it (isWaitAnswerPending reads just this field), so
251
+ // re-point it at the surviving park (latest deadline wins, mirroring
252
+ // wait.resolve's repointedWaitState); it is cleared only when this was
253
+ // the last live park. The clear remains the correct outcome there — the
254
+ // pre-fix guard left the stale pointer shielding the run for the full
255
+ // 24h TTL with a stale marker in the manifest/UI.
250
256
  if (manifest.waitState && requeuedIds.includes(manifest.waitState.taskId)) {
251
- manifest = { ...manifest, waitState: undefined, updatedAt: new Date().toISOString() };
257
+ const waitState = repointWaitState(updatedTasks, manifest.waitState, (candidateId) => requeuedIds.includes(candidateId));
258
+ manifest = { ...manifest, waitState, updatedAt: new Date().toISOString() };
252
259
  saveRunManifest(manifest);
253
260
  }
254
261
  if (
@@ -2,6 +2,7 @@ import type { PiTeamsConfig } from "../../config/config.ts";
2
2
  import { loadConfig } from "../../config/config.ts";
3
3
  import { isSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts";
4
4
  import { safeAbort } from "../../utils/safe-abort.ts";
5
+ import { clampTimerDelayMs, MAX_TIMER_DELAY_MS } from "../../utils/timer-limits.ts";
5
6
  import type { TeamContext } from "./context.ts";
6
7
 
7
8
  /**
@@ -22,6 +23,28 @@ export interface RunDeadline {
22
23
  /** Fallback deadline when no config or param override is available: 1 hour. */
23
24
  export const DEFAULT_RUN_DEADLINE_MS = 3_600_000;
24
25
 
26
+ /**
27
+ * R37-F1: re-export the timer ceiling so callers/tests of this module can pin
28
+ * the clamp without reaching into utils/timer-limits.ts.
29
+ */
30
+ export { MAX_TIMER_DELAY_MS };
31
+
32
+ /**
33
+ * R37-F1: the resolved deadline MUST stay inside the `setTimeout` range.
34
+ *
35
+ * `params.timeoutMs` is read off the raw tool params (`additionalProperties:
36
+ * true`, so it is not schema-validated) and `maxRunMinutes * 60_000` comes from
37
+ * config. Either can exceed 2^31-1, and Node's timer then fires after **1 ms**
38
+ * — the run aborted at birth (deep-review round 37, runtime-proven).
39
+ *
40
+ * Exported so the `waitForRun` mirror (run-tracker.ts) and tests share ONE
41
+ * boundary. Returns undefined for absent/invalid values so the config →
42
+ * default chain still applies.
43
+ */
44
+ export function clampRunDeadlineMs(value: unknown): number | undefined {
45
+ return clampTimerDelayMs(value);
46
+ }
47
+
25
48
  /** Options for resolveRunDeadline. */
26
49
  export interface RunDeadlineOptions {
27
50
  /** "abort" (default): arm the deadline timer that ABORTS the controller —
@@ -69,10 +92,14 @@ export function resolveRunDeadline<T extends object>(
69
92
  config?: PiTeamsConfig,
70
93
  options?: RunDeadlineOptions,
71
94
  ): RunDeadline {
72
- const timeoutMs = (params as { timeoutMs?: number }).timeoutMs;
95
+ // R37-F1: BOTH inputs are caller-controlled and neither is schema-validated
96
+ // (params: additionalProperties; config: may be passed in directly).
97
+ // Validate (finite, > 0) and clamp to the timer ceiling before arming.
98
+ const timeoutMs = clampRunDeadlineMs((params as { timeoutMs?: number }).timeoutMs);
73
99
  const effectiveConfig = config ?? loadConfig(ctx.cwd).config;
74
100
  const maxRunMinutes = effectiveConfig?.limits?.maxRunMinutes;
75
- const deadlineMs = timeoutMs ?? (maxRunMinutes ? maxRunMinutes * 60_000 : DEFAULT_RUN_DEADLINE_MS);
101
+ const configDeadlineMs = clampRunDeadlineMs(typeof maxRunMinutes === "number" ? maxRunMinutes * 60_000 : undefined);
102
+ const deadlineMs = timeoutMs ?? configDeadlineMs ?? DEFAULT_RUN_DEADLINE_MS;
76
103
 
77
104
  const controller = new AbortController();
78
105
  // Propagate caller abort (ctx.signal) to the shared controller.
@@ -27,6 +27,7 @@ async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Pro
27
27
  import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
28
28
  import { resolveCrewRuntime, runtimeResolutionState } from "../../runtime/model/runtime-resolver.ts";
29
29
  import { captureRunModelContext, resolveParentModel } from "../../runtime/model/session-model.ts";
30
+ import { resolveHostVerifyGateSpec } from "../../runtime/verification/verify-gate.ts";
30
31
  import { appendEventAsync, readEventsCursor } from "../../state/event-log/event-log.ts";
31
32
  import type { RunMetrics } from "../../state/stores/run-metrics.ts";
32
33
  import type { RuntimeResolutionState, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
@@ -495,6 +496,14 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
495
496
  );
496
497
  }
497
498
  const executedConfig = effectiveRunConfig(loadedConfig.config, params.config);
499
+ // M1-7 (R47-1): pin the host-owned verify-gate ground truth AT DISPATCH —
500
+ // this host process read of the project manifests happens BEFORE any
501
+ // worker can run, so the snapshot is pristine. The run manifest carries it
502
+ // to the verifier pre-gate (task-runner), which re-hashes the task cwd
503
+ // against it and never re-derives checks from the worker-mutable
504
+ // task-cwd package.json. Undefined when the project has neither manifests
505
+ // nor a check preset — the gate then resolves no checks (fail-closed).
506
+ const hostVerifyGateSpec = await resolveHostVerifyGateSpec(resolvedCtx.cwd);
498
507
  const runtime = await resolveCrewRuntime(executedConfig);
499
508
  const runtimeResolution = runtimeResolutionState(runtime);
500
509
  // DEBUG: log what we received (gated to avoid stdout pollution in production)
@@ -514,6 +523,8 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
514
523
  ...updatedManifest,
515
524
  runtimeResolution,
516
525
  runConfig: executedConfig,
526
+ // M1-7 (R47-1): host-owned verify-gate spec (see hostVerifyGateSpec above).
527
+ ...(hostVerifyGateSpec ? { verifyGateSpec: hostVerifyGateSpec } : {}),
517
528
  // Background/async runs re-enter through background-runner in a detached
518
529
  // process with no ExtensionContext. Snapshot the model routing inputs so
519
530
  // they survive the hand-off instead of being rediscovered from models.json.
@@ -3,15 +3,17 @@ import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from
3
3
  import { extractCommandTrace } from "../../runtime/command-trace.ts";
4
4
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
5
5
  import { deadletterStatusLine } from "../../runtime/deadletter.ts";
6
- import { evaluateRunEffectiveness } from "../../runtime/effectiveness.ts";
6
+ import { delegateCompletedTaskIdsFromEvents, evaluateRunEffectiveness } from "../../runtime/effectiveness.ts";
7
7
  import { computePhaseProgress } from "../../runtime/phase-progress.ts";
8
8
  import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
9
+ import { stripWorkerEscapes } from "../../runtime/sanitize/worker-text.ts";
9
10
  import { formatTaskGraphLines, waitingReason } from "../../runtime/task-display.ts";
10
11
  import { verifyTaskCompletion } from "../../runtime/verification/completion-guard.ts";
11
12
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
12
13
  import { withRunLockSync } from "../../state/coordination/locks.ts";
13
14
  import { readDeliveryState, readMailbox } from "../../state/coordination/mailbox.ts";
14
15
  import { appendEvent, readEventsCursor } from "../../state/event-log/event-log.ts";
16
+ import { getTasksCorruptionReport } from "../../state/stores/manifest-io.ts";
15
17
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
16
18
  import { aggregateUsage, formatCost, formatUsage } from "../../state/usage.ts";
17
19
  import { formatDuration } from "../../ui/format-helpers.ts";
@@ -19,6 +21,7 @@ import { goalAchievedStatusLabel } from "../../ui/goal-flag.ts";
19
21
  import { locateRunCwd } from "../team-tool.ts";
20
22
  import type { PiTeamsToolResult } from "../tool-result.ts";
21
23
  import { result, type TeamContext } from "./context.ts";
24
+ import { stuckTasksFromRecords } from "./health-monitor.ts";
22
25
  import { paramRequired } from "./param-error.ts";
23
26
  import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
24
27
 
@@ -108,6 +111,31 @@ export function transitionStaleAsyncUnderLock(
108
111
  return transitioned;
109
112
  }
110
113
 
114
+ /**
115
+ * R46-1 / EP1 (unfenced-worker-text rollout): worker-authored values reach the
116
+ * orchestrator-facing status text from several places — child stderr flows
117
+ * VERBATIM into `task.error` / `failureCause` / `agent.error`
118
+ * (child-executor.ts attemptErrorFor), tool names/activity states arrive from
119
+ * worker JSON events, and event/attention messages are appended by crew code
120
+ * that embeds worker text. Interpolated raw, any of them can forge a fresh
121
+ * column-0 status row (e.g. a fake `✓ verifier [completed]` line, fabricated
122
+ * task/agent rows or `Progress: 100%`) and smuggle terminal escapes
123
+ * (ANSI color, OSC-52 clipboard writes) into the LEADER's status view.
124
+ *
125
+ * Shape: these are inline `key=value` fields inside dense single-line rows, so
126
+ * a multi-line provenance fence would break the row grammar. Route each value
127
+ * through the SHARED strip cascade (`stripWorkerEscapes`, not reimplemented)
128
+ * and collapse newlines/CRs to a single space — the R46-1 "newline-collapse"
129
+ * fix (the finding prescribes "newline-collapse or structural quoting in both
130
+ * status builders"). The stripped value can no longer start a new line, so
131
+ * forged rows are structurally impossible and escapes never reach the session.
132
+ */
133
+ function sanitizeStatusField(value: string): string {
134
+ return stripWorkerEscapes(value)
135
+ .replace(/[\n\r]+/g, " ")
136
+ .trim();
137
+ }
138
+
111
139
  export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
112
140
  if (!params.runId)
113
141
  return result(
@@ -225,21 +253,43 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
225
253
  tasks,
226
254
  executeWorkers: manifest.runtimeResolution?.kind !== "scaffold",
227
255
  runtimeConfig: cfg.runtime,
256
+ // EP2 parity rider (wave-6 M4-R2): finalize-run passes the delegate.completed
257
+ // id set so a grandchild delegate task (real output delivered via the broker
258
+ // mailbox, no usage/transcript of its own) is NOT misread as no-observed-work.
259
+ // status omitted it, so the SAME run rendered a phantom `noObservedWork` row
260
+ // here while finalize counted it as work. `allEvents` is the bounded
261
+ // cursor/tail read above (not the full readEvents call finalize uses) —
262
+ // best-effort parity: a delegate.completed event that has already scrolled
263
+ // out of the tail window is not recoverable here, matching the reader's
264
+ // bounded contract.
265
+ delegateCompleted: delegateCompletedTaskIdsFromEvents(allEvents),
228
266
  });
229
267
  const noObservedWorkTasks = effectiveness.noObservedWorkTaskIds
230
268
  .map((id) => tasks.find((task) => task.id === id))
231
269
  .filter((task): task is (typeof tasks)[number] => task !== undefined);
232
- const attentionTasks = effectiveness.needsAttentionTaskIds
270
+ // R48-N1 (fix-wave 6, EP3): fold an EXTERNAL liveness signal into the attention
271
+ // surface. `effectiveness.needsAttentionTaskIds` is worker-SELF-REPORTED
272
+ // (activityState === "needs_attention"); a dead sync host reports nothing and
273
+ // the run renders "running / needsAttention=none / 0%" forever. Consult the
274
+ // timestamp-based stuck-task signal (heartbeat/activity age) for active runs so
275
+ // an unresponsive/frozen task is surfaced even with zero self-report.
276
+ const externallyStuckTasks = isActiveRunStatus(manifest.status) ? stuckTasksFromRecords(tasks, manifest.runId, Date.now()) : [];
277
+ const externallyStuckIds = new Set(externallyStuckTasks.map((stuck) => stuck.taskId));
278
+ const needsAttentionIds = [...new Set([...effectiveness.needsAttentionTaskIds, ...externallyStuckIds])];
279
+ const attentionTasks = needsAttentionIds
233
280
  .map((id) => tasks.find((task) => task.id === id))
234
281
  .filter((task): task is (typeof tasks)[number] => task !== undefined);
235
282
  const activeAgents = crewAgents.filter((agent) => agent.status === "running");
236
283
  const completedAgents = crewAgents.filter((agent) => agent.status !== "running");
237
284
  const waitingTasks = tasks.filter((task) => task.status === "queued" || task.status === "waiting");
238
285
  const agentLine = (agent: (typeof crewAgents)[number]): string =>
239
- `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.usage?.cost ? ` cost=${formatCost(agent.usage.cost)}` : ""}${agent.progress?.activityState ? ` activityState=${agent.progress.activityState}` : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${agent.progress.currentTool}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.outputPath ? ` output=${agent.outputPath}` : ""}${agent.transcriptPath ? ` transcript=${agent.transcriptPath}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${agent.error}` : ""}`;
286
+ `- ${agent.id} [${agent.status}] ${agent.role} -> ${agent.agent} runtime=${agent.runtime}${agent.model ? ` model=${agent.model}` : ""}${agent.usage ? ` usage=${formatUsage(agent.usage)}` : ""}${agent.usage?.cost ? ` cost=${formatCost(agent.usage.cost)}` : ""}${agent.progress?.activityState ? ` activityState=${sanitizeStatusField(agent.progress.activityState)}` : ""}${formatActivityAge(agent) ? ` activity=${formatActivityAge(agent)}` : ""}${agent.progress?.currentTool ? ` tool=${sanitizeStatusField(agent.progress.currentTool)}` : ""}${agent.toolUses ? ` tools=${agent.toolUses}` : ""}${!agent.usage && agent.progress?.tokens ? ` tokens=${agent.progress.tokens}` : ""}${agent.progress?.turns ? ` turns=${agent.progress.turns}` : ""}${agent.jsonEvents !== undefined ? ` jsonEvents=${agent.jsonEvents}` : ""}${agent.outputPath ? ` output=${agent.outputPath}` : ""}${agent.transcriptPath ? ` transcript=${agent.transcriptPath}` : ""}${agent.statusPath ? ` status=${agent.statusPath}` : ""}${agent.error ? ` error=${sanitizeStatusField(agent.error)}` : ""}`;
240
287
  // G19 (W-E Phase 1): surface the goal-achievement verdict on terminal runs —
241
288
  // ⚠ false-green warning line; silent while running, when achieved, or when
242
289
  // the run predates the assessment (goalAchieved undefined).
290
+ // R39-M2 (fix-wave 6, EP3): if the tasks load above recovered from a corrupt
291
+ // tasks.json, surface the drop instead of a clean "completed 100%".
292
+ const corruptionReport = getTasksCorruptionReport(manifest.runId);
243
293
  const goalLine = goalAchievedStatusLabel(manifest);
244
294
  const lines = [
245
295
  `Run: ${manifest.runId}`,
@@ -268,10 +318,10 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
268
318
  ...(tasks.length
269
319
  ? tasks.map(
270
320
  (task) =>
271
- `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.agentProgress?.activityState ? ` activityState=${task.agentProgress.activityState}` : ""}${(() => {
321
+ `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.modelRouting ? ` modelRouting=${task.modelRouting.requested ? `${task.modelRouting.requested}->` : ""}${task.modelRouting.resolved}${task.modelRouting.usedAttempt ? ` attempt=${task.modelRouting.usedAttempt + 1}` : ""}` : ""}${task.agentProgress?.activityState ? ` activityState=${sanitizeStatusField(task.agentProgress.activityState)}` : ""}${(() => {
272
322
  const t = extractCommandTrace(task.agentProgress?.recentTools);
273
323
  return t.summary ? ` ${t.summary}` : "";
274
- })()}${attentionByTask.get(task.id)?.data?.reason ? ` attention=${String(attentionByTask.get(task.id)?.data?.reason)}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.resultArtifact ? ` result=${task.resultArtifact.path}` : ""}${task.transcriptArtifact ? ` transcript=${task.transcriptArtifact.path}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}${task.failureCause ? ` failureCause=${task.failureCause}` : ""}`,
324
+ })()}${attentionByTask.get(task.id)?.data?.reason ? ` attention=${sanitizeStatusField(String(attentionByTask.get(task.id)?.data?.reason))}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.resultArtifact ? ` result=${task.resultArtifact.path}` : ""}${task.transcriptArtifact ? ` transcript=${task.transcriptArtifact.path}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${sanitizeStatusField(task.error)}` : ""}${task.failureCause ? ` failureCause=${sanitizeStatusField(task.failureCause)}` : ""}`,
275
325
  )
276
326
  : ["- (none)"]),
277
327
  `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
@@ -281,7 +331,13 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
281
331
  `- observable=${effectiveness.observable}/${Math.max(1, effectiveness.completed)} completed tasks`,
282
332
  `- workerExecution=${effectiveness.workerExecution} guard=${effectiveness.guardMode} severity=${effectiveness.severity}`,
283
333
  `- noObservedWork=${effectiveness.noObservedWorkTaskIds.length ? effectiveness.noObservedWorkTaskIds.join(",") : "none"}`,
284
- `- needsAttention=${effectiveness.needsAttentionTaskIds.length ? effectiveness.needsAttentionTaskIds.join(",") : "none"}`,
334
+ `- needsAttention=${needsAttentionIds.length ? needsAttentionIds.join(",") : "none"}`,
335
+ `- unresponsive=${externallyStuckTasks.length ? externallyStuckTasks.map((stuck) => `${stuck.taskId}(${stuck.detail})`).join(",") : "none"}`,
336
+ ...(corruptionReport
337
+ ? [
338
+ `⚠ State recovery: tasks.json was corrupt; reconstructed ${corruptionReport.reconstructedCount} task(s) from events${corruptionReport.droppedIds.length ? `, ${corruptionReport.droppedIds.length} DROPPED (${corruptionReport.droppedIds.join(",")})` : ""} — run is DEGRADED.`,
339
+ ]
340
+ : []),
285
341
  "Completion verification",
286
342
  ...(tasks.filter((t) => t.status === "completed").length
287
343
  ? tasks
@@ -316,7 +372,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
316
372
  ...(events.length
317
373
  ? events.map(
318
374
  (event) =>
319
- `- ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${event.message}` : ""}`,
375
+ `- ${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${sanitizeStatusField(event.message)}` : ""}`,
320
376
  )
321
377
  : ["- (none)"]),
322
378
  ];
@@ -382,7 +438,7 @@ export function buildCompactStatus(
382
438
  if (failedOrAttention.length > 0) {
383
439
  lines.push("Issues:");
384
440
  for (const t of failedOrAttention) {
385
- lines.push(`- ${t.id} [${t.status}] ${t.role}: ${t.error ?? "(no error detail)"}`);
441
+ lines.push(`- ${t.id} [${t.status}] ${t.role}: ${t.error ? sanitizeStatusField(t.error) : "(no error detail)"}`);
386
442
  }
387
443
  }
388
444
  lines.push("Tip: pass details=true for full output (task graph, agents, effectiveness, events).");
@@ -89,7 +89,11 @@ export function handleWorkflowCreate(params: TeamToolParamsValue, ctx: TeamConte
89
89
  return result(`workflow-create rejected: ${validationError}`, { action: "workflow-create", status: "error" }, true);
90
90
  }
91
91
  try {
92
- const scope = (params.scope === "user" ? "user" : "project") as "user" | "project";
92
+ // R11-F1 (fix-wave 6, M1-2): scope reads the canonical carrier
93
+ // `config.scope` (same source as the root-gate and every other
94
+ // scope-bearing handler), defaulting to project like handleImport —
95
+ // top-level `params.scope` no longer diverges from the gate's read.
96
+ const scope = (params.config?.scope === "user" ? "user" : "project") as "user" | "project";
93
97
  const filePath = resolveWorkflowWritePath(ctx.cwd, name, scope);
94
98
  writeFileSync(filePath, script, "utf-8");
95
99
  return result(
@@ -1230,6 +1230,9 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
1230
1230
  // actions (schedule mutations, goal start/force, user-scope config/settings/
1231
1231
  // create/update, autonomy, settings theme) are reserved for the user's
1232
1232
  // ROOT session. Non-root callers (workers) can still write project scope.
1233
+ // Wave-6 (R11-F1/R11-S1): scope resolves from config.scope only, and
1234
+ // workflow-create/save/delete are ALWAYS worker-gated (.dwf.ts ACE),
1235
+ // import/export at user scope.
1233
1236
  // Single chokepoint: every domain router dispatch passes through here.
1234
1237
  const rootGateReason = rootGateBlockReason(params);
1235
1238
  if (rootGateReason) {
package/src/i18n.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { isDangerousKey, stripDangerousKeysDeep } from "./config/config-validation.ts";
2
3
  import { logInternalError } from "./utils/internal-error.ts";
3
4
 
4
5
  type Params = Record<string, string | number>;
@@ -166,22 +167,17 @@ export function t(key: Key, params?: Params): string {
166
167
  * @example
167
168
  * addTranslations("vi", { "agent.requiresPrompt": "Agent cần prompt." })
168
169
  */
169
- const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
170
-
171
- function stripDangerousKeys<T extends Record<string, unknown>>(obj: T): T {
172
- const safe: Record<string, unknown> = {};
173
- for (const key of Object.keys(obj)) {
174
- if (!DANGEROUS_KEYS.has(key)) {
175
- safe[key] = obj[key];
176
- }
177
- }
178
- return safe as T;
179
- }
180
-
181
170
  export function addTranslations(locale: string, bundle: Partial<Record<Key, string>>): void {
182
171
  if (!locale) return;
183
- const safeBundle = stripDangerousKeys(bundle as Record<string, unknown>) as Partial<Record<Key, string>>;
184
- const existing = translations[locale];
172
+ // R46-7 (M1-1): the locale axis reaches `translations[locale]` — a
173
+ // reserved name (`__proto__`) resolves through the [[Prototype]] getter to
174
+ // Object.prototype, so the Object.assign branch would pollute it and the
175
+ // assignment branch would silently reassign [[Prototype]]. Reject reserved
176
+ // locale names and use own-property lookup (shared proto-safe helpers in
177
+ // config-validation.ts).
178
+ if (isDangerousKey(locale)) return;
179
+ const safeBundle = stripDangerousKeysDeep(bundle as Record<string, unknown>) as Partial<Record<Key, string>>;
180
+ const existing = Object.hasOwn(translations, locale) ? translations[locale] : undefined;
185
181
  if (existing) {
186
182
  Object.assign(existing, safeBundle);
187
183
  } else {
@@ -199,6 +195,14 @@ export function listLocales(): string[] {
199
195
  // --- Initialization ---
200
196
 
201
197
  export function initI18n(pi: ExtensionAPI): () => void {
198
+ // R49-F3 (pi-core census): the `pi-core/i18n/*` channels below are a PHANTOM
199
+ // protocol in pi 0.77 and 1.0.4 — the resolved api literal + dist contain ZERO
200
+ // emitters/consumers for `pi-core/i18n/registerBundle`, `.../localeChanged` and
201
+ // `.../requestApi`, so `pi.events.on(...)` never fires and the locale stays
202
+ // locked to the default (`currentLocale` stays undefined → English/built-in
203
+ // fallback via t()). Harmless today (no runtime callers rely on a non-default
204
+ // locale); kept as forward-compat shims. Re-check before wiring any locale
205
+ // switch to this path.
202
206
  try {
203
207
  pi.events?.emit?.("pi-core/i18n/registerBundle", {
204
208
  namespace,
@@ -25,7 +25,8 @@
25
25
  * `sinceTs` watermark — one message delivers once;
26
26
  * - U5 (2026-10-10): dedup is ALSO durable across restarts — delivered
27
27
  * rows are recorded in delivery.json (`messages[id]="acknowledged"` +
28
- * `${direction}:${requestId}` index) and re-read on the next poll, so a
28
+ * `${direction}:${recipientTaskId}:${requestId}` index — recipient-scoped,
29
+ * M3-9/R33-1) and re-read on the next poll, so a
29
30
  * worker that restarts mid-run (fresh process, EMPTY seen-set) never
30
31
  * re-delivers. A resend carrying the same requestId is dropped by the
31
32
  * same index; duplicate rows under one requestId deliver exactly once.
@@ -44,6 +45,7 @@ import {
44
45
  readAllMailboxMessages,
45
46
  readDeliveryState,
46
47
  recordMailboxRequestIdDelivery,
48
+ requestIdDeliveryKey,
47
49
  } from "../state/coordination/mailbox.ts";
48
50
  import { appendEventFireAndForget } from "../state/event-log/event-log.ts";
49
51
  import type { TeamRunManifest } from "../state/types.ts";
@@ -201,7 +203,13 @@ export function pollWorkerInbox(pickup: WorkerInboxPickup, limit?: number): Mail
201
203
  // was PREVENTED — audited via mailbox.pickup_deduped (once per key
202
204
  // per process so the 500ms re-scan cannot spam the event log).
203
205
  const requestId = messageIdempotencyKey(m);
204
- const dedupKey = requestId !== undefined ? `${m.direction}:${requestId}` : undefined;
206
+ // M3-9 / R33-1: the index key is RECIPIENT-scoped
207
+ // (`direction:recipient:requestId`). A group fan-out mints ONE requestId
208
+ // and reuses it for every recipient, so the pre-fix run-scoped key let the
209
+ // first acking recipient suppress every sibling at its own pickup
210
+ // (probe: 1 delivered / 0 / 0). Same-row duplicate protection is
211
+ // unaffected — that is the per-message `messages[id]` record below.
212
+ const dedupKey = requestId !== undefined ? requestIdDeliveryKey(m.direction, taskId, requestId) : undefined;
205
213
  if (dedupKey !== undefined) {
206
214
  // (direction, requestId) index — catches resends carrying the same
207
215
  // requestId AND duplicate rows under one requestId.
@@ -258,6 +266,10 @@ export function acknowledgeWorkerInboxDelivery(
258
266
  picked.map((m) => ({
259
267
  direction: m.direction,
260
268
  messageId: m.id,
269
+ // M3-9 / R33-1: record WHICH recipient consumed the row — the index
270
+ // key is recipient-scoped, so group fan-out siblings keep their own
271
+ // exactly-once records.
272
+ taskId,
261
273
  requestId: messageIdempotencyKey(m),
262
274
  })),
263
275
  );
@@ -118,7 +118,7 @@ export interface MessageTool {
118
118
  * the injected env object, never a raw process.env.PI_CREW_* member, so the
119
119
  * check:env-vars gate stays green). */
120
120
  export function shouldRegisterMessageTool(env: NodeJS.ProcessEnv = process.env): boolean {
121
- return env[PI_CREW_MSG_ENABLED_ENV] === "1";
121
+ return getCrewEnv(PI_CREW_MSG_ENABLED_ENV, env) === "1";
122
122
  }
123
123
 
124
124
  export function createMessageTool(deps: MessageToolDeps = {}): MessageTool {
@@ -385,7 +385,7 @@ export type AskToolDefinition = ToolDefinition<typeof AskParams, AskDetails>;
385
385
  * reads the injected env object, never a raw process.env.PI_CREW_* member,
386
386
  * so the check:env-vars gate stays green). */
387
387
  export function shouldRegisterAskTool(env: NodeJS.ProcessEnv = process.env): boolean {
388
- return env[PI_CREW_ASK_ENABLED_ENV] === "1";
388
+ return getCrewEnv(PI_CREW_ASK_ENABLED_ENV, env) === "1";
389
389
  }
390
390
 
391
391
  // ── T3/R5 (ADR-5 2026-08-17-governed-nesting §1): worker-side `delegate` tool ──
@@ -551,11 +551,33 @@ export function createDelegateTool(deps: DelegateToolDeps = {}): DelegateToolDef
551
551
  const socketPath = get(PI_CREW_BROKER_SOCKET_ASK_ENV);
552
552
  const token = get(PI_CREW_BROKER_TOKEN_ASK_ENV);
553
553
  const runId = get(PI_CREW_BROKER_RUN_ID_ASK_ENV);
554
+ // delegate-auth: name the ACTUALLY-missing vars and distinguish the two
555
+ // no-creds shapes instead of a catch-all that blames all four:
556
+ // (a) identity present + socket/token absent — nested-host run: the
557
+ // spawning host is itself a child session, and child sessions
558
+ // never install a broker (issueForChild is root-session-only,
559
+ // lifecycle-handlers), so NO broker exists for this runId at any
560
+ // depth. (A root-spawned depth-2 worker DOES carry creds, so the
561
+ // old "depth-2 worker" label was wrong for this case.)
562
+ // (b) identity vars absent too — scaffold, mock, or partially-wired
563
+ // spawn; list exactly which vars are missing.
564
+ const missingVars: string[] = [];
565
+ if (!taskId) missingVars.push("PI_CREW_TASK_ID/PI_CREW_BROKER_TASK_ID");
566
+ if (!stateRoot) missingVars.push(PI_CREW_STATE_ROOT_ENV);
567
+ if (!runId) missingVars.push(PI_CREW_BROKER_RUN_ID_ASK_ENV);
568
+ if (!socketPath) missingVars.push(PI_CREW_BROKER_SOCKET_ASK_ENV);
569
+ if (!token) missingVars.push(PI_CREW_BROKER_TOKEN_ASK_ENV);
570
+ // direct-truthiness guard keeps TS narrowing for the client construction
571
+ // below (the missingVars[] indirection alone defeats control-flow analysis).
554
572
  if (!taskId || !stateRoot || !socketPath || !token || !runId) {
573
+ const reason =
574
+ !socketPath && !token && taskId && stateRoot && runId
575
+ ? "PI_CREW_BROKER_SOCKET / PI_CREW_BROKER_TOKEN absent while task-id/run-id/state-root are set — nested-host run: this worker's host is itself a child session, which never installs a broker, so no broker exists for this run"
576
+ : `${missingVars.join(" / ")} absent — scaffold, mock, or partially-wired spawn`;
555
577
  return notice(
556
578
  "unavailable",
557
579
  "no-broker",
558
- "[delegate] unavailable: no broker connection (socket/token/run-id/state-root absent — scaffold, mock, or depth-2 worker) — do the work yourself; do not call delegate again.",
580
+ `[delegate] unavailable: no broker connection (${reason}) — do the work yourself; do not call delegate again.`,
559
581
  );
560
582
  }
561
583
  const client = deps.makeBrokerClient
@@ -674,7 +696,7 @@ export function createDelegateTool(deps: DelegateToolDeps = {}): DelegateToolDef
674
696
  * for EVERY role. Broker admission re-checks depth+slot from the task
675
697
  * RECORD — the env is UX/hygiene, not the security boundary). */
676
698
  export function shouldRegisterDelegateTool(env: NodeJS.ProcessEnv = process.env): boolean {
677
- return env[PI_CREW_DELEGATE_ENABLED_ENV] === "1";
699
+ return getCrewEnv(PI_CREW_DELEGATE_ENABLED_ENV, env) === "1";
678
700
  }
679
701
 
680
702
  /** Task 5 (§15.2): fence a sibling/group inbox message before it reaches the
@@ -30,6 +30,7 @@
30
30
  import * as fs from "node:fs";
31
31
  import * as path from "node:path";
32
32
  import { type Static, Type } from "@sinclair/typebox";
33
+ import { getCrewEnv } from "../config/env-vars.ts";
33
34
  import { defineTool, type ExtensionAPI, type ToolDefinition } from "../extension/pi-api.ts";
34
35
  import { EngineManager, type ExecuteResult } from "../runtime/scratchpad/engine.ts";
35
36
  import { appendEventBuffered, appendEventFireAndForget } from "../state/event-log/event-log.ts";
@@ -77,8 +78,8 @@ function emitScratchpadMetric(
77
78
  data: Record<string, unknown>,
78
79
  appendEvent: typeof appendEventFireAndForget = appendEventFireAndForget,
79
80
  ): void {
80
- const eventsPath = env[PI_CREW_EVENTS_PATH_ENV];
81
- const runId = env[PI_CREW_RUN_ID_ENV];
81
+ const eventsPath = getCrewEnv(PI_CREW_EVENTS_PATH_ENV, env);
82
+ const runId = getCrewEnv(PI_CREW_RUN_ID_ENV, env);
82
83
  if (!eventsPath || !runId) return;
83
84
  // Fire-and-forget semantics (H1): a throwing writer (sync or async) must
84
85
  // never break the cell. The real appendEventFireAndForget already catches
@@ -87,7 +88,7 @@ function emitScratchpadMetric(
87
88
  appendEventBuffered(eventsPath, {
88
89
  type,
89
90
  runId,
90
- taskId: env[PI_CREW_TASK_ID_ENV],
91
+ taskId: getCrewEnv(PI_CREW_TASK_ID_ENV, env),
91
92
  data,
92
93
  }).catch((e) => logInternalError("scratchpad_lifecycle.buffered", e, `type=${type}`));
93
94
  } catch {
@@ -224,10 +225,10 @@ export interface SnapshotEnvValidation {
224
225
  * write to a guessed location.
225
226
  */
226
227
  export function validateSnapshotEnv(env: NodeJS.ProcessEnv = process.env): SnapshotEnvValidation {
227
- const taskId = env[PI_CREW_TASK_ID_ENV];
228
- const artifactsRoot = env[PI_CREW_ARTIFACTS_ROOT_ENV];
229
- const snapshotPath = env[PI_CREW_SCRATCHPAD_SNAPSHOT_ENV];
230
- const attempt = env[PI_CREW_ATTEMPT_ENV] ?? "0";
228
+ const taskId = getCrewEnv(PI_CREW_TASK_ID_ENV, env);
229
+ const artifactsRoot = getCrewEnv(PI_CREW_ARTIFACTS_ROOT_ENV, env);
230
+ const snapshotPath = getCrewEnv(PI_CREW_SCRATCHPAD_SNAPSHOT_ENV, env);
231
+ const attempt = getCrewEnv(PI_CREW_ATTEMPT_ENV, env) ?? "0";
231
232
 
232
233
  if (!taskId) return { valid: false, reason: `missing ${PI_CREW_TASK_ID_ENV}`, attempt };
233
234
  if (!artifactsRoot) return { valid: false, reason: `missing ${PI_CREW_ARTIFACTS_ROOT_ENV}`, attempt };
@@ -277,8 +278,8 @@ export interface RestoreEnvValidation {
277
278
  * no restore, empty namespace, execute continues, message never leaks paths.
278
279
  */
279
280
  export function validateRestoreEnv(env: NodeJS.ProcessEnv = process.env, restorePath: string): RestoreEnvValidation {
280
- const taskId = env[PI_CREW_TASK_ID_ENV];
281
- const artifactsRoot = env[PI_CREW_ARTIFACTS_ROOT_ENV];
281
+ const taskId = getCrewEnv(PI_CREW_TASK_ID_ENV, env);
282
+ const artifactsRoot = getCrewEnv(PI_CREW_ARTIFACTS_ROOT_ENV, env);
282
283
  if (!taskId) return { valid: false, reason: `missing ${PI_CREW_TASK_ID_ENV}` };
283
284
  if (!artifactsRoot) return { valid: false, reason: `missing ${PI_CREW_ARTIFACTS_ROOT_ENV}` };
284
285
  try {
@@ -299,7 +300,7 @@ export function validateRestoreEnv(env: NodeJS.ProcessEnv = process.env, restore
299
300
  if (st.isSymbolicLink() || !st.isFile()) return { valid: false, reason: "restore-path-not-regular-file" };
300
301
  if (st.size > SNAPSHOT_MAX_BYTES) return { valid: false, reason: "restore-path-over-cap" };
301
302
  // (4) mtime pin (swap-detection hint — forgeable, never authn).
302
- const pinned = env[PI_CREW_SCRATCHPAD_RESTORE_MTIME_ENV];
303
+ const pinned = getCrewEnv(PI_CREW_SCRATCHPAD_RESTORE_MTIME_ENV, env);
303
304
  if (pinned) {
304
305
  const pinnedMs = Number.parseFloat(pinned);
305
306
  if (Number.isFinite(pinnedMs) && Math.abs(st.mtimeMs - pinnedMs) > RESTORE_MTIME_TOLERANCE_MS) {
@@ -483,7 +484,7 @@ export function createExecuteTool(engine: EngineManager, deps: Partial<Scratchpa
483
484
  async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
484
485
  const env = deps.env ?? process.env;
485
486
  // 1. Dormant check (lớp 2 — defense in depth behind the registration gate).
486
- if (env[PI_CREW_SCRATCHPAD_ENV] !== "1") {
487
+ if (getCrewEnv(PI_CREW_SCRATCHPAD_ENV, env) !== "1") {
487
488
  throw new Error("scratchpad is dormant");
488
489
  }
489
490
  // 2. F12: terminal shutdown state — a distinct system error, not "wedged".
@@ -650,7 +651,7 @@ export function registerScratchpadLifecycle(pi: ExtensionAPI, options: Scratchpa
650
651
  // Reset first: each register is a fresh worker session (a session reload
651
652
  // re-arms restore via the env — NIT-2a, module-scope is intentional).
652
653
  const env = options.env ?? process.env;
653
- const restorePath = env[PI_CREW_SCRATCHPAD_RESTORE_ENV];
654
+ const restorePath = getCrewEnv(PI_CREW_SCRATCHPAD_RESTORE_ENV, env);
654
655
  if (restorePath) {
655
656
  const m = restorePath.match(/\.attempt-(\d+)\.snapshot\.json$/);
656
657
  restorePending = { path: restorePath, attempt: m ? Number.parseInt(m[1], 10) : 0 };
@@ -676,5 +677,5 @@ export function registerScratchpadLifecycle(pi: ExtensionAPI, options: Scratchpa
676
677
  * the single source of truth per F15.
677
678
  */
678
679
  export function shouldRegisterScratchpadTool(env: NodeJS.ProcessEnv = process.env): boolean {
679
- return env[PI_CREW_SCRATCHPAD_ENV] === "1" && env[PI_CREW_KIND_ENV] === "subagent";
680
+ return getCrewEnv(PI_CREW_SCRATCHPAD_ENV, env) === "1" && getCrewEnv(PI_CREW_KIND_ENV, env) === "subagent";
680
681
  }