pi-crew 0.6.1 → 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 (102) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +70 -33
  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/config/config.ts +106 -15
  7. package/src/errors.ts +107 -0
  8. package/src/extension/async-notifier.ts +6 -2
  9. package/src/extension/crew-cleanup.ts +8 -5
  10. package/src/extension/management.ts +464 -109
  11. package/src/extension/register.ts +194 -34
  12. package/src/extension/registration/commands.ts +2 -2
  13. package/src/extension/registration/subagent-helpers.ts +2 -2
  14. package/src/extension/registration/subagent-tools.ts +1 -1
  15. package/src/extension/registration/team-tool.ts +1 -1
  16. package/src/extension/registration/viewers.ts +2 -2
  17. package/src/extension/team-tool/api.ts +3 -3
  18. package/src/extension/team-tool/cancel.ts +3 -3
  19. package/src/extension/team-tool/explain.ts +1 -1
  20. package/src/extension/team-tool/handle-schedule.ts +40 -0
  21. package/src/extension/team-tool/inspect.ts +3 -3
  22. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  23. package/src/extension/team-tool/respond.ts +2 -2
  24. package/src/extension/team-tool/run.ts +60 -11
  25. package/src/extension/team-tool/status.ts +1 -1
  26. package/src/extension/team-tool.ts +173 -46
  27. package/src/hooks/registry.ts +77 -13
  28. package/src/hooks/types.ts +9 -0
  29. package/src/plugins/plugin-define.ts +6 -0
  30. package/src/plugins/plugin-registry.ts +32 -0
  31. package/src/plugins/plugins/index.ts +3 -0
  32. package/src/plugins/plugins/nextjs.ts +19 -0
  33. package/src/plugins/plugins/vite.ts +14 -0
  34. package/src/plugins/plugins/vitest.ts +14 -0
  35. package/src/runtime/async-runner.ts +35 -7
  36. package/src/runtime/background-runner.ts +529 -144
  37. package/src/runtime/chain-parser.ts +5 -1
  38. package/src/runtime/chain-runner.ts +9 -3
  39. package/src/runtime/checkpoint.ts +262 -180
  40. package/src/runtime/child-pi.ts +164 -37
  41. package/src/runtime/crash-recovery.ts +25 -14
  42. package/src/runtime/crew-agent-records.ts +2 -0
  43. package/src/runtime/diagnostic-export.ts +1 -1
  44. package/src/runtime/dynamic-script-runner.ts +10 -7
  45. package/src/runtime/foreground-watchdog.ts +1 -1
  46. package/src/runtime/heartbeat-watcher.ts +19 -2
  47. package/src/runtime/intercom-bridge.ts +7 -0
  48. package/src/runtime/iteration-hooks.ts +1 -1
  49. package/src/runtime/manifest-cache.ts +4 -0
  50. package/src/runtime/orphan-worker-registry.ts +444 -0
  51. package/src/runtime/parent-guard.ts +70 -16
  52. package/src/runtime/pi-args.ts +437 -14
  53. package/src/runtime/pi-spawn.ts +1 -0
  54. package/src/runtime/post-checks.ts +1 -1
  55. package/src/runtime/retry-runner.ts +9 -3
  56. package/src/runtime/run-tracker.ts +38 -10
  57. package/src/runtime/sandbox.ts +17 -2
  58. package/src/runtime/scheduler.ts +25 -2
  59. package/src/runtime/skill-effectiveness.ts +105 -62
  60. package/src/runtime/skill-instructions.ts +110 -32
  61. package/src/runtime/stale-reconciler.ts +310 -69
  62. package/src/runtime/subagent-manager.ts +251 -57
  63. package/src/runtime/task-health.ts +76 -0
  64. package/src/runtime/task-id.ts +20 -0
  65. package/src/runtime/task-runner/live-executor.ts +1 -1
  66. package/src/runtime/task-runner/progress.ts +1 -1
  67. package/src/runtime/task-runner/state-helpers.ts +110 -6
  68. package/src/runtime/task-runner.ts +92 -70
  69. package/src/runtime/team-runner.ts +186 -20
  70. package/src/schema/team-tool-schema.ts +27 -9
  71. package/src/skills/discover-skills.ts +9 -3
  72. package/src/state/active-run-registry.ts +170 -38
  73. package/src/state/artifact-store.ts +25 -13
  74. package/src/state/atomic-write-v2.ts +86 -0
  75. package/src/state/atomic-write.ts +346 -55
  76. package/src/state/blob-store.ts +178 -10
  77. package/src/state/crew-init.ts +161 -28
  78. package/src/state/decision-ledger.ts +172 -111
  79. package/src/state/event-log-rotation.ts +82 -52
  80. package/src/state/event-log.ts +254 -70
  81. package/src/state/health-store.ts +71 -0
  82. package/src/state/locks.ts +102 -20
  83. package/src/state/mailbox.ts +45 -7
  84. package/src/state/observation-store.ts +4 -1
  85. package/src/state/run-graph.ts +141 -130
  86. package/src/state/run-metrics.ts +24 -8
  87. package/src/state/state-store.ts +330 -43
  88. package/src/ui/live-run-sidebar.ts +1 -1
  89. package/src/ui/loaders.ts +4 -0
  90. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  91. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  92. package/src/ui/run-action-dispatcher.ts +2 -2
  93. package/src/ui/run-snapshot-cache.ts +1 -1
  94. package/src/utils/env-filter.ts +66 -0
  95. package/src/utils/file-coalescer.ts +9 -1
  96. package/src/utils/paths.ts +80 -5
  97. package/src/utils/redaction.ts +6 -1
  98. package/src/utils/safe-paths.ts +281 -10
  99. package/src/worktree/cleanup.ts +112 -24
  100. package/src/worktree/worktree-manager.ts +128 -15
  101. package/test-bugs-all.mjs +10 -6
  102. 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)";
