pi-crew 0.6.0 → 0.6.3

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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. package/test-integration-check.ts +4 -0
@@ -5,7 +5,7 @@ import type {
5
5
  ExtensionAPI,
6
6
  ExtensionContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
- import { loadConfig } from "../config/config.ts";
8
+ import { asRecord, loadConfig } from "../config/config.ts";
9
9
  import { applyCrewSettingsToConfig, loadCrewSettings } from "../runtime/settings-store.ts";
10
10
  // 2.7: Lazy-load LiveRunSidebar — only constructed when the user actually opens
11
11
  // a live run sidebar overlay. The class pulls in transcript-viewer and other
@@ -19,7 +19,7 @@ import {
19
19
  import { registerAutonomousPolicy } from "./autonomous-policy.ts";
20
20
  import { registerCleanupHandler } from "./crew-cleanup.ts";
21
21
  import type { ScheduledJob } from "../runtime/scheduler.ts";
22
- import { clearHooks } from "../hooks/registry.ts";
22
+ import { clearHooksScoped } from "../hooks/registry.ts";
23
23
  import { uninstallCrewGlobalRegistry } from "./team-tool.ts";
24
24
  import { notifyActiveRuns } from "./session-summary.ts";
25
25
 
@@ -87,6 +87,7 @@ import {
87
87
  projectCrewRoot,
88
88
  userCrewRoot,
89
89
  } from "../utils/paths.ts";
90
+ import { resolveContainedPath } from "../utils/safe-paths.ts";
90
91
  import { resetTimings, time } from "../utils/timings.ts";
91
92
  import {
92
93
  type PiCrewRpcHandle,
@@ -108,6 +109,7 @@ import {
108
109
  import { registerSubagentTools } from "./registration/subagent-tools.ts";
109
110
  import { registerTeamTool } from "./registration/team-tool.ts";
110
111
  import { handleTeamTool } from "./team-tool.ts";
112
+ import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
111
113
 
112
114
  let _cachedOTLPExporter: typeof OTLPExporterType | undefined;
113
115
  async function importOTLPExporter(): Promise<typeof OTLPExporterType> {
@@ -132,6 +134,8 @@ import {
132
134
  } from "../runtime/crash-recovery.ts";
133
135
  import { appendDeadletter } from "../runtime/deadletter.ts";
134
136
  import { HeartbeatWatcher } from "../runtime/heartbeat-watcher.ts";
137
+ import { cleanupOrphanTempDirs, cleanupLegacyOrphanTempDirs } from "../runtime/pi-args.ts";
138
+ import { cleanupOrphanWorkers } from "../runtime/orphan-worker-registry.ts";
135
139
  import { reconcileOrphanedTempWorkspaces } from "../runtime/stale-reconciler.ts";
136
140
 
137
141
  let _cachedCrashRecovery:
@@ -183,10 +187,14 @@ export function registerPiTeams(pi: ExtensionAPI): void {
183
187
  const disposeI18n = initI18n(pi);
184
188
  resetTimings();
185
189
  time("register:start");
186
- const globalStore = globalThis as Record<string, unknown>;
187
- const runtimeCleanupStoreKey = "__piCrewRuntimeCleanup";
190
+ const globalStore = globalThis as Record<string | symbol, unknown>;
191
+ const runtimeCleanupStoreKey = Symbol("__piCrewRuntimeCleanup");
188
192
  const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
189
193
  time("register:init");
194
+ // Best-effort cleanup of the previous runtime instance. Errors are logged but
195
+ // do not halt new registration — a failing cleanup from a prior instance is
196
+ // preferable to leaving pi-crew unregistered, and any stale state from the
197
+ // previous instance will be reconciled when the new instance initializes.
190
198
  if (typeof previousRuntimeCleanup === "function") {
191
199
  try {
192
200
  previousRuntimeCleanup();
@@ -443,6 +451,34 @@ export function registerPiTeams(pi: ExtensionAPI): void {
443
451
  cleanupOrphanedTempDirs:
444
452
  config.reliability?.cleanupOrphanedTempDirs,
445
453
  });
454
+ // Layer 4: also clean orphan temp dirs under
455
+ // ~/.pi/agent/pi-crew/tmp/ that the SIGKILL'd parent
456
+ // processes left behind. Catches anything Layers 1-3 missed.
457
+ const orphanResult = cleanupOrphanTempDirs();
458
+ if (orphanResult.cleaned > 0) {
459
+ notifyOperator({
460
+ id: `layer4_temp_cleanup_${Date.now()}`,
461
+ severity: "info",
462
+ source: "temp-cleanup",
463
+ title: `Layer 4: cleaned ${orphanResult.cleaned} orphan temp dir(s)`,
464
+ body: `~/.pi/agent/pi-crew/tmp/ orphans older than 24h removed (scanned ${orphanResult.scanned}, failed ${orphanResult.failed}).`,
465
+ });
466
+ }
467
+ // Layer 5: clean legacy /tmp/pi-crew-* prompt/task orphans
468
+ // from before commit 8ba270d moved temp dirs out of /tmp.
469
+ // The existing reconcileOrphanedTempWorkspaces only cleans
470
+ // dirs containing .crew/state/runs/ (run-state dirs), so
471
+ // prompt/task orphans are never touched by Layer 3.
472
+ const legacyResult = cleanupLegacyOrphanTempDirs();
473
+ if (legacyResult.cleaned > 0) {
474
+ notifyOperator({
475
+ id: `layer5_legacy_temp_cleanup_${Date.now()}`,
476
+ severity: "info",
477
+ source: "temp-cleanup",
478
+ title: `Layer 5: cleaned ${legacyResult.cleaned} legacy /tmp/pi-crew-* orphan(s)`,
479
+ body: `Pre-fix /tmp/pi-crew-* prompt/task orphans (no .crew/state/runs/, >24h) removed (scanned ${legacyResult.scanned}, failed ${legacyResult.failed}).`,
480
+ });
481
+ }
446
482
  } catch (error) {
447
483
  logInternalError("register.tempAutoRepair", error);
448
484
  }
@@ -478,13 +514,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
478
514
  );
479
515
  }
480
516
  };
481
- const autoRecoveryLast = new Map<string, number>();
517
+ const autoRecoveryLast = new Map<string, { insertedAt: number; lastAccessAt: number }>();
482
518
  // FIX (Round 22, defensive cap): Bound the cooldown-gate Map. Each run
483
519
  // contributes up to 4 keys (one per maybeNotifyHealth kind). Without a cap,
484
520
  // a long-running pi session that runs thousands of teams accumulates
485
- // thousands of entries. Eviction: oldest insertion first — matches the
486
- // 5-minute cooldown gate semantics, since once the gate has expired the
487
- // entry is irrelevant.
521
+ // thousands of entries. Eviction: oldest lastAccessAt first — uses LRU-like
522
+ // semantics so entries that are still being actively accessed (re-accessed
523
+ // before eviction) survive longer. This is fairer than insertion-order
524
+ // eviction under high churn where many entries expire naturally before
525
+ // being re-accessed.
488
526
  const AUTO_RECOVERY_LAST_MAX_ENTRIES = 1000;
489
527
  const configureDeliveryCoordinator = (): void => {
490
528
  deliveryCoordinator?.dispose();
@@ -812,8 +850,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
812
850
  .then(({ startForegroundWatchdog }) => {
813
851
  startForegroundWatchdog({ pi, cwd: ctx.cwd, runId });
814
852
  })
815
- .catch(() => {
816
- /* non-critical */
853
+ .catch((error) => {
854
+ logInternalError("register.foreground-watchdog-import", error);
817
855
  });
818
856
  }
819
857
  setImmediate(() => {
@@ -823,7 +861,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
823
861
  error instanceof Error ? error.message : String(error);
824
862
  if (runId) {
825
863
  try {
826
- const loaded = loadRunManifestById(ctx.cwd, runId);
864
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency. Post-run status updates tolerate slight staleness.
827
865
  if (
828
866
  loaded &&
829
867
  loaded.manifest.status !== "completed" &&
@@ -877,7 +915,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
877
915
  }
878
916
  }
879
917
  if (ownerCurrent && runId) {
880
- const loaded = loadRunManifestById(ctx.cwd, runId);
918
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency. Post-run status updates tolerate slight staleness.
881
919
  const status = loaded?.manifest.status ?? "finished";
882
920
  const level =
883
921
  status === "failed" || status === "blocked"
@@ -971,9 +1009,25 @@ export function registerPiTeams(pi: ExtensionAPI): void {
971
1009
  const registry = (globalThis as Record<symbol | string, unknown>)[
972
1010
  CREW_REGISTRY_KEY
973
1011
  ] as Record<string, unknown>;
974
- registry.getRecord = (runId: string) =>
1012
+ // Phase 3b (defensive): Validate registry structure before patching methods.
1013
+ // If a previous occupant left a non-conforming object, replace it entirely.
1014
+ // This prevents runtime failures if getRecord/listRuns/etc. are called on a
1015
+ // malformed predecessor value.
1016
+ if (
1017
+ registry === null ||
1018
+ typeof registry !== "object" ||
1019
+ Array.isArray(registry)
1020
+ ) {
1021
+ (globalThis as Record<symbol | string, unknown>)[
1022
+ CREW_REGISTRY_KEY
1023
+ ] = {};
1024
+ }
1025
+ const validatedRegistry = (globalThis as Record<symbol | string, unknown>)[
1026
+ CREW_REGISTRY_KEY
1027
+ ] as Record<string, unknown>;
1028
+ validatedRegistry.getRecord = (runId: string) =>
975
1029
  manifestCacheForRegistry.get(runId);
976
- registry.listRuns = () =>
1030
+ validatedRegistry.listRuns = () =>
977
1031
  manifestCacheForRegistry
978
1032
  .list(100)
979
1033
  .map(
@@ -987,7 +1041,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
987
1041
  goal: m.goal,
988
1042
  }),
989
1043
  );
990
- registry.appendEvent = (
1044
+ validatedRegistry.appendEvent = (
991
1045
  runId: string,
992
1046
  event: Record<string, unknown>,
993
1047
  ) => {
@@ -1003,7 +1057,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1003
1057
  ),
1004
1058
  );
1005
1059
  };
1006
- registry.waitForAll = async (runId: string) => {
1060
+ validatedRegistry.waitForAll = async (runId: string) => {
1007
1061
  // LAZY: state-store only needed for post-completion polling (waitForAll) and sync hasRunning check; avoid at startup.
1008
1062
  const { loadRunManifestById } = await import(
1009
1063
  "../state/state-store.ts"
@@ -1022,7 +1076,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1022
1076
  while (!check())
1023
1077
  await new Promise((resolve) => setTimeout(resolve, 500));
1024
1078
  };
1025
- registry.hasRunning = async (runId: string) => {
1079
+ validatedRegistry.hasRunning = async (runId: string) => {
1026
1080
  const manifest = manifestCacheForRegistry.get(runId);
1027
1081
  if (!manifest) return false;
1028
1082
  // LAZY: state-store only needed in hasRunning; avoid at startup.
@@ -1112,7 +1166,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1112
1166
  otlpExporter = undefined;
1113
1167
  metricRegistry = undefined;
1114
1168
  deliveryCoordinator?.dispose();
1115
- clearHooks();
1169
+ clearHooksScoped();
1116
1170
  uninstallCrewGlobalRegistry();
1117
1171
  overflowTracker?.dispose();
1118
1172
  deliveryCoordinator = undefined;
@@ -1150,10 +1204,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1150
1204
  notifyActiveRuns(ctx);
1151
1205
 
1152
1206
  // Auto-cancel orphaned runs from dead sessions
1153
- // Extract sessionId from context — validate runtime type instead of unsafe cast.
1207
+ // Extract sessionId from context — use Object.getOwnPropertyDescriptor
1208
+ // to safely access property without triggering Proxy traps, then validate.
1154
1209
  const rawSessionId =
1155
- typeof ctx === "object" && ctx !== null && "sessionId" in ctx
1156
- ? (ctx as Record<string, unknown>).sessionId
1210
+ typeof ctx === "object" && ctx !== null
1211
+ ? Object.getOwnPropertyDescriptor(ctx, "sessionId")?.value
1157
1212
  : undefined;
1158
1213
  const currentSessionId =
1159
1214
  typeof rawSessionId === "string" && rawSessionId.length > 0
@@ -1219,6 +1274,54 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1219
1274
  }
1220
1275
  }
1221
1276
 
1277
+ // Startup cleanup (Fix A): run orphan-temp-dir cleanup
1278
+ // immediately on session_start so we don't wait 5 minutes
1279
+ // for the first timer tick. Especially important after
1280
+ // a SIGKILL'd previous session that left thousands of
1281
+ // orphan temp dirs behind.
1282
+ try {
1283
+ const orphanTmp = cleanupOrphanTempDirs();
1284
+ const legacyTmp = cleanupLegacyOrphanTempDirs();
1285
+ if (orphanTmp.cleaned > 0 || legacyTmp.cleaned > 0) {
1286
+ notifyOperator({
1287
+ id: `startup_temp_cleanup_${Date.now()}`,
1288
+ severity: "info",
1289
+ source: "temp-cleanup",
1290
+ title: `Startup cleanup: removed ${orphanTmp.cleaned + legacyTmp.cleaned} orphan temp dir(s)`,
1291
+ body: `${orphanTmp.cleaned} from ~/.pi/agent/pi-crew/tmp/ + ${legacyTmp.cleaned} legacy /tmp/pi-crew-*`,
1292
+ });
1293
+ }
1294
+ } catch (error) {
1295
+ logInternalError(
1296
+ "register.sessionStart.startupTempCleanup",
1297
+ error,
1298
+ );
1299
+ }
1300
+
1301
+ // Orphan worker cleanup (Fix B): kill stale background-runner
1302
+ // processes from previous (SIGKILL'd) sessions. Workers
1303
+ // detached via setsid+unref outlive the spawning pi
1304
+ // session, and the per-worker parent-guard is intentionally
1305
+ // disabled for background-runner (BUG #17 design). So
1306
+ // orphans can only be cleaned from the next session_start.
1307
+ try {
1308
+ const orphanWorkers = cleanupOrphanWorkers(currentSessionId);
1309
+ if (orphanWorkers.killed > 0) {
1310
+ notifyOperator({
1311
+ id: `orphan_workers_cleanup`,
1312
+ severity: "info",
1313
+ source: "worker-cleanup",
1314
+ title: `Cleaned up ${orphanWorkers.killed} orphan worker(s)`,
1315
+ body: `Background workers from previous (SIGKILL'd) sessions were terminated (pruned ${orphanWorkers.pruned} dead, kept ${orphanWorkers.kept}).`,
1316
+ });
1317
+ }
1318
+ } catch (error) {
1319
+ logInternalError(
1320
+ "register.sessionStart.orphanWorkers",
1321
+ error,
1322
+ );
1323
+ }
1324
+
1222
1325
  // Global purge of stale active-run-index entries
1223
1326
  try {
1224
1327
  const { purged } = purgeStaleActiveRunIndexFn();
@@ -1324,19 +1427,60 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1324
1427
  runParams = { action: "run", team: "default", goal: job.prompt };
1325
1428
  }
1326
1429
  if (runParams.action !== "run") return `scheduled-${job.id}-${Date.now()}`;
1430
+ const agentId = `scheduled-${job.id}-${Date.now()}`;
1327
1431
  setImmediate(async () => {
1328
1432
  try {
1329
- await handleTeamTool(
1433
+ const runResult = await handleTeamTool(
1330
1434
  { action: "run", team: runParams.team, goal: runParams.goal, async: true },
1331
1435
  { cwd: ctx.cwd, sessionId },
1332
1436
  );
1437
+ // Track the runId so remove() can cancel spawned runs
1438
+ const runId = runResult?.details?.runId;
1439
+ if (runId && typeof runId === "string") {
1440
+ crewScheduler?.recordSpawnedRun(job.id, runId);
1441
+ // Update run manifest with scheduler provenance for traceability
1442
+ try {
1443
+ const cwd = ctx.cwd ?? process.cwd();
1444
+ const loaded = loadRunManifestById(cwd, runId);
1445
+ if (loaded) {
1446
+ const { atomicWriteJson } = await import("../state/atomic-write.ts");
1447
+ atomicWriteJson(loaded.manifest.stateRoot + "/manifest.json", {
1448
+ ...loaded.manifest,
1449
+ schedulerJobId: job.id,
1450
+ schedulerName: job.name,
1451
+ });
1452
+ }
1453
+ } catch { /* best-effort provenance tracking */ }
1454
+ }
1455
+ // Persist updated job with spawnedRunIds to settings
1456
+ try {
1457
+ const updatedJob = crewScheduler?.list().find((j) => j.id === job.id);
1458
+ if (updatedJob) persistScheduledJobUpdate(ctx.cwd, updatedJob);
1459
+ } catch { /* best-effort */ }
1460
+ // Update run count
1461
+ crewScheduler?.update(job.id, {
1462
+ runCount: job.runCount + 1,
1463
+ lastRun: new Date().toISOString(),
1464
+ lastStatus: "success",
1465
+ });
1333
1466
  } catch (err) {
1334
1467
  logInternalError("scheduler.execute", err);
1468
+ crewScheduler?.update(job.id, { lastStatus: "error" });
1335
1469
  }
1336
1470
  });
1337
- return `scheduled-${job.id}-${Date.now()}`;
1471
+ return agentId;
1338
1472
  },
1339
1473
  finalizer: () => {},
1474
+ runCancelFn: (runId: string) => {
1475
+ try {
1476
+ handleTeamTool(
1477
+ { action: "cancel", runId, confirm: true },
1478
+ { cwd: ctx.cwd, sessionId },
1479
+ ).catch((err) => logInternalError("scheduler.runCancelFn", err, `runId=${runId}`));
1480
+ } catch (err) {
1481
+ logInternalError("scheduler.runCancelFn.sync", err, `runId=${runId}`);
1482
+ }
1483
+ },
1340
1484
  });
1341
1485
  // Wire scheduler into handle-schedule.ts so handlers can add/list jobs.
1342
1486
  // Uses a global symbol so the module doesn't need a direct circular import.
@@ -1534,18 +1678,26 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1534
1678
  const previous = autoRecoveryLast.get(key);
1535
1679
  if (
1536
1680
  previous !== undefined &&
1537
- now - previous < 5 * 60_000
1681
+ now - previous.lastAccessAt < 5 * 60_000
1538
1682
  )
1539
1683
  return;
1540
- // Defensive cap: evict oldest entries before inserting
1541
- // when size exceeds the limit. Map's natural insertion
1542
- // order means the first key is the oldest.
1684
+ // Defensive cap: evict entry with oldest lastAccessAt before
1685
+ // inserting/updating when size exceeds the limit. Uses LRU
1686
+ // semantics so entries that are still being actively
1687
+ // accessed survive longer than insertion-order eviction.
1543
1688
  while (autoRecoveryLast.size >= AUTO_RECOVERY_LAST_MAX_ENTRIES) {
1544
- const oldest = autoRecoveryLast.keys().next().value;
1545
- if (oldest === undefined) break;
1546
- autoRecoveryLast.delete(oldest);
1689
+ let oldestKey: string | undefined;
1690
+ let oldestAccess = Infinity;
1691
+ for (const [k, v] of autoRecoveryLast) {
1692
+ if (v.lastAccessAt < oldestAccess) {
1693
+ oldestAccess = v.lastAccessAt;
1694
+ oldestKey = k;
1695
+ }
1696
+ }
1697
+ if (oldestKey === undefined) break;
1698
+ autoRecoveryLast.delete(oldestKey);
1547
1699
  }
1548
- autoRecoveryLast.set(key, now);
1700
+ autoRecoveryLast.set(key, { insertedAt: now, lastAccessAt: now });
1549
1701
  notifyOperator({
1550
1702
  id: key,
1551
1703
  severity: "warning",
@@ -1752,8 +1904,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1752
1904
  );
1753
1905
  const paths: string[] = [];
1754
1906
  if (fs.existsSync(extSkillDir)) paths.push(extSkillDir);
1755
- if (skillDir !== extSkillDir && fs.existsSync(skillDir))
1756
- paths.push(skillDir);
1907
+ if (skillDir !== extSkillDir && fs.existsSync(skillDir)) {
1908
+ // Validate skillDir is within sessionCwd to prevent path traversal
1909
+ try {
1910
+ resolveContainedPath(sessionCwd, "skills");
1911
+ paths.push(skillDir);
1912
+ } catch {
1913
+ // skillDir outside sessionCwd boundary — skip
1914
+ }
1915
+ }
1757
1916
  return paths.length > 0 ? { skillPaths: paths } : {};
1758
1917
  });
1759
1918
  } catch {
@@ -1777,7 +1936,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1777
1936
  if (event.toolName !== "team") return;
1778
1937
  const rawInput = event.input;
1779
1938
  if (!rawInput || typeof rawInput !== "object") return;
1780
- const input = rawInput as { action?: unknown; confirm?: unknown; force?: unknown };
1939
+ const input = asRecord(rawInput);
1940
+ if (!input) return;
1781
1941
  const action = typeof input.action === "string" ? input.action : undefined;
1782
1942
  const destructiveActions = new Set(["delete", "forget", "prune", "cleanup"]);
1783
1943
  if (!action || !destructiveActions.has(action)) return;
@@ -154,7 +154,7 @@ function teamCommandContext(ctx: ExtensionCommandContext): ExtensionCommandConte
154
154
  }
155
155
 
156
156
  async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selection: RunDashboardSelection): Promise<void> {
157
- const loaded = loadRunManifestById(ctx.cwd, selection.runId);
157
+ const loaded = loadRunManifestById(ctx.cwd, selection.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
158
158
  if (!loaded) {
159
159
  depsNotify(ctx, `Run '${selection.runId}' not found.`, "error");
160
160
  return;
@@ -379,7 +379,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
379
379
  pi.registerCommand("team-result", { description: "Open a pi-crew agent result viewer: <runId> [taskId]", handler: async (args: string, ctx: ExtensionCommandContext) => {
380
380
  const [runId, rawTaskId] = args.trim().split(/\s+/).filter(Boolean);
381
381
  const selected = await selectAgentTask(ctx, runId, rawTaskId);
382
- const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined;
382
+ const loaded = selected ? loadRunManifestById(ctx.cwd, selected.runId) : undefined; // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
383
383
  if (ctx.hasUI && loaded) {
384
384
  const agent = readCrewAgents(loaded.manifest).find((item) => item.taskId === selected?.taskId || item.id === selected?.taskId) ?? readCrewAgents(loaded.manifest)[0];
385
385
  const resultText = agent?.resultArtifactPath ? commandText(await handleTeamTool({ action: "api", runId: selected?.runId ?? "", config: { operation: "read-agent-output", agentId: agent.taskId, maxBytes: 64_000 } }, teamCommandContext(ctx))) : "(no result)";
@@ -428,7 +428,8 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
428
428
  if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
429
429
  if (selection.action === "agent-live" && await openLiveConversation(ctx, selection.runId)) continue;
430
430
  if (selection.action === "agent-live") { await notifyCommandResult(ctx, commandText({ content: [{ type: "text", text: "No live agent found for this run." }] })); continue; }
431
- const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(ctx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(ctx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(ctx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(ctx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(ctx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(ctx)) : await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(ctx));
431
+ const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(ctx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(ctx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(ctx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(ctx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(ctx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(ctx)) : // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
+ await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(ctx));
432
433
  await notifyCommandResult(ctx, commandText(result));
433
434
  return;
434
435
  }
@@ -34,7 +34,7 @@ export function sendAgentWakeUp(pi: ExtensionAPI, content: string): boolean {
34
34
 
35
35
  export function refreshPersistedSubagentRecord(ctx: ExtensionContext | ExtensionCommandContext, record: SubagentRecord): SubagentRecord {
36
36
  if (!record.runId) return record;
37
- const loaded = loadRunManifestById(ctx.cwd, record.runId);
37
+ const loaded = loadRunManifestById(ctx.cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
38
38
  if (!loaded) return record;
39
39
  if (loaded.manifest.status === "completed" || loaded.manifest.status === "failed" || loaded.manifest.status === "cancelled" || loaded.manifest.status === "blocked") {
40
40
  const refreshed = {
@@ -65,7 +65,7 @@ export function formatSubagentRecord(record: SubagentRecord): string {
65
65
 
66
66
  export function readSubagentRunResult(ctx: ExtensionContext | ExtensionCommandContext, record: SubagentRecord): string | undefined {
67
67
  if (!record.runId) return record.result;
68
- const loaded = loadRunManifestById(ctx.cwd, record.runId);
68
+ const loaded = loadRunManifestById(ctx.cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
69
69
  const task = loaded?.tasks.find((item) => item.resultArtifact) ?? loaded?.tasks[0];
70
70
  const artifactPath = task?.resultArtifact?.path;
71
71
  if (!artifactPath || !loaded) return undefined;
@@ -96,9 +96,11 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
96
96
  }
97
97
  return foregroundResult;
98
98
  },
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
100
  renderCall(args: any, theme: any, context: any): any {
100
101
  return renderAgentToolCall(args, theme, context);
101
102
  },
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
104
  renderResult(result: any, options: any, theme: any, context: any): any {
103
105
  return renderAgentToolResult(result, options, theme, context);
104
106
  },
@@ -202,7 +204,7 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
202
204
  let tasks;
203
205
  let agents;
204
206
  if (record.runId) {
205
- const loaded = loadRunManifestById(cwd, record.runId);
207
+ const loaded = loadRunManifestById(cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
206
208
  if (loaded) {
207
209
  manifest = loaded.manifest;
208
210
  tasks = loaded.tasks;
@@ -105,9 +105,11 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
105
105
  stopProgress.stop();
106
106
  }
107
107
  },
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
109
  renderCall(args: any, theme: any, context: any): any {
109
110
  return renderTeamToolCall(args, theme, context);
110
111
  },
112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
113
  renderResult(result: any, options: any, theme: any, context: any): any {
112
114
  return renderTeamToolResult(result, options, theme, context);
113
115
  },
@@ -134,7 +136,7 @@ function startTeamToolProgressBinder(onUpdate: OnUpdate | undefined): TeamToolPr
134
136
  onUpdate({ content: [{ type: "text", text: `team status=starting elapsed=${elapsed}s` }] });
135
137
  return;
136
138
  }
137
- const loaded = loadRunManifestById(cwd, runId);
139
+ const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
138
140
  if (!loaded) {
139
141
  const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
140
142
  onUpdate({ content: [{ type: "text", text: `team run=${runId} elapsed=${elapsed}s (manifest pending)` }] });
@@ -23,7 +23,7 @@ async function getViewer(): Promise<typeof DurableTranscriptViewerType> {
23
23
  export async function selectAgentTask(ctx: ExtensionCommandContext, runId: string | undefined, taskId?: string): Promise<{ runId: string; taskId?: string } | undefined> {
24
24
  if (!runId) return undefined;
25
25
  if (taskId) return { runId, taskId };
26
- const loaded = loadRunManifestById(ctx.cwd, runId);
26
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
27
27
  if (!loaded) return { runId };
28
28
  const agents = readCrewAgents(loaded.manifest);
29
29
  if (ctx.hasUI && agents.length > 1) {
@@ -39,7 +39,7 @@ export async function openTranscriptViewer(ctx: ExtensionCommandContext, initial
39
39
  const runId = selected.runId;
40
40
  const taskId = selected.taskId;
41
41
  if (!runId || !ctx.hasUI) return false;
42
- const loaded = loadRunManifestById(ctx.cwd, runId);
42
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
43
43
  if (!loaded) return false;
44
44
  const uiConfig = loadConfig(ctx.cwd).config.ui;
45
45
  const DurableTranscriptViewer = await getViewer();
@@ -58,6 +58,7 @@ export async function openLiveConversation(ctx: ExtensionCommandContext, initial
58
58
  const handle = liveAgents.find((h) => h.runId === selected.runId && (selected.taskId ? h.taskId === selected.taskId : true));
59
59
  if (!handle) return false;
60
60
  const theme = asCrewTheme({});
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
62
  await ctx.ui.custom<undefined>((tui: any, _theme: any, _keybindings: any, done: (result: undefined) => void) => {
62
63
  const columns = tui?.terminal?.columns ?? 80;
63
64
  const rows = tui?.terminal?.rows ?? 24;
@@ -1,17 +1,29 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as os from "node:os";
4
+ import * as crypto from "node:crypto";
4
5
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
6
  import { writeArtifact } from "../state/artifact-store.ts";
6
7
  import { readEvents, type TeamEvent } from "../state/event-log.ts";
7
8
  import { redactSecrets } from "../utils/redaction.ts";
8
9
 
9
10
  /** Replace absolute paths containing home directory with ~/ */
11
+ /** Escape special regex characters in a string */
12
+ function escapeRegex(str: string): string {
13
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ }
15
+
16
+ /** Only redact home directory at path boundaries to avoid corrupting substrings */
17
+ function redactHomePathInString(str: string, home: string): string {
18
+ return str.replace(new RegExp(`(^|(?<=[:=/]))${escapeRegex(home)}`, "g"), "$1~");
19
+ }
20
+
21
+ /** Replace absolute paths containing home directory with ~/ at path boundaries only */
10
22
  function redactHomePaths<T>(obj: T): T {
11
23
  const home = os.homedir();
12
24
  if (!home) return redactSecrets(obj) as T;
13
25
  const json = JSON.stringify(obj);
14
- const safe = json.split(home).join("~");
26
+ const safe = redactHomePathInString(json, home);
15
27
  return redactSecrets(JSON.parse(safe)) as T;
16
28
  }
17
29
 
@@ -37,6 +49,9 @@ export function exportRunBundle(manifest: TeamRunManifest, tasks: TeamTaskState[
37
49
  events: safeEvents as TeamEvent[],
38
50
  artifactPaths: safeManifest.artifacts.map((artifact) => artifact.path),
39
51
  };
52
+ // Compute SHA-256 integrity hash of the bundle and store in manifest
53
+ const sha256 = crypto.createHash("sha256").update(JSON.stringify(bundle)).digest("hex");
54
+ (bundle.manifest as unknown as Record<string, unknown>).sha256 = sha256;
40
55
  const json = writeArtifact(manifest.artifactsRoot, {
41
56
  kind: "metadata",
42
57
  relativePath: "export/run-export.json",
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
3
4
  import { assertRunBundle } from "./run-bundle-schema.ts";
4
5
  import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
5
6
  import { DEFAULT_PATHS } from "../config/defaults.ts";
@@ -42,6 +43,21 @@ export function importRunBundle(cwd: string, bundlePath: string, scope: "project
42
43
  if (!isContained) throw new Error(`Import path must be within project directory or crew root: ${resolvedPath}`);
43
44
  const raw = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
44
45
  assertRunBundle(raw);
46
+
47
+ // Integrity check: verify SHA-256 hash if present in manifest
48
+ const bundleJson = fs.readFileSync(resolvedPath, "utf-8");
49
+ const parsedForHash = JSON.parse(bundleJson) as { manifest?: { sha256?: string } };
50
+ if (parsedForHash.manifest?.sha256) {
51
+ const expectedHash = parsedForHash.manifest.sha256;
52
+ // Recompute hash by stringifying the bundle without the sha256 field
53
+ const { sha256: _sha256, ...manifestWithoutHash } = parsedForHash.manifest as Record<string, unknown> & { sha256?: string };
54
+ const bundleForHash = { ...parsedForHash, manifest: manifestWithoutHash };
55
+ const recomputedHash = crypto.createHash("sha256").update(JSON.stringify(bundleForHash)).digest("hex");
56
+ if (recomputedHash !== expectedHash) {
57
+ throw new Error(`Integrity check failed: SHA-256 mismatch. Expected ${expectedHash}, got ${recomputedHash}`);
58
+ }
59
+ }
60
+
45
61
  const runId = assertSafePathId("runId", raw.manifest.runId);
46
62
  const importedAt = new Date().toISOString();
47
63
 
@@ -40,9 +40,13 @@ export function handleAnchorSet(
40
40
  const cfg = params.config ?? {};
41
41
 
42
42
  // Parse context from config
43
+ const POLLUTED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
43
44
  const context: Record<string, unknown> = {};
44
45
  if (cfg.context && typeof cfg.context === "object") {
45
- Object.assign(context, cfg.context as Record<string, unknown>);
46
+ const raw = cfg.context as Record<string, unknown>;
47
+ for (const [k, v] of Object.entries(raw)) {
48
+ if (!POLLUTED_KEYS.has(k)) context[k] = v;
49
+ }
46
50
  }
47
51
  if (cfg.key) {
48
52
  // Single key shorthand