pi-crew 0.11.20 → 0.11.22

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 (84) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/index.mjs +537 -526
  3. package/package.json +2 -1
  4. package/skills/real-test-pi-crew/SKILL.md +21 -0
  5. package/src/errors.ts +14 -1
  6. package/src/extension/async-notifier.ts +42 -17
  7. package/src/extension/cross-extension-rpc.ts +42 -5
  8. package/src/extension/notification-router.ts +15 -2
  9. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  10. package/src/extension/registration/runtime-cleanup.ts +17 -0
  11. package/src/extension/registration/wire-cross-extension.ts +26 -0
  12. package/src/extension/rpc-hmac.ts +53 -2
  13. package/src/extension/team-tool/cancel.ts +34 -21
  14. package/src/extension/team-tool/dispatch/index.ts +6 -5
  15. package/src/extension/team-tool/dispatch/status.ts +42 -2
  16. package/src/extension/team-tool.ts +24 -2
  17. package/src/prompt/inbox-poll.ts +77 -25
  18. package/src/prompt/message-tool.ts +62 -4
  19. package/src/prompt/prompt-runtime.ts +82 -32
  20. package/src/runtime/broker/crew-broker-client.ts +74 -16
  21. package/src/runtime/broker/crew-broker.ts +111 -77
  22. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  23. package/src/runtime/broker/wait-status-cache.ts +14 -5
  24. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  25. package/src/runtime/child-pi/child-pi.ts +20 -2
  26. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  27. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  28. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  29. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  30. package/src/runtime/crew-agent-records.ts +7 -1
  31. package/src/runtime/delivery-coordinator.ts +30 -0
  32. package/src/runtime/dwf-state-store.ts +41 -7
  33. package/src/runtime/finalize-run.ts +13 -2
  34. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  35. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  36. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  37. package/src/runtime/live-session/embedded-tier.ts +3 -1
  38. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  39. package/src/runtime/model/model-fallback.ts +23 -1
  40. package/src/runtime/output/pi-json-output.ts +26 -1
  41. package/src/runtime/policy-engine.ts +18 -0
  42. package/src/runtime/process/zombie-scanner.ts +78 -14
  43. package/src/runtime/process-status.ts +6 -1
  44. package/src/runtime/recovery/crash-recovery.ts +170 -28
  45. package/src/runtime/rpc/rpc-worker.ts +44 -1
  46. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
  47. package/src/runtime/scheduling/scheduler.ts +74 -11
  48. package/src/runtime/scratchpad/engine.ts +26 -3
  49. package/src/runtime/settings-store.ts +22 -2
  50. package/src/runtime/surface/tmux-provider.ts +24 -3
  51. package/src/runtime/task-health.ts +16 -1
  52. package/src/runtime/task-output-context.ts +143 -71
  53. package/src/runtime/task-packet.ts +70 -17
  54. package/src/runtime/task-runner/child-executor.ts +27 -0
  55. package/src/runtime/task-runner/output-splitter.ts +79 -16
  56. package/src/runtime/task-runner/post-execution.ts +75 -15
  57. package/src/runtime/task-runner/state-helpers.ts +92 -2
  58. package/src/runtime/usage-tracker.ts +11 -0
  59. package/src/runtime/verification/green-contract.ts +17 -0
  60. package/src/runtime/verification/verification-gates.ts +24 -6
  61. package/src/runtime/workspace-lock.ts +53 -0
  62. package/src/schema/team-tool-schema.ts +10 -4
  63. package/src/state/coordination/mailbox.ts +133 -38
  64. package/src/state/coordination/process-lock.ts +22 -2
  65. package/src/state/decision-ledger.ts +56 -8
  66. package/src/state/event-log/event-log-rotation.ts +83 -5
  67. package/src/state/event-log/event-log.ts +181 -12
  68. package/src/state/stores/active-run-registry.ts +10 -3
  69. package/src/state/types.ts +9 -1
  70. package/src/ui/agents-jobs-browser.ts +22 -8
  71. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  72. package/src/ui/events-state-source.ts +67 -28
  73. package/src/ui/inline-panel/agent-pane.ts +13 -6
  74. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  75. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  76. package/src/ui/inline-panel/index.ts +21 -1
  77. package/src/ui/live-run-sidebar.ts +14 -3
  78. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  79. package/src/ui/powerbar-publisher.ts +6 -3
  80. package/src/ui/rail.ts +7 -1
  81. package/src/ui/render-scheduler.ts +53 -1
  82. package/src/ui/run-action-dispatcher.ts +68 -20
  83. package/src/ui/status-colors.ts +9 -0
  84. package/src/utils/env-filter.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.20",
3
+ "version": "0.11.22",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -88,6 +88,7 @@
88
88
  "test": "npm run test:unit && npm run test:integration",
89
89
  "test:full": "npm run test:unit && npm run test:integration && npm run test:integration:slow",
90
90
  "test:unit": "node scripts/test-runner.mjs --test-concurrency=4 --test-timeout=180000 --test-force-exit 'test/unit/**/*.test.ts'",
91
+ "test:coverage": "node --import tsx/esm --test --experimental-test-coverage --test-concurrency=4 --test-timeout=180000 --test-force-exit 'test/unit/**/*.test.ts'",
91
92
  "test:critical": "node scripts/test-runner.mjs --test-concurrency=4 --test-timeout=30000 --test-force-exit test/unit/runtime/broker/crew-broker-handshake.test.ts test/unit/runtime/broker/crew-broker-stale-socket.test.ts test/unit/runtime/broker/crew-broker-feature-flag.test.ts test/unit/runtime/broker/crew-broker-server-gate.test.ts test/unit/runtime/broker/crew-broker-client-fallback.test.ts test/unit/runtime/broker/crew-broker-mailbox-observer.test.ts test/unit/runtime/broker/crew-broker-close-during-reconnect.test.ts test/unit/runtime/broker/crew-broker-steer-dedup.test.ts test/unit/runtime/broker/crew-broker-symlink-steering.test.ts test/unit/ui/keybinding-map.parity.test.ts test/unit/ui/pi-tui-dispatch-probe.test.ts test/unit/utils/session-utils-extract.test.ts test/unit/config/config-schema-sync.test.ts test/unit/runtime/child-pi/child-pi-env-spread.test.ts test/unit/runtime/task-runner/child-executor-heartbeat-pulse.test.ts",
92
93
  "test:watch": "node scripts/test-runner.mjs --watch --test-concurrency=4 --test-timeout=30000 'test/unit/**/*.test.ts'",
93
94
  "test:integration": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=300000 test/integration/*.test.ts",
@@ -1256,6 +1256,27 @@ When a tier fails, the recovery is usually quick. Match the symptom to the cause
1256
1256
  | `team action='schedule' interval=<ms>` always says `Invalid schedule "<N>ms". Use "5m", …` | handle-schedule builds `${interval}ms`; both interval parsers lacked an `ms` unit — every numeric interval died. Fixed 2026-09-21 | The suggested "5m" formats only work via `cron`/`once` params, not `interval` (interval is schema-typed as a plain ms number) |
1257
1257
  | Runs keep appearing under one scheduled job; children of YOUR pi process; machine thrashing | **Interval ≥ 2^31-1 ms (≥24.8 days) overflowed the Node timer to a 1ms hot loop** (fixed 2026-09-22 — clamped hops). Check `.crew/audit/prune.jsonl` for interleaved auto-prunes. Contain: remove job, then `pkill -f '[b]ackground-runner.ts --cwd <root>'` | Any new timer scheduling must clamp below MAX_TIMER_DELAY_MS — grep for `setInterval(`/`setTimeout(` with computed delays |
1258
1258
 
1259
+ ## Release gate — battery → push → GitHub Actions GREEN → publish (canonical, 2026-10-14)
1260
+
1261
+ Local `npm run ci` là LINUX-ONLY — không thay được matrix 3-OS của GitHub Actions.
1262
+ Live-proven 2026-10-14: **0.11.19 và 0.11.20 đều publish khi CI GitHub ĐỎ** (local ci + pre-push
1263
+ hook xanh hết) — windows fail do child-env thiếu `SYSTEMROOT` (node con không khởi tạo Winsock được,
1264
+ stdout rỗng); macOS fail do transient ECONNREFUSED khiến accepting-probe 1 lần coi holder SỐNG là
1265
+ corpse → xóa file socket + rebind (STEAL lock) — fix bằng 3-strike takeover (`process-lock.ts`).
1266
+
1267
+ **Quy trình BẮT BUỘC cho mọi release (theo thứ tự, không bỏ bước):**
1268
+
1269
+ 1. Cập nhật skill (wave entry) + CHANGELOG heading cho version sắp release — commit.
1270
+ 2. Chạy battery theo decision table (đủ tier cho paths đổi) — xanh hết.
1271
+ 3. `npm version patch` (tạo release commit + tag).
1272
+ 4. `git push origin main --follow-tags` — pre-push hook tự chạy full unit local.
1273
+ 5. **`gh run watch <runId> --exit-status` (hoặc poll `gh run list`) — CHỜ matrix 3-OS XANH TOÀN BỘ.**
1274
+ CI đỏ ⇒ KHÔNG publish — fix, push lại, chờ lại. (Check nhanh fail: `gh run view <id>` +
1275
+ `gh api repos/<o>/<r>/actions/jobs/<jobId>//logs` — KHÔNG grep "not ok" mù: tên test có thể
1276
+ chứa literal "not ok" ("malformed JSON is not ok with a message") — grep kèm assertion block.)
1277
+ 6. CHỈ KHI xanh: `npm publish` → poll `npm view <pkg> dist-tags.latest` → `gh release create`.
1278
+ 7. Post-release: knowledge.md entry SHIPPED + dọn branch/scratch.
1279
+
1259
1280
  ## Performance budget (per-tier soft limits)
1260
1281
 
1261
1282
  | Tier | Soft limit | Hard limit | What happens over hard limit |
package/src/errors.ts CHANGED
@@ -147,9 +147,22 @@ export const errors = {
147
147
  );
148
148
  },
149
149
 
150
- modelExhausted(chain: string[], lastFailure?: string): CrewError {
150
+ modelExhausted(chain: string[], lastFailure?: string, plannedChainLength?: number): CrewError {
151
151
  const tried = chain.join(" → ");
152
152
  const last = lastFailure ? ` Last failure: ${lastFailure}` : "";
153
+ // J4 (DR-03 round-6): a chain that broke EARLY (non-retryable failure,
154
+ // classifier veto, or spawn-budget stop while queued fallbacks remained)
155
+ // is not exhaustion — the old "All N candidates exhausted" wording
156
+ // overstated what happened and hid that fallbacks were never tried.
157
+ // plannedChainLength (the full candidate chain length) lets the message
158
+ // distinguish the two shapes; omit it for the legacy exhaustion wording.
159
+ if (plannedChainLength !== undefined && chain.length < plannedChainLength) {
160
+ const untried = plannedChainLength - chain.length;
161
+ return new CrewError(
162
+ ErrorCode.ModelExhausted,
163
+ `Model chain failed early (tried ${tried} — ${chain.length} of ${plannedChainLength} candidates; ${untried} queued fallback${untried === 1 ? "" : "s"} not tried).${last}`,
164
+ ).withContext("model fallback chain");
165
+ }
153
166
  return new CrewError(
154
167
  ErrorCode.ModelExhausted,
155
168
  `All ${chain.length} model candidates exhausted (tried: ${tried}).${last}`,
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readCrewAgents, saveCrewAgents } from "../runtime/crew-agent-records.ts";
3
3
  import { checkProcessLiveness, isActiveRunStatus } from "../runtime/process-status.ts";
4
- import { withRunLock } from "../state/coordination/locks.ts";
4
+ import { withRunLock, withRunLockSync } from "../state/coordination/locks.ts";
5
5
  import { appendEventAsync, readEventsCursor, type TeamEvent } from "../state/event-log/event-log.ts";
6
6
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
7
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -70,24 +70,49 @@ function isTaskActive(task: TeamTaskState): boolean {
70
70
  return task.status === "running" || task.status === "queued" || task.status === "waiting";
71
71
  }
72
72
 
73
+ /**
74
+ * R4-7 (DR-02 Wave 2 Cluster C): pure RMW core of the async-died sweep —
75
+ * flip ONLY still-active tasks to failed. Exported so the locked sweep body
76
+ * stays unit-testable without the notifier interval harness (repo seam
77
+ * pattern). Predicate is byte-identical to the previous inline map.
78
+ */
79
+ export function failStillActiveTasks(tasks: readonly TeamTaskState[], failedAt: string, message: string): TeamTaskState[] {
80
+ return tasks.map((task) =>
81
+ isTaskActive(task)
82
+ ? {
83
+ ...task,
84
+ status: "failed",
85
+ finishedAt: failedAt,
86
+ error: message,
87
+ }
88
+ : task,
89
+ );
90
+ }
91
+
73
92
  function markActiveTasksAndAgentsFailed(run: TeamRunManifest, message: string): void {
74
- const loaded = loadRunManifestById(run.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
75
- const tasks = loaded?.tasks ?? [];
76
93
  const failedAt = new Date().toISOString();
77
- if (tasks.some(isTaskActive)) {
78
- saveRunTasks(
79
- run,
80
- tasks.map((task) =>
81
- isTaskActive(task)
82
- ? {
83
- ...task,
84
- status: "failed",
85
- finishedAt: failedAt,
86
- error: message,
87
- }
88
- : task,
89
- ),
90
- );
94
+ // R4-7 (DR-02 Wave 2 Cluster C): the task flip used to be an UNLOCKED
95
+ // load→map→save on tasks.json (":74 no withRunLock - best-effort"). It is
96
+ // now a run-locked RMW with the fresh re-read INSIDE the lock (R13-S1
97
+ // canonical pattern): only tasks that are STILL active in the fresh read
98
+ // are flipped, so a task that completed (or was cancelled) by a concurrent
99
+ // writer is never overwritten with failed. Lock re-entrance: the sole
100
+ // caller (markDeadAsyncRunIfNeeded) already holds the ASYNC run lock for
101
+ // this run in the same async context, so this sync acquisition bypasses
102
+ // via lockCtx — no self-deadlock, no double file acquire; an unlocked
103
+ // future caller gets real mutual exclusion.
104
+ try {
105
+ withRunLockSync(run, () => {
106
+ const fresh = loadRunManifestById(run.cwd, run.runId);
107
+ if (!fresh) return;
108
+ if (!fresh.tasks.some(isTaskActive)) return;
109
+ saveRunTasks(fresh.manifest, failStillActiveTasks(fresh.tasks, failedAt, message));
110
+ });
111
+ } catch (error) {
112
+ // Best-effort sweep: lock contention or a store failure must not break
113
+ // the notifier tick (the run-status flip above already happened) — warn
114
+ // and keep going; whatever remains active is retried by the next pass.
115
+ logInternalError("async-notifier.markActiveTasksFailed", error, `runId=${run.runId}`, "warn");
91
116
  }
92
117
  const agents = readCrewAgents(run);
93
118
  if (agents.some((agent) => agent.status === "running" || agent.status === "queued" || agent.status === "waiting")) {
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
3
+ import { logInternalError } from "../utils/internal-error.ts";
3
4
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
4
- import { withHmacVerification } from "./rpc-hmac.ts";
5
+ import { isHmacEnabled, withHmacVerification } from "./rpc-hmac.ts";
5
6
  // Lazy-loaded to avoid pulling team-tool.ts (and its entire runtime chain) into module load.
6
7
  import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
7
8
 
@@ -75,6 +76,19 @@ function isAllowedRpcOperation(operation: string): boolean {
75
76
  return RPC_ALLOWED_OPERATIONS.has(operation);
76
77
  }
77
78
 
79
+ // R7-9 (DR-04 round 7): operations a publisher WITHOUT a valid HMAC signature
80
+ // may issue on the realtime `pi-crew:live-control` channel. Mirrors the
81
+ // RPC_ALLOWED_OPERATIONS semantics (unauthenticated = read-only). The
82
+ // live-control protocol currently defines NO read-only operations — every
83
+ // parseable operation (steer/stop/resume/follow-up) mutates a live agent
84
+ // session — so the set is empty and unauthenticated publishers are rejected
85
+ // outright. Add future read-only live-control ops here when introduced.
86
+ const LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS: ReadonlySet<string> = new Set([]);
87
+
88
+ function isUnauthenticatedLiveControlOperationAllowed(operation: string): boolean {
89
+ return LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS.has(operation);
90
+ }
91
+
78
92
  // SECURITY (HIGH #4 fix): In-memory rate limiter for RPC run requests.
79
93
  // Prevents any extension from spawning unlimited child processes.
80
94
  const RPC_RATE_LIMIT_MAX = 5; // Max 5 RPC run requests...
@@ -148,9 +162,15 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => un
148
162
  }
149
163
 
150
164
  // SECURITY TRUST BOUNDARY: RPC channels (pi-crew:rpc:run, pi-crew:rpc:status,
151
- // pi-crew:rpc:live-control) are accessible to any extension on the shared event
152
- // bus. Mitigations: rate limiting, explicit intent, operation allowlist, cwd
153
- // containment validation, and HMAC origin signing (see rpc-hmac.ts).
165
+ // pi-crew:rpc:live-control, and the realtime pi-crew:live-control channel) are
166
+ // accessible to any extension on the shared event bus. Mitigations: rate
167
+ // limiting, explicit intent, operation allowlist, cwd containment validation,
168
+ // and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9): without a
169
+ // valid HMAC signature every RPC surface is read-only — the request/response
170
+ // channels enforce this via RPC_ALLOWED_OPERATIONS, and the realtime
171
+ // pi-crew:live-control channel rejects any operation outside
172
+ // LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS (currently ALL of them, since
173
+ // steer/stop/resume/follow-up are mutating) with a warn log and no publish.
154
174
 
155
175
  export function registerPiCrewRpc(
156
176
  events: EventBusLike | undefined,
@@ -283,7 +303,24 @@ export function registerPiCrewRpc(
283
303
  "pi-crew:live-control",
284
304
  withHmacVerification((raw) => {
285
305
  const request = parseLiveControlRealtimeMessage(raw);
286
- if (request) publishLiveControlRealtime(request);
306
+ if (!request) return;
307
+ // R7-9 (DR-04 round 7): gate mutating live-control operations. This
308
+ // handler is only reachable WITHOUT a valid HMAC signature when
309
+ // PI_CREW_RPC_SECRET is unset — when the secret IS set the wrapper
310
+ // rejects missing/invalid signatures outright (throw, no publish).
311
+ // With no secret the publisher is unauthenticated, so only the
312
+ // (currently empty) read-only allowlist may pass; steer/stop/
313
+ // resume/follow-up are rejected with a warn log and no publish.
314
+ if (!isHmacEnabled() && !isUnauthenticatedLiveControlOperationAllowed(request.operation)) {
315
+ logInternalError(
316
+ "rpc.live-control",
317
+ new Error(`rejected unauthenticated live-control operation '${request.operation}'`),
318
+ `runId=${request.runId},taskId=${request.taskId}`,
319
+ "warn",
320
+ );
321
+ return;
322
+ }
323
+ publishLiveControlRealtime(request);
287
324
  }, "pi-crew:live-control"),
288
325
  ),
289
326
  on(
@@ -154,11 +154,15 @@ export class NotificationRouter {
154
154
  if (this.batch.length === 0) return;
155
155
  const items = this.batch;
156
156
  this.batch = [];
157
+ // R7-4 (2026-10-16): flush() also runs inside the setTimeout callback —
158
+ // a throwing `deliver` used to escape into the timer as an uncaught
159
+ // exception. Wrap every delivery; log via logInternalError and keep the
160
+ // lossy batch semantics (the batch is NOT re-queued on error).
157
161
  if (items.length === 1) {
158
- this.deliver(items[0]!);
162
+ this.deliverSafely(items[0]!);
159
163
  return;
160
164
  }
161
- this.deliver({
165
+ this.deliverSafely({
162
166
  id: `batch:${items.map((item) => notificationKey(item)).join(",")}`,
163
167
  severity: batchSeverity(items),
164
168
  source: "batch",
@@ -168,6 +172,15 @@ export class NotificationRouter {
168
172
  });
169
173
  }
170
174
 
175
+ /** R7-4: never let a throwing deliver callback escape flush() (timer context). */
176
+ private deliverSafely(notification: NotificationDescriptor): void {
177
+ try {
178
+ this.deliver(notification);
179
+ } catch (error) {
180
+ logInternalError("notification.flush", error);
181
+ }
182
+ }
183
+
171
184
  dispose(): void {
172
185
  if (this.timer) clearTimeout(this.timer);
173
186
  this.timer = undefined;
@@ -536,8 +536,11 @@ async function runDeferredSessionCleanup(
536
536
  * Build a CrewScheduler wired to the current session. The scheduler's
537
537
  * executor closure invokes handleTeamTool lazily — the heavy team-tool
538
538
  * import only fires when a scheduled job actually runs.
539
+ *
540
+ * @internal — exported for the R6-2 persistence-wiring unit tests
541
+ * (test/unit/runtime/scheduling/scheduler-persist-reload.test.ts).
539
542
  */
540
- function setupCrewScheduler(
543
+ export function setupCrewScheduler(
541
544
  pi: ExtensionAPI,
542
545
  ctx: RegistrationContext,
543
546
  extensionCtx: ExtensionContext,
@@ -556,6 +559,15 @@ function setupCrewScheduler(
556
559
  if (ctx.cleanedUp) return;
557
560
  notifyScheduleEvent(event);
558
561
  pi.events?.emit?.("crew-scheduler", event);
562
+ // R6-2(a) (round-6 audit): `updated` scheduler events persist the job —
563
+ // the persist flow armCron's docstring always claimed existed. fire()'s
564
+ // lastStatus flips and advanceCronNextRun's nextRun writes now land on
565
+ // disk; before this only the executor persisted, and it persisted the
566
+ // pre-completion snapshot (lastStatus:"running") while the terminal flip
567
+ // stayed memory-only — a restart in that window reloaded "running" and
568
+ // fire()'s in-flight guard skipped the job forever (jobs died after
569
+ // restart). persistScheduledJobUpdate is best-effort (internal catch).
570
+ if (event.type === "updated") persistScheduledJobUpdate(extensionCtx.cwd, event.job);
559
571
  },
560
572
  executor: (job) => {
561
573
  let runParams: { action: string; team: string; goal: string };
@@ -601,17 +613,26 @@ function setupCrewScheduler(
601
613
  /* best-effort provenance tracking */
602
614
  }
603
615
  }
604
- try {
605
- const updatedJob = crewScheduler?.list().find((j) => j.id === job.id);
606
- if (updatedJob) persistScheduledJobUpdate(extensionCtx.cwd, updatedJob);
607
- } catch {
608
- /* best-effort */
609
- }
610
- crewScheduler?.update(job.id, {
611
- runCount: job.runCount + 1,
612
- lastRun: new Date().toISOString(),
613
- lastStatus: "success",
614
- });
616
+ // R6-2(b) + I6 (round-6 audit): update state FIRST, then persist the
617
+ // UPDATED job. The old order persisted the fire-time snapshot (still
618
+ // lastStatus:"running") and then flipped memory only — a restart in
619
+ // that window reloaded "running" and fire()'s in-flight guard skipped
620
+ // the job forever. runCount comes from a FRESH list() read: the
621
+ // closure-captured `job` is the dispatch-time snapshot, so any update
622
+ // that landed between dispatch and completion (run-now, another
623
+ // completion) would otherwise be clobbered with a stale count. The
624
+ // update() call also persists via the emit wiring above; the explicit
625
+ // persist keeps the executor authoritative when the emit guard exited
626
+ // early (cleanedUp).
627
+ const freshJob = crewScheduler?.list().find((j) => j.id === job.id);
628
+ const completedJob = freshJob
629
+ ? crewScheduler?.update(job.id, {
630
+ runCount: freshJob.runCount + 1,
631
+ lastRun: new Date().toISOString(),
632
+ lastStatus: "success",
633
+ })
634
+ : undefined;
635
+ if (completedJob) persistScheduledJobUpdate(extensionCtx.cwd, completedJob);
615
636
  } catch (err) {
616
637
  logInternalError("scheduler.execute", err);
617
638
  crewScheduler?.update(job.id, { lastStatus: "error" });
@@ -651,7 +672,9 @@ function setupCrewScheduler(
651
672
  // - retains the broker across session switches when the session_id is
652
673
  // unchanged; on a session_id change, stops the old broker and binds a new
653
674
  // one on the next acquire.
654
- // - stops the broker during session_shutdown BEFORE the runtime cleanup path.
675
+ // - stops the broker during session_shutdown: cleanupRuntime
676
+ // (registration/runtime-cleanup.ts) calls ctx.brokerController?.stop()
677
+ // first, before the rest of the runtime teardown (R7-11 wiring).
655
678
  //
656
679
  // The gate is re-evaluated on every `issueForChild` call (cheap env+depth
657
680
  // check) so the kill switch (PI_CREW_BROKER=0) takes effect immediately.
@@ -129,6 +129,23 @@ function buildCleanupRuntime(ctx: RegistrationContext): () => void {
129
129
  return (): void => {
130
130
  if (ctx.cleanedUp) return;
131
131
  ctx.cleanedUp = true;
132
+ // R7-11 (DR-04 round 7): stop the inter-pi broker FIRST, before the rest
133
+ // of the runtime teardown — this call is what makes the
134
+ // CrewBrokerLifecycleController docblock's "stops the broker during
135
+ // session_shutdown" promise true (see lifecycle-handlers.ts). The
136
+ // controller's stop() is idempotent and swallows its own errors; clearing
137
+ // the field makes a re-entry a no-op even if the cleanedUp gate were
138
+ // bypassed. Fire-and-forget: cleanupRuntime is synchronous and shutdown
139
+ // must not block on the broker socket teardown.
140
+ try {
141
+ const stopping = ctx.brokerController?.stop();
142
+ ctx.brokerController = undefined;
143
+ stopping?.catch((error) => {
144
+ logInternalError("cleanup-runtime.broker-stop", error);
145
+ });
146
+ } catch (error) {
147
+ logInternalError("cleanup-runtime.broker-stop", error);
148
+ }
132
149
  const sid = extractSessionId(ctx.currentCtx);
133
150
  if (ctx.preloadTimer) {
134
151
  clearTimeout(ctx.preloadTimer);
@@ -10,9 +10,31 @@
10
10
  * (EXT-9: no longer on globalThis[Symbol.for(...)]).
11
11
  */
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { logInternalError } from "../../utils/internal-error.ts";
13
14
  import { registerPiCrewRpc } from "../cross-extension-rpc.ts";
14
15
  import type { RegistrationContext } from "./registration-types.ts";
15
16
 
17
+ /**
18
+ * N-6 (DR-04 round 7): shared generation/ownership guard for the late lazy
19
+ * team-tool import. The dynamic import resolves on a LATER tick — a
20
+ * session_shutdown (or session-switch cleanup) may already have torn the
21
+ * runtime down by then, and installing the crew registry after teardown would
22
+ * resurrect extension state for a disposed registration. Skip the install
23
+ * when the registration generation moved on or the context is marked
24
+ * cleaned-up; session_start rebinds the registry via
25
+ * refreshCrossExtensionWiringForSession anyway.
26
+ */
27
+ function registryInstallSkipped(ctx: RegistrationContext, installGeneration: number, where: string): boolean {
28
+ if (!ctx.cleanedUp && ctx.sessionGeneration === installGeneration) return false;
29
+ logInternalError(
30
+ "register.wire-cross-extension",
31
+ new Error(`skipped crew registry install (${where}): runtime cleanup already ran before the lazy import resolved`),
32
+ `installGeneration=${installGeneration},currentGeneration=${ctx.sessionGeneration},cleanedUp=${ctx.cleanedUp}`,
33
+ "debug",
34
+ );
35
+ return true;
36
+ }
37
+
16
38
  /** Install both wires on the registration context. */
17
39
  export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationContext): void {
18
40
  // Wire the in-process RPC handle.
@@ -27,8 +49,10 @@ export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationC
27
49
  // Install the crew registry. Lazy import keeps team-tool.ts (which pulls
28
50
  // in the entire runtime chain) out of the cold-start module graph. EXT-9:
29
51
  // the registry now lives in module-scoped state, not globalThis.
52
+ const installGeneration = ctx.sessionGeneration;
30
53
  void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
31
54
  // LAZY: defer team-tool until cross-extension wiring fires
55
+ if (registryInstallSkipped(ctx, installGeneration, "install")) return;
32
56
  const manifestCacheForRegistry = ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd());
33
57
  installCrewGlobalRegistry({
34
58
  manifestCache: manifestCacheForRegistry,
@@ -57,8 +81,10 @@ export function refreshCrossExtensionWiringForSession(pi: ExtensionAPI, ctx: Reg
57
81
  installCrossExtensionWiring(pi, ctx);
58
82
  return;
59
83
  }
84
+ const installGeneration = ctx.sessionGeneration;
60
85
  void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
61
86
  // LAZY: defer team-tool until cross-extension wiring fires
87
+ if (registryInstallSkipped(ctx, installGeneration, "session-refresh")) return;
62
88
  installCrewGlobalRegistry({
63
89
  manifestCache: ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd()),
64
90
  cwdProvider: () => ctx.currentCtx?.cwd ?? process.cwd(),
@@ -96,6 +96,32 @@ export function createRpcSignature(origin: string, channel: string, body: unknow
96
96
 
97
97
  // --- Verification ------------------------------------------------------------
98
98
 
99
+ /**
100
+ * Bounded insertion-ordered (FIFO) cache of nonces from VERIFIED signatures
101
+ * (R7-10, DR-04 round 7). A captured request replayed inside the signature
102
+ * validity window is rejected with reason "replayed nonce".
103
+ *
104
+ * Nonces are recorded ONLY after the HMAC itself validates, so an attacker
105
+ * cannot flood the cache with junk nonces to evict recorded ones (that would
106
+ * re-enable replay). Entries expire with the signature validity window and
107
+ * are pruned on every verification; size is FIFO-capped to bound memory.
108
+ */
109
+ const NONCE_CACHE_MAX_ENTRIES = 512;
110
+ const seenNonces = new Map<string, number>();
111
+
112
+ function pruneSeenNonces(now: number): void {
113
+ const expiredBefore = now - SIGNATURE_VALIDITY_MS;
114
+ for (const [nonce, seenAt] of seenNonces) {
115
+ if (seenAt >= expiredBefore) break; // insertion order ⇒ every later entry is newer
116
+ seenNonces.delete(nonce);
117
+ }
118
+ while (seenNonces.size > NONCE_CACHE_MAX_ENTRIES) {
119
+ const oldestNonce = seenNonces.keys().next().value;
120
+ if (oldestNonce === undefined) break;
121
+ seenNonces.delete(oldestNonce);
122
+ }
123
+ }
124
+
99
125
  /**
100
126
  * Verify an HMAC signature.
101
127
  *
@@ -121,7 +147,21 @@ export function verifyRpcSignature(payload: RpcSignedPayload, body: unknown): {
121
147
  }
122
148
 
123
149
  const expected = computeHmac(secret, payload, body);
124
- return timingSafeCompare(payload.signature, expected) ? { valid: true } : { valid: false, reason: "HMAC signature mismatch" };
150
+ if (!timingSafeCompare(payload.signature, expected)) {
151
+ return { valid: false, reason: "HMAC signature mismatch" };
152
+ }
153
+
154
+ // R7-10: replay protection — reject a nonce that was already verified within
155
+ // the validity window; record this one only after the HMAC validated.
156
+ pruneSeenNonces(now);
157
+ if (seenNonces.has(payload.nonce)) {
158
+ return {
159
+ valid: false,
160
+ reason: `replayed nonce (nonce already verified within the ${Math.round(SIGNATURE_VALIDITY_MS / 60000)}-minute validity window)`,
161
+ };
162
+ }
163
+ seenNonces.set(payload.nonce, now);
164
+ return { valid: true };
125
165
  }
126
166
 
127
167
  // --- Middleware --------------------------------------------------------------
@@ -135,7 +175,7 @@ export function verifyRpcSignature(payload: RpcSignedPayload, body: unknown): {
135
175
  */
136
176
  export function withHmacVerification<P extends { requestId: string }>(
137
177
  handler: (params: P) => unknown | Promise<unknown>,
138
- _channel: string,
178
+ channel: string,
139
179
  ): (raw: unknown) => unknown | Promise<unknown> {
140
180
  return (raw: unknown) => {
141
181
  const params = raw as P;
@@ -155,6 +195,17 @@ export function withHmacVerification<P extends { requestId: string }>(
155
195
  throw new Error("[pi-crew HMAC] Missing HMAC signature in RPC request.");
156
196
  }
157
197
 
198
+ // N-3 (DR-04 round 7): bind the arrival channel. The MAC covers the
199
+ // CLAIMED channel (payload.channel is mixed into computeHmac), so without
200
+ // this check a signature issued for channel A also validates on channel B
201
+ // — a cross-channel replay. The signature must have been issued for the
202
+ // exact channel this wrapper guards.
203
+ if (sigPayload.channel !== channel) {
204
+ throw new Error(
205
+ `[pi-crew HMAC] Signature channel mismatch: signature was issued for '${sigPayload.channel}', request arrived on '${channel}'.`,
206
+ );
207
+ }
208
+
158
209
  // Strip HMAC fields from body before verification (HMAC was signed over original params)
159
210
  const originalBody = stripHmacFields(params);
160
211
  const verification = verifyRpcSignature(sigPayload, originalBody);
@@ -293,27 +293,11 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
293
293
  true,
294
294
  );
295
295
  }
296
- await terminateLiveAgentsForRun(loaded.manifest.runId, "cancelled", appendEvent, loaded.manifest.eventsPath);
297
-
298
- // Best-effort: kill the async background runner process so it doesn't
299
- // overwrite the cancelled state while we hold the run lock.
300
- const asyncPid = loaded.manifest.async?.pid;
301
- if (asyncPid !== undefined && asyncPid > 0) {
302
- try {
303
- killProcessPid(asyncPid);
304
- // H1 (2026-08-10): informational event in async context — await the
305
- // async lock path (non-blocking event loop, ~1ms vs ~14ms sync).
306
- await appendEventAsync(loaded.manifest.eventsPath, {
307
- type: "async.kill_requested",
308
- runId: loaded.manifest.runId,
309
- message: "Sent SIGTERM to background runner process.",
310
- data: { pid: asyncPid },
311
- });
312
- } catch (error) {
313
- logInternalError("team-tool.handleCancel.killAsync", error, `runId=${loaded.manifest.runId},pid=${asyncPid}`);
314
- }
315
- }
316
-
296
+ // N-5 (DR-04 round 7): the live-agent termination + async-runner kill used
297
+ // to run HERE on the stale pre-lock `loaded` snapshot — a run that reached a
298
+ // terminal state during the before_cancel hook gap was still torn down and
299
+ // its background runner SIGTERMed. The signal decision now derives from the
300
+ // FRESH in-lock read below (see the gated block inside the lock).
317
301
  return withRunLockSync(loaded.manifest, () => {
318
302
  // R13-2 (2026-08-14): fresh re-read INSIDE the lock. `loaded` was captured
319
303
  // lock-free BEFORE the before_cancel hook + live-agent termination gap; a
@@ -349,6 +333,35 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
349
333
  true,
350
334
  );
351
335
  }
336
+ // N-5 (DR-04 round 7): signal ONLY when the FRESH in-lock status is still
337
+ // "running". Best-effort semantics are kept, but this is a synchronous
338
+ // run-lock callback (cannot await — H1 2026-08-10 pattern), so the
339
+ // async operations are fire-and-forget with errors logged. killProcessPid
340
+ // still signals synchronously here — while we hold the lock — so the async
341
+ // background runner cannot overwrite the cancelled state we write below.
342
+ if (fresh.manifest.status === "running") {
343
+ void terminateLiveAgentsForRun(fresh.manifest.runId, "cancelled", appendEvent, fresh.manifest.eventsPath).catch((error) =>
344
+ logInternalError("team-tool.handleCancel.terminateLiveAgents", error, `runId=${fresh.manifest.runId}`),
345
+ );
346
+ const asyncPid = fresh.manifest.async?.pid;
347
+ if (asyncPid !== undefined && asyncPid > 0) {
348
+ try {
349
+ killProcessPid(asyncPid);
350
+ // H1 (2026-08-10): informational event in async context — fire-and-forget
351
+ // on the async lock path (non-blocking event loop, ~1ms vs ~14ms sync).
352
+ void appendEventAsync(fresh.manifest.eventsPath, {
353
+ type: "async.kill_requested",
354
+ runId: fresh.manifest.runId,
355
+ message: "Sent SIGTERM to background runner process.",
356
+ data: { pid: asyncPid },
357
+ }).catch((error) =>
358
+ logInternalError("team-tool.handleCancel.killAsyncEvent", error, `runId=${fresh.manifest.runId},pid=${asyncPid}`),
359
+ );
360
+ } catch (error) {
361
+ logInternalError("team-tool.handleCancel.killAsync", error, `runId=${fresh.manifest.runId},pid=${asyncPid}`);
362
+ }
363
+ }
364
+ }
352
365
  const cancellableIds = new Set(abortResult.abortedIds);
353
366
  const cancelReason = cancelReasonFromParams(params);
354
367
  const cancelIntent = intentFromConfig(params.config);
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * API-5 facade dispatch — domain router registry.
3
3
  *
4
- * `domainForAction(action)` maps each of the 54 action strings to one of 5
4
+ * `domainForAction(action)` maps each of the 57 action strings to one of 5
5
5
  * domains. `handleTeamTool` (in team-tool.ts) calls this to pick the right
6
6
  * domain router, preserving the exact same public API and zero caller changes.
7
7
  *
8
- * 54→5 mapping:
9
- * run (9): run, parallel, plan, orchestrate, resume, retry, wait, steer, goal
8
+ * 57→5 mapping (kept in sync by test/unit/schema/team-tool-docs-sync.test.ts —
9
+ * DR-01: this header drifted to 54/9/16 while the schema had 57/10/17):
10
+ * run (10): run, parallel, plan, plans, orchestrate, resume, retry, wait, steer, goal
10
11
  * status (17): status, list, get, events, artifacts, breakdown, summary, graph,
11
12
  * search, health, worktrees, checkpoint, cache, explain, onboard, recommend, help
12
13
  * control (7): cancel, invalidate, respond, cleanup, prune, forget, doctor
@@ -24,7 +25,7 @@ import { handleStatusDomain } from "./status.ts";
24
25
  export { handleAutomateDomain, handleControlDomain, handleManageDomain, handleRunDomain, handleStatusDomain };
25
26
 
26
27
  const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
27
- // run domain (9)
28
+ // run domain (10)
28
29
  run: "run",
29
30
  plans: "run",
30
31
  parallel: "run",
@@ -64,7 +65,7 @@ const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
64
65
  forget: "control",
65
66
  doctor: "control",
66
67
 
67
- // manage domain (16)
68
+ // manage domain (17)
68
69
  create: "manage",
69
70
  update: "manage",
70
71
  delete: "manage",