@@ -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;
@@ -204,7 +204,7 @@ function startAgentToolProgress(cwd: string, agentRecordId: string, onUpdate: On
204
204
  let tasks;
205
205
  let agents;
206
206
  if (record.runId) {
207
- const loaded = loadRunManifestById(cwd, record.runId);
207
+ const loaded = loadRunManifestById(cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
208
208
  if (loaded) {
209
209
  manifest = loaded.manifest;
210
210
  tasks = loaded.tasks;
@@ -136,7 +136,7 @@ function startTeamToolProgressBinder(onUpdate: OnUpdate | undefined): TeamToolPr
136
136
  onUpdate({ content: [{ type: "text", text: `team status=starting elapsed=${elapsed}s` }] });
137
137
  return;
138
138
  }
139
- const loaded = loadRunManifestById(cwd, runId);
139
+ const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
140
140
  if (!loaded) {
141
141
  const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
142
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();
@@ -92,7 +92,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
92
92
  if (!params.runId) return result("API requires runId.", { action: "api", status: "error" }, true);
93
93
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
94
94
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "api", status: "error" }, true);
95
- const loaded = loadRunManifestById(runCwd, params.runId);
95
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
96
96
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "api", status: "error" }, true);
97
97
  if (operation === "read-manifest") {
98
98
  return result(JSON.stringify(loaded.manifest, null, 2), { action: "api", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
@@ -102,7 +102,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
102
102
  if (!permission.allowed) return result(permission.reason ?? "Plan approval is not allowed in this context.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
103
103
  try {
104
104
  return withRunLockSync(loaded.manifest, () => {
105
- const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded;
105
+ const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded; // NOTE: inside withRunLockSync - consistent read
106
106
  const approval = current.manifest.planApproval;
107
107
  if (!approval?.required || approval.status !== "pending") return result("Run has no pending plan approval request.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
108
108
  const now = new Date().toISOString();
@@ -121,7 +121,7 @@ export async function handleApi(params: TeamToolParamsValue, ctx: TeamContext):
121
121
  if (!permission.allowed) return result(permission.reason ?? "Plan approval cancellation is not allowed in this context.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
122
122
  try {
123
123
  return withRunLockSync(loaded.manifest, () => {
124
- const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded;
124
+ const current = loadRunManifestById(ctx.cwd, loaded.manifest.runId) ?? loaded; // NOTE: inside withRunLockSync - consistent read
125
125
  const approval = current.manifest.planApproval;
126
126
  if (!approval?.required || approval.status !== "pending") return result("Run has no pending plan approval request.", { action: "api", status: "error", runId: loaded.manifest.runId }, true);
127
127
  const now = new Date().toISOString();
@@ -41,7 +41,7 @@ export function abortOwned(
41
41
  ): AbortOwnedResult {
42
42
  const runCwd = locateRunCwd(runId, ctx.cwd);
43
43
  if (!runCwd) return { abortedIds: [], missingIds: taskIds ?? [], foreignIds: [] };
44
- const loaded = loadRunManifestById(runCwd, runId);
44
+ const loaded = loadRunManifestById(runCwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
45
45
  if (!loaded) return { abortedIds: [], missingIds: taskIds ?? [], foreignIds: [] };
46
46
 
47
47
  const result: AbortOwnedResult = { abortedIds: [], missingIds: [], foreignIds: [] };
@@ -81,7 +81,7 @@ export async function handleRetry(params: TeamToolParamsValue, ctx: TeamContext,
81
81
  if (!params.runId) return result("Retry requires runId.", { action: "retry", status: "error" }, true);
82
82
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
83
83
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "retry", status: "error" }, true);
84
- const loaded = loadRunManifestById(runCwd, params.runId);
84
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
85
85
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "retry", status: "error" }, true);
86
86
 
87
87
  // Pre-lock ownership check: reject foreign-owned runs unless force is set
@@ -146,7 +146,7 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
146
146
  if (!params.runId) return result("Cancel requires runId.", { action: "cancel", status: "error" }, true);
147
147
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
148
148
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "cancel", status: "error" }, true);
149
- const loaded = loadRunManifestById(runCwd, params.runId);
149
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
150
150
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "cancel", status: "error" }, true);
151
151
 
152
152
  // Pre-lock ownership check: reject foreign-owned runs unless force is set
@@ -209,7 +209,7 @@ export function handleExplain(params: {
209
209
  return result("explain requires runId", { action: "explain", status: "error" }, true);
210
210
  }
211
211
 
212
- const loaded = loadRunManifestById(cwd, params.runId);
212
+ const loaded = loadRunManifestById(cwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
213
213
  if (!loaded) {
214
214
  return result(`Run '${params.runId}' not found.`, { action: "explain", status: "error" }, true);
215
215
  }
@@ -163,6 +163,43 @@ function persistScheduledJob(cwd: string, job: import("../../runtime/scheduler.t
163
163
  }
164
164
  }
165
165
 
166
+ /** Update an existing scheduled job in persistent settings. */
167
+ export function persistScheduledJobUpdate(cwd: string, job: import("../../runtime/scheduler.ts").ScheduledJob): void {
168
+ try {
169
+ const settings = loadCrewSettings(cwd);
170
+ const existingJobs: import("../../runtime/scheduler.ts").ScheduledJob[] = Array.isArray(
171
+ (settings as Record<string, unknown>).scheduledJobs,
172
+ )
173
+ ? ((settings as Record<string, unknown>).scheduledJobs as import("../../runtime/scheduler.ts").ScheduledJob[])
174
+ : [];
175
+ const updated = existingJobs.map((j) => j.id === job.id ? job : j);
176
+ saveCrewSettings(
177
+ { ...settings, scheduledJobs: updated } as Parameters<typeof saveCrewSettings>[0],
178
+ cwd,
179
+ );
180
+ } catch {
181
+ /* best-effort persistence */
182
+ }
183
+ }
184
+
185
+ /** Remove a scheduled job from persistent settings. */
186
+ function persistScheduledJobRemove(cwd: string, jobId: string): void {
187
+ try {
188
+ const settings = loadCrewSettings(cwd);
189
+ const existingJobs: import("../../runtime/scheduler.ts").ScheduledJob[] = Array.isArray(
190
+ (settings as Record<string, unknown>).scheduledJobs,
191
+ )
192
+ ? ((settings as Record<string, unknown>).scheduledJobs as import("../../runtime/scheduler.ts").ScheduledJob[])
193
+ : [];
194
+ saveCrewSettings(
195
+ { ...settings, scheduledJobs: existingJobs.filter((j) => j.id !== jobId) } as Parameters<typeof saveCrewSettings>[0],
196
+ cwd,
197
+ );
198
+ } catch {
199
+ /* best-effort persistence */
200
+ }
201
+ }
202
+
166
203
  export function handleListScheduled(_params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
167
204
  const scheduler = getCrewScheduler();
168
205
  if (!scheduler) return result("Scheduler not running.", { action: "scheduled", status: "error" }, true);
@@ -177,6 +214,9 @@ export function handleListScheduled(_params: TeamToolParamsValue, ctx: TeamConte
177
214
  ` Next run: ${job.nextRun ?? "(unscheduled)"}`,
178
215
  ` Runs: ${job.runCount}, Last: ${job.lastRun ?? "(never)"} [${job.lastStatus ?? "?"}]`,
179
216
  );
217
+ if (job.spawnedRunIds && job.spawnedRunIds.length > 0) {
218
+ lines.push(` Spawned runs: ${job.spawnedRunIds.join(", ")}`);
219
+ }
180
220
  }
181
221
  return result(lines.join("\n"), { action: "scheduled", status: "ok" });
182
222
  }
@@ -10,7 +10,7 @@ export function handleEvents(params: TeamToolParamsValue, ctx: TeamContext): PiT
10
10
  if (!params.runId) return result("Events requires runId.", { action: "events", status: "error" }, true);
11
11
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
12
12
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "events", status: "error" }, true);
13
- const loaded = loadRunManifestById(runCwd, params.runId);
13
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
14
14
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "events", status: "error" }, true);
15
15
  const events = readEvents(loaded.manifest.eventsPath);
16
16
  const lines = [`Events for ${loaded.manifest.runId}:`, ...(events.length ? events.map((event) => `${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${event.message}` : ""}${event.data ? ` ${JSON.stringify(event.data)}` : ""}`) : ["(none)"])];
@@ -21,7 +21,7 @@ export function handleArtifacts(params: TeamToolParamsValue, ctx: TeamContext):
21
21
  if (!params.runId) return result("Artifacts requires runId.", { action: "artifacts", status: "error" }, true);
22
22
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
23
23
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "artifacts", status: "error" }, true);
24
- const loaded = loadRunManifestById(runCwd, params.runId);
24
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
25
25
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "artifacts", status: "error" }, true);
26
26
  const lines = [`Artifacts for ${loaded.manifest.runId}:`, ...(loaded.manifest.artifacts.length ? loaded.manifest.artifacts.map((artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}${artifact.contentHash ? ` sha256=${artifact.contentHash.slice(0, 12)}` : ""}`) : ["- (none)"])];
27
27
  return result(lines.join("\n"), { action: "artifacts", status: "ok", runId: loaded.manifest.runId, artifactsRoot: loaded.manifest.artifactsRoot });
@@ -31,7 +31,7 @@ export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): Pi
31
31
  if (!params.runId) return result("Summary requires runId.", { action: "summary", status: "error" }, true);
32
32
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
33
33
  if (!runCwd) return result(`Run '${params.runId}' not found.`, { action: "summary", status: "error" }, true);
34
- const loaded = loadRunManifestById(runCwd, params.runId);
34
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
35
35
  if (!loaded) return result(`Run '${params.runId}' not found.`, { action: "summary", status: "error" }, true);
36
36
  const usage = aggregateUsage(loaded.tasks);
37
37
  const lines = [