pi-crew 0.10.3 → 0.10.5

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 (64) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10684 -6883
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +8 -3
  10. package/scripts/README.md +4 -3
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +1 -0
  12. package/skills/real-test-pi-crew/SKILL.md +153 -6
  13. package/src/config/migration-validator.ts +113 -0
  14. package/src/extension/cross-extension-rpc.ts +3 -7
  15. package/src/extension/register.ts +13 -0
  16. package/src/extension/registration/observability.ts +3 -7
  17. package/src/extension/registration/subagent-tools.ts +3 -7
  18. package/src/extension/registration/team-tool.ts +3 -7
  19. package/src/extension/registration/ui.ts +3 -8
  20. package/src/extension/registration/viewers.ts +3 -10
  21. package/src/extension/team-manager-command.ts +3 -7
  22. package/src/extension/team-tool/api/agent-control.ts +17 -10
  23. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  24. package/src/extension/team-tool/api/mailbox.ts +33 -20
  25. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  26. package/src/extension/team-tool/api/task-claims.ts +8 -7
  27. package/src/extension/team-tool/cancel.ts +6 -0
  28. package/src/extension/team-tool/handle-settings.ts +4 -1
  29. package/src/extension/team-tool/run.ts +3 -7
  30. package/src/extension/team-tool/status.ts +5 -0
  31. package/src/extension/team-tool.ts +6 -14
  32. package/src/hooks/registry.ts +3 -0
  33. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  34. package/src/runtime/background-runner.ts +62 -42
  35. package/src/runtime/broker/crew-broker.ts +112 -441
  36. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  37. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  38. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  39. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  40. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  41. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  42. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  43. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  44. package/src/runtime/child-pi/child-pi.ts +15 -0
  45. package/src/runtime/finalize-run.ts +15 -7
  46. package/src/runtime/foreground-control.ts +19 -6
  47. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  48. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  49. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  50. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  51. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  52. package/src/runtime/model/pi-args.ts +6 -1
  53. package/src/runtime/plan-replan.ts +3 -0
  54. package/src/runtime/run-tracker.ts +9 -8
  55. package/src/runtime/stale-reconciler.ts +28 -3
  56. package/src/runtime/supervisor-contact.ts +3 -0
  57. package/src/runtime/task-runner/child-executor.ts +33 -0
  58. package/src/runtime/team-runner.ts +3 -3
  59. package/src/state/stores/ownership-map.ts +5 -4
  60. package/src/state/stores/plan-store.ts +12 -0
  61. package/src/state/stores/state-store.ts +5 -0
  62. package/src/ui/powerbar-publisher.ts +3 -7
  63. package/src/ui/run-action-dispatcher.ts +7 -10
  64. package/src/ui/settings-overlay.ts +4 -1
@@ -23,14 +23,10 @@ import { listRecentRuns } from "./run-index.ts";
23
23
  import type { PiTeamsToolResult } from "./tool-result.ts";
24
24
 
25
25
  type ExecuteTeamRunFn = typeof _executeTeamRunFn;
26
- let _cachedExecuteTeamRun: ExecuteTeamRunFn | undefined;
27
26
  async function executeTeamRun(...args: Parameters<ExecuteTeamRunFn>): Promise<Awaited<ReturnType<ExecuteTeamRunFn>>> {
28
- if (_cachedExecuteTeamRun === undefined) {
29
- // LAZY: heavy runtime — defer 1.4s import cost until team run actually executes.
30
- const mod = await import("../runtime/team-runner.ts");
31
- _cachedExecuteTeamRun = mod.executeTeamRun;
32
- }
33
- return _cachedExecuteTeamRun(...args);
27
+ // LAZY: heavy runtime — defer 1.4s import cost until team run actually executes.
28
+ const mod = await import("../runtime/team-runner.ts");
29
+ return mod.executeTeamRun(...args);
34
30
  }
35
31
 
36
32
  import { directTeamAndWorkflowFromRun } from "../runtime/direct-run.ts";
@@ -44,14 +40,10 @@ import { buildParentContext, formatScoped, result, type TeamContext } from "./te
44
40
  import type { handleRun as _handleRunFn } from "./team-tool/run.ts";
45
41
 
46
42
  type HandleRunFn = typeof _handleRunFn;
47
- let _cachedHandleRun: HandleRunFn | undefined;
48
43
  async function handleRun(...args: Parameters<HandleRunFn>): Promise<Awaited<ReturnType<HandleRunFn>>> {
49
- if (_cachedHandleRun === undefined) {
50
- // LAZY: run.ts pulls in spawnBackgroundTeamRun + resolveCrewRuntime; also avoids jiti import race in child-process contexts.
51
- const mod = await import("./team-tool/run.ts");
52
- _cachedHandleRun = mod.handleRun;
53
- }
54
- return _cachedHandleRun(...args);
44
+ // LAZY: run.ts pulls in spawnBackgroundTeamRun + resolveCrewRuntime; also avoids jiti import race in child-process contexts.
45
+ const mod = await import("./team-tool/run.ts");
46
+ return mod.handleRun(...args);
55
47
  }
56
48
 
57
49
  import { t } from "../i18n.ts";
@@ -179,6 +179,9 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
179
179
  }
180
180
 
181
181
  export function appendHookEvent(manifest: TeamRunManifest, report: HookExecutionReport): void {
182
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — hook.executed
183
+ // events are read back synchronously (recovery-hooks tests, hooks audit
184
+ // display) and are low-frequency (per hook execution).
182
185
  appendEvent(manifest.eventsPath, {
183
186
  type: "hook.executed",
184
187
  runId: manifest.runId,
@@ -32,7 +32,7 @@ import * as path from "node:path";
32
32
  import { type Static, Type } from "@sinclair/typebox";
33
33
  import { defineTool, type ExtensionAPI, type ToolDefinition } from "../extension/pi-api.ts";
34
34
  import { EngineManager, type ExecuteResult } from "../runtime/scratchpad/engine.ts";
35
- import { appendEventFireAndForget } from "../state/event-log/event-log.ts";
35
+ import { appendEventBuffered, appendEventFireAndForget } from "../state/event-log/event-log.ts";
36
36
  // D5/MAJOR-S1: PI_CREW_PARENT_PID + PI_CREW_GUEST build the guest's zombie-
37
37
  // backstop env (PI_CREW_KIND_ENV is already exported above).
38
38
  import { type ArtifactWriteOptions, writeArtifact } from "../state/stores/artifact-store.ts";
@@ -84,12 +84,12 @@ function emitScratchpadMetric(
84
84
  // never break the cell. The real appendEventFireAndForget already catches
85
85
  // async; this guard also absorbs a sync throw from a bad writer.
86
86
  try {
87
- appendEvent(eventsPath, {
87
+ appendEventBuffered(eventsPath, {
88
88
  type,
89
89
  runId,
90
90
  taskId: env[PI_CREW_TASK_ID_ENV],
91
91
  data,
92
- });
92
+ }).catch((e) => logInternalError("scratchpad_lifecycle.buffered", e, `type=${type}`));
93
93
  } catch {
94
94
  // Metric is best-effort — drop the event, never the cell.
95
95
  }
@@ -5,12 +5,11 @@ import { loadConfig } from "../config/config.ts";
5
5
  import { getCrewEnv } from "../config/env-vars.ts";
6
6
  import { atomicWriteFile } from "../state/atomic-write.ts";
7
7
  import { withRunLockSync } from "../state/coordination/locks.ts";
8
- import { appendEvent, appendEventFireAndForget } from "../state/event-log/event-log.ts";
8
+ import { appendEvent, appendEventBuffered, appendEventFireAndForget } from "../state/event-log/event-log.ts";
9
9
  import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunStatus } from "../state/stores/state-store.ts";
10
10
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
11
11
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
12
12
  import { errorMessage } from "../utils/guards.ts";
13
- import { projectCrewRoot } from "../utils/paths.ts";
14
13
  import { assertSafePathId } from "../utils/safe-paths.ts";
15
14
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
16
15
  // Heavy runtime — lazy-loaded to avoid pulling team-runner into background-runner
@@ -18,8 +17,6 @@ import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows
18
17
  import { primePeerDep } from "./peer-dep.ts";
19
18
  import type { executeTeamRun as ExecuteTeamRunFn } from "./team-runner.ts";
20
19
 
21
- let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
22
-
23
20
  /** Maximum runtime for a single background run before the watchdog force-aborts
24
21
  * it. Prevents zombie background-runner processes when a team run hangs forever
25
22
  * (e.g. a hung child Pi process, a stuck lock, or a test that spawns a run
@@ -33,16 +30,13 @@ const MAX_BACKGROUND_RUN_MS = (() => {
33
30
  return Number.isFinite(env) && env > 0 ? env : 2 * 60 * 60 * 1000;
34
31
  })();
35
32
  async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
36
- if (!_cachedExecuteTeamRun) {
37
- // FIX (split-scope install): prime the ESM peer dep BEFORE team-runner is
38
- // imported, so its transitive skill-instructions.ts can read getAgentDir()
39
- // from the primed cache instead of crashing on `Cannot find module`.
40
- await primePeerDep().catch(() => undefined);
41
- // LAZY: avoid pulling team-runner into background-runner at module load time.
42
- const mod = await import("./team-runner.ts");
43
- _cachedExecuteTeamRun = mod.executeTeamRun;
44
- }
45
- return _cachedExecuteTeamRun(...args);
33
+ // FIX (split-scope install): prime the ESM peer dep BEFORE team-runner is
34
+ // imported, so its transitive skill-instructions.ts can read getAgentDir()
35
+ // from the primed cache instead of crashing on `Cannot find module`.
36
+ await primePeerDep().catch(() => undefined);
37
+ // LAZY: avoid pulling team-runner into background-runner at module load time.
38
+ const mod = await import("./team-runner.ts");
39
+ return mod.executeTeamRun(...args);
46
40
  }
47
41
 
48
42
  import { logInternalError } from "../utils/internal-error.ts";
@@ -165,6 +159,27 @@ export function signalEventType(sig: string): "async.signal" | "async.failed" {
165
159
  return BENIGN_SIGNALS.has(sig) ? "async.signal" : "async.failed";
166
160
  }
167
161
 
162
+ /**
163
+ * Scope-aware diagnostic paths for the background runner (issue #55, follows
164
+ * the #54 fix in run-tracker.ts). Runs created in a markerless (non-git) cwd
165
+ * live under userCrewRoot() — resolved via the same createRunPaths/
166
+ * scopeBaseRoot chain used at run CREATION — while project cwds keep .crew/ or
167
+ * the .pi/teams/ fallback (issue #29). Pure path math: createRunPaths asserts
168
+ * the runId (R11-2 boundary hardening preserved) and never touches the fs.
169
+ *
170
+ * These must stay (cwd, runId)-derived: both sites run BEFORE the manifest is
171
+ * loaded (the console redirect is the FIRST thing main() does; exitCodePath is
172
+ * computed at module load), so manifest.stateRoot is not available yet.
173
+ */
174
+ export function backgroundLogPath(cwd: string, runId: string): string {
175
+ return path.join(createRunPaths(cwd, runId).stateRoot, "background.log");
176
+ }
177
+
178
+ /** exit-code.txt sibling of background.log — same scope resolution (#55). */
179
+ export function backgroundExitCodePath(cwd: string, runId: string): string {
180
+ return path.join(createRunPaths(cwd, runId).stateRoot, "exit-code.txt");
181
+ }
182
+
168
183
  /**
169
184
  * Fire-and-forget event log for signal handlers. Extracted to module level
170
185
  * (from inside main()) so the exported SIGINT handler installer (test seam)
@@ -256,11 +271,11 @@ export function startInterruptGuard(
256
271
  /* best-effort ack — interruptHandled gate prevents re-fire */
257
272
  }
258
273
 
259
- appendEvent(manifest.eventsPath, {
274
+ appendEventBuffered(manifest.eventsPath, {
260
275
  type: "async.interrupt_detected",
261
276
  runId: manifest.runId,
262
277
  message: "Background runner detected foreground interrupt — killing child processes and exiting.",
263
- });
278
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.interrupt_detected"));
264
279
  // FIX: Terminate ALL child-pi processes IMMEDIATELY before exiting.
265
280
  // Previously this was missing, causing orphaned child processes to run forever
266
281
  // after the background-runner exited. terminateActiveChildPiProcesses sends
@@ -311,7 +326,7 @@ function setupUnhandledRejectionGuard(
311
326
  console.error("[background-runner] Stack:", reason instanceof Error ? reason.stack : "N/A");
312
327
  try {
313
328
  if (state.eventsPath && state.runId) {
314
- appendEvent(state.eventsPath, {
329
+ appendEventBuffered(state.eventsPath, {
315
330
  type: "async.failed",
316
331
  runId: state.runId,
317
332
  message: `Unhandled rejection: ${message}`,
@@ -320,7 +335,7 @@ function setupUnhandledRejectionGuard(
320
335
  stack: reason instanceof Error ? reason.stack : undefined,
321
336
  handled: false,
322
337
  },
323
- });
338
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=unhandledRejection"));
324
339
  }
325
340
  } catch (appendErr) {
326
341
  console.error("[background-runner] Failed to write async.failed event:", appendErr);
@@ -377,12 +392,12 @@ function runCleanup(
377
392
  console.log(`[background-runner] runCleanup: unregisterWorker error: ${errorMessage(error)}`);
378
393
  if (eventsPath) {
379
394
  try {
380
- appendEvent(eventsPath, {
395
+ appendEventBuffered(eventsPath, {
381
396
  type: "background.unregister_worker_failed",
382
397
  runId: argValue("--run-id") ?? "unknown",
383
398
  message: `unregisterWorker failed: ${errorMessage(error)}`,
384
399
  data: { pid: process.pid },
385
- });
400
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=background.unregister_worker_failed"));
386
401
  } catch {
387
402
  /* best-effort */
388
403
  }
@@ -426,10 +441,12 @@ async function main(): Promise<void> {
426
441
  // best-effort try/catch so the hardening is never silently swallowed).
427
442
  assertSafePathId("runId", _runId);
428
443
  try {
429
- // Use projectCrewRoot() so the background log lives next to the
430
- // manifest in either .crew/state/runs/ or .pi/teams/state/runs/
431
- // depending on the project's chosen layout (issue #29).
432
- const logPath = path.join(projectCrewRoot(_cwd), "state", "runs", _runId, "background.log");
444
+ // Scope-aware (issue #55): resolve via createRunPaths so the log lands
445
+ // next to the manifest for BOTH project cwds (.crew/ or the .pi/teams/
446
+ // fallback, issue #29) and markerless cwds (user scope, issue #54). The
447
+ // previous projectCrewRoot join silently dropped the console redirect
448
+ // for user-scope runs — exactly the log needed to diagnose them.
449
+ const logPath = backgroundLogPath(_cwd, _runId);
433
450
  logFd = fs.openSync(logPath, "a");
434
451
  const origWrite =
435
452
  (_prefix: string) =>
@@ -483,8 +500,11 @@ async function main(): Promise<void> {
483
500
  // IIFE runs at MODULE LOAD, so an unsafe runId throws before the runner
484
501
  // starts (intended fail-fast, matching run-import.ts:105 pattern).
485
502
  assertSafePathId("runId", runId);
486
- // Use projectCrewRoot() to honour the .pi/teams/ fallback (issue #29).
487
- return path.join(projectCrewRoot(cwd), "state", "runs", runId, "exit-code.txt");
503
+ // Scope-aware (issue #55): same resolution as backgroundLogPath — project
504
+ // scope (.crew/ or .pi/teams/, issue #29) or user scope (#54). The
505
+ // previous projectCrewRoot join never landed exit-code.txt for user-scope
506
+ // runs, hiding non-zero exit diagnostics.
507
+ return backgroundExitCodePath(cwd, runId);
488
508
  })();
489
509
  if (exitCodePath) {
490
510
  process.on("exit", (code) => {
@@ -538,12 +558,12 @@ async function main(): Promise<void> {
538
558
  const runId = argValue("--run-id");
539
559
  if (runId && manifest.eventsPath) {
540
560
  try {
541
- appendEvent(manifest.eventsPath, {
561
+ appendEventBuffered(manifest.eventsPath, {
542
562
  type: "async.sigterm_received_graceful_shutdown",
543
563
  runId,
544
564
  message: `SIGTERM received, graceful shutdown via abort pid=${process.pid}`,
545
565
  data: { pid: process.pid, ppid: process.ppid },
546
- });
566
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.sigterm_received_graceful_shutdown"));
547
567
  } catch {
548
568
  /* best-effort */
549
569
  }
@@ -596,12 +616,12 @@ async function main(): Promise<void> {
596
616
  const codeStr = code === undefined ? "<none>" : String(code);
597
617
  if (runId2 && manifest.eventsPath) {
598
618
  try {
599
- appendEvent(manifest.eventsPath, {
619
+ appendEventBuffered(manifest.eventsPath, {
600
620
  type: "async.exit",
601
621
  runId: runId2,
602
622
  message: `Background runner exit(${codeStr}) pid=${process.pid}`,
603
623
  data: { code, pid: process.pid },
604
- });
624
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.exit"));
605
625
  } catch {
606
626
  /* best-effort */
607
627
  }
@@ -635,11 +655,11 @@ async function main(): Promise<void> {
635
655
  // NOTE: intentionally no unref() — the guard keeps the event loop alive
636
656
  // to prevent premature worker exit. See parent-guard.ts:86 for rationale.
637
657
 
638
- appendEvent(manifest.eventsPath, {
658
+ appendEventBuffered(manifest.eventsPath, {
639
659
  type: "async.started",
640
660
  runId: manifest.runId,
641
661
  data: { pid: process.pid },
642
- });
662
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.started"));
643
663
  debugLog(`[background-runner] async.started written, pid=${process.pid}`);
644
664
  writeAsyncStartMarker(manifest, {
645
665
  pid: process.pid,
@@ -663,12 +683,12 @@ async function main(): Promise<void> {
663
683
  const watchdogTimer = setTimeout(() => {
664
684
  console.error(`[background-runner] WATCHDOG: run ${runId} exceeded ${MAX_BACKGROUND_RUN_MS}ms — aborting (zombie prevention)`);
665
685
  try {
666
- appendEvent(manifest.eventsPath, {
686
+ appendEventBuffered(manifest.eventsPath, {
667
687
  type: "async.watchdog_fired",
668
688
  runId,
669
689
  message: `Run exceeded ${MAX_BACKGROUND_RUN_MS}ms and was force-aborted to prevent a zombie background-runner process.`,
670
690
  data: { maxRunMs: MAX_BACKGROUND_RUN_MS },
671
- });
691
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.watchdog_fired"));
672
692
  } catch {
673
693
  /* best-effort event log */
674
694
  }
@@ -825,12 +845,12 @@ async function main(): Promise<void> {
825
845
  updatedAt: new Date().toISOString(),
826
846
  };
827
847
  await saveRunManifestAsync(manifest);
828
- appendEvent(manifest.eventsPath, {
848
+ appendEventBuffered(manifest.eventsPath, {
829
849
  type: "runtime.resolved",
830
850
  runId: manifest.runId,
831
851
  message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`,
832
852
  data: { runtimeResolution, async: true },
833
- });
853
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=runtime.resolved"));
834
854
  if (runtime.safety === "blocked")
835
855
  throw new Error(runtime.reason ?? "Child worker execution is disabled; refusing to create no-op scaffold subagents.");
836
856
  const executeWorkers = runtime.kind !== "scaffold";
@@ -890,11 +910,11 @@ async function main(): Promise<void> {
890
910
  } // close if (!earlyResult) — team-run setup+execute done; earlyResult path skips to here
891
911
  manifest = result!.manifest;
892
912
  tasks = result!.tasks;
893
- appendEvent(manifest.eventsPath, {
913
+ appendEventBuffered(manifest.eventsPath, {
894
914
  type: "async.completed",
895
915
  runId: manifest.runId,
896
916
  data: { status: manifest.status, tasks: tasks.length },
897
- });
917
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.completed"));
898
918
  console.log(`[background-runner] async.completed written, status=${manifest.status}`);
899
919
  if (manifest.status === "failed" || manifest.status === "cancelled" || manifest.status === "blocked") process.exitCode = 1;
900
920
  } catch (error) {
@@ -915,11 +935,11 @@ async function main(): Promise<void> {
915
935
  const fresh = loaded?.manifest ?? manifest;
916
936
  if (fresh) {
917
937
  manifest = updateRunStatus(fresh, "failed", message);
918
- appendEvent(manifest.eventsPath, {
938
+ appendEventBuffered(manifest.eventsPath, {
919
939
  type: "async.failed",
920
940
  runId: manifest.runId,
921
941
  message,
922
- });
942
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=updateFailed"));
923
943
  }
924
944
  return fresh;
925
945
  },
@@ -988,12 +1008,12 @@ try {
988
1008
  const mRunId = argValue("--run-id");
989
1009
  if (mCwd && mRunId) {
990
1010
  const mEventsPath = createRunPaths(mCwd, mRunId).eventsPath;
991
- appendEvent(mEventsPath, {
1011
+ appendEventBuffered(mEventsPath, {
992
1012
  type: "async.failed",
993
1013
  runId: mRunId,
994
1014
  message: errorMessage(err),
995
1015
  data: { stack: err instanceof Error ? err.stack : undefined },
996
- });
1016
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=catch"));
997
1017
  }
998
1018
  } catch {
999
1019
  /* best-effort — don't let event-write failure mask the original error */