pi-crew 0.6.1 → 0.6.4

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 (119) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +81 -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/docs/ui-optimization-plan.md +447 -0
  6. package/package.json +2 -2
  7. package/src/config/config.ts +106 -15
  8. package/src/errors.ts +107 -0
  9. package/src/extension/async-notifier.ts +6 -2
  10. package/src/extension/crew-cleanup.ts +8 -5
  11. package/src/extension/management.ts +464 -109
  12. package/src/extension/register.ts +213 -35
  13. package/src/extension/registration/brief-tool-overrides.ts +400 -0
  14. package/src/extension/registration/commands.ts +27 -2
  15. package/src/extension/registration/subagent-helpers.ts +2 -2
  16. package/src/extension/registration/subagent-tools.ts +9 -4
  17. package/src/extension/registration/team-tool.ts +17 -10
  18. package/src/extension/registration/viewers.ts +2 -2
  19. package/src/extension/team-tool/api.ts +3 -3
  20. package/src/extension/team-tool/cancel.ts +3 -3
  21. package/src/extension/team-tool/explain.ts +1 -1
  22. package/src/extension/team-tool/handle-schedule.ts +40 -0
  23. package/src/extension/team-tool/inspect.ts +3 -3
  24. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  25. package/src/extension/team-tool/respond.ts +2 -2
  26. package/src/extension/team-tool/run.ts +64 -14
  27. package/src/extension/team-tool/status.ts +1 -1
  28. package/src/extension/team-tool-types.ts +2 -0
  29. package/src/extension/team-tool.ts +173 -46
  30. package/src/hooks/registry.ts +77 -13
  31. package/src/hooks/types.ts +9 -0
  32. package/src/plugins/plugin-define.ts +6 -0
  33. package/src/plugins/plugin-registry.ts +32 -0
  34. package/src/plugins/plugins/index.ts +3 -0
  35. package/src/plugins/plugins/nextjs.ts +19 -0
  36. package/src/plugins/plugins/vite.ts +14 -0
  37. package/src/plugins/plugins/vitest.ts +14 -0
  38. package/src/runtime/async-runner.ts +35 -7
  39. package/src/runtime/background-runner.ts +529 -144
  40. package/src/runtime/chain-parser.ts +5 -1
  41. package/src/runtime/chain-runner.ts +9 -3
  42. package/src/runtime/checkpoint.ts +262 -180
  43. package/src/runtime/child-pi.ts +164 -37
  44. package/src/runtime/crash-recovery.ts +25 -14
  45. package/src/runtime/crew-agent-records.ts +2 -0
  46. package/src/runtime/diagnostic-export.ts +1 -1
  47. package/src/runtime/dynamic-script-runner.ts +10 -7
  48. package/src/runtime/foreground-watchdog.ts +1 -1
  49. package/src/runtime/heartbeat-watcher.ts +19 -2
  50. package/src/runtime/intercom-bridge.ts +7 -0
  51. package/src/runtime/iteration-hooks.ts +1 -1
  52. package/src/runtime/manifest-cache.ts +4 -0
  53. package/src/runtime/orphan-worker-registry.ts +444 -0
  54. package/src/runtime/parent-guard.ts +70 -16
  55. package/src/runtime/pi-args.ts +437 -14
  56. package/src/runtime/pi-spawn.ts +1 -0
  57. package/src/runtime/post-checks.ts +1 -1
  58. package/src/runtime/retry-runner.ts +9 -3
  59. package/src/runtime/run-tracker.ts +38 -10
  60. package/src/runtime/sandbox.ts +17 -2
  61. package/src/runtime/scheduler.ts +25 -2
  62. package/src/runtime/skill-effectiveness.ts +105 -62
  63. package/src/runtime/skill-instructions.ts +110 -32
  64. package/src/runtime/stale-reconciler.ts +310 -69
  65. package/src/runtime/subagent-manager.ts +251 -57
  66. package/src/runtime/task-health.ts +76 -0
  67. package/src/runtime/task-id.ts +20 -0
  68. package/src/runtime/task-runner/live-executor.ts +1 -1
  69. package/src/runtime/task-runner/progress.ts +1 -1
  70. package/src/runtime/task-runner/state-helpers.ts +110 -6
  71. package/src/runtime/task-runner.ts +92 -70
  72. package/src/runtime/team-runner.ts +186 -20
  73. package/src/schema/team-tool-schema.ts +27 -9
  74. package/src/skills/discover-skills.ts +9 -3
  75. package/src/state/active-run-registry.ts +170 -38
  76. package/src/state/artifact-store.ts +25 -13
  77. package/src/state/atomic-write-v2.ts +86 -0
  78. package/src/state/atomic-write.ts +346 -55
  79. package/src/state/blob-store.ts +178 -10
  80. package/src/state/crew-init.ts +161 -28
  81. package/src/state/decision-ledger.ts +172 -111
  82. package/src/state/event-log-rotation.ts +82 -52
  83. package/src/state/event-log.ts +254 -70
  84. package/src/state/health-store.ts +71 -0
  85. package/src/state/locks.ts +102 -20
  86. package/src/state/mailbox.ts +45 -7
  87. package/src/state/observation-store.ts +4 -1
  88. package/src/state/run-graph.ts +141 -130
  89. package/src/state/run-metrics.ts +24 -8
  90. package/src/state/session-state-map.ts +51 -0
  91. package/src/state/state-store.ts +330 -43
  92. package/src/ui/live-run-sidebar.ts +1 -1
  93. package/src/ui/loaders.ts +4 -0
  94. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  95. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  96. package/src/ui/powerbar-publisher.ts +1 -1
  97. package/src/ui/run-action-dispatcher.ts +2 -2
  98. package/src/ui/run-snapshot-cache.ts +1 -1
  99. package/src/ui/status-colors.ts +5 -1
  100. package/src/ui/theme-adapter.ts +80 -1
  101. package/src/ui/tool-progress-formatter.ts +9 -5
  102. package/src/ui/tool-render.ts +4 -0
  103. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  104. package/src/ui/tool-renderers/index.ts +627 -0
  105. package/src/ui/widget/index.ts +224 -0
  106. package/src/ui/widget/widget-formatters.ts +148 -0
  107. package/src/ui/widget/widget-model.ts +90 -0
  108. package/src/ui/widget/widget-renderer.ts +130 -0
  109. package/src/ui/widget/widget-types.ts +37 -0
  110. package/src/utils/env-filter.ts +66 -0
  111. package/src/utils/file-coalescer.ts +9 -1
  112. package/src/utils/guards.ts +110 -0
  113. package/src/utils/paths.ts +80 -5
  114. package/src/utils/redaction.ts +6 -1
  115. package/src/utils/safe-paths.ts +281 -10
  116. package/src/worktree/cleanup.ts +112 -24
  117. package/src/worktree/worktree-manager.ts +128 -15
  118. package/test-bugs-all.mjs +10 -6
  119. 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
 
@@ -61,7 +61,7 @@ import {
61
61
  type CrewWidgetState,
62
62
  stopCrewWidget,
63
63
  updateCrewWidget,
64
- } from "../ui/crew-widget.ts";
64
+ } from "../ui/widget/index.ts";
65
65
  import { summarizeHeartbeats } from "../ui/heartbeat-aggregator.ts";
66
66
  import {
67
67
  requestRender,
@@ -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,
@@ -99,6 +100,7 @@ import {
99
100
  import { createJsonlSink, type NotificationSink } from "./notification-sink.ts";
100
101
  import { runArtifactCleanup } from "./registration/artifact-cleanup.ts";
101
102
  import { registerTeamCommands } from "./registration/commands.ts";
103
+ import { registerBriefToolOverrides } from "./registration/brief-tool-overrides.ts";
102
104
  import { registerCompactionGuard } from "./registration/compaction-guard.ts";
103
105
  import {
104
106
  __test__subagentSpawnParams,
@@ -108,6 +110,7 @@ import {
108
110
  import { registerSubagentTools } from "./registration/subagent-tools.ts";
109
111
  import { registerTeamTool } from "./registration/team-tool.ts";
110
112
  import { handleTeamTool } from "./team-tool.ts";
113
+ import { persistScheduledJobUpdate } from "./team-tool/handle-schedule.ts";
111
114
 
112
115
  let _cachedOTLPExporter: typeof OTLPExporterType | undefined;
113
116
  async function importOTLPExporter(): Promise<typeof OTLPExporterType> {
@@ -132,6 +135,8 @@ import {
132
135
  } from "../runtime/crash-recovery.ts";
133
136
  import { appendDeadletter } from "../runtime/deadletter.ts";
134
137
  import { HeartbeatWatcher } from "../runtime/heartbeat-watcher.ts";
138
+ import { cleanupOrphanTempDirs, cleanupLegacyOrphanTempDirs } from "../runtime/pi-args.ts";
139
+ import { cleanupOrphanWorkers } from "../runtime/orphan-worker-registry.ts";
135
140
  import { reconcileOrphanedTempWorkspaces } from "../runtime/stale-reconciler.ts";
136
141
 
137
142
  let _cachedCrashRecovery:
@@ -183,10 +188,14 @@ export function registerPiTeams(pi: ExtensionAPI): void {
183
188
  const disposeI18n = initI18n(pi);
184
189
  resetTimings();
185
190
  time("register:start");
186
- const globalStore = globalThis as Record<string, unknown>;
187
- const runtimeCleanupStoreKey = "__piCrewRuntimeCleanup";
191
+ const globalStore = globalThis as Record<string | symbol, unknown>;
192
+ const runtimeCleanupStoreKey = Symbol("__piCrewRuntimeCleanup");
188
193
  const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
189
194
  time("register:init");
195
+ // Best-effort cleanup of the previous runtime instance. Errors are logged but
196
+ // do not halt new registration — a failing cleanup from a prior instance is
197
+ // preferable to leaving pi-crew unregistered, and any stale state from the
198
+ // previous instance will be reconciled when the new instance initializes.
190
199
  if (typeof previousRuntimeCleanup === "function") {
191
200
  try {
192
201
  previousRuntimeCleanup();
@@ -443,6 +452,34 @@ export function registerPiTeams(pi: ExtensionAPI): void {
443
452
  cleanupOrphanedTempDirs:
444
453
  config.reliability?.cleanupOrphanedTempDirs,
445
454
  });
455
+ // Layer 4: also clean orphan temp dirs under
456
+ // ~/.pi/agent/pi-crew/tmp/ that the SIGKILL'd parent
457
+ // processes left behind. Catches anything Layers 1-3 missed.
458
+ const orphanResult = cleanupOrphanTempDirs();
459
+ if (orphanResult.cleaned > 0) {
460
+ notifyOperator({
461
+ id: `layer4_temp_cleanup_${Date.now()}`,
462
+ severity: "info",
463
+ source: "temp-cleanup",
464
+ title: `Layer 4: cleaned ${orphanResult.cleaned} orphan temp dir(s)`,
465
+ body: `~/.pi/agent/pi-crew/tmp/ orphans older than 24h removed (scanned ${orphanResult.scanned}, failed ${orphanResult.failed}).`,
466
+ });
467
+ }
468
+ // Layer 5: clean legacy /tmp/pi-crew-* prompt/task orphans
469
+ // from before commit 8ba270d moved temp dirs out of /tmp.
470
+ // The existing reconcileOrphanedTempWorkspaces only cleans
471
+ // dirs containing .crew/state/runs/ (run-state dirs), so
472
+ // prompt/task orphans are never touched by Layer 3.
473
+ const legacyResult = cleanupLegacyOrphanTempDirs();
474
+ if (legacyResult.cleaned > 0) {
475
+ notifyOperator({
476
+ id: `layer5_legacy_temp_cleanup_${Date.now()}`,
477
+ severity: "info",
478
+ source: "temp-cleanup",
479
+ title: `Layer 5: cleaned ${legacyResult.cleaned} legacy /tmp/pi-crew-* orphan(s)`,
480
+ body: `Pre-fix /tmp/pi-crew-* prompt/task orphans (no .crew/state/runs/, >24h) removed (scanned ${legacyResult.scanned}, failed ${legacyResult.failed}).`,
481
+ });
482
+ }
446
483
  } catch (error) {
447
484
  logInternalError("register.tempAutoRepair", error);
448
485
  }
@@ -478,13 +515,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
478
515
  );
479
516
  }
480
517
  };
481
- const autoRecoveryLast = new Map<string, number>();
518
+ const autoRecoveryLast = new Map<string, { insertedAt: number; lastAccessAt: number }>();
482
519
  // FIX (Round 22, defensive cap): Bound the cooldown-gate Map. Each run
483
520
  // contributes up to 4 keys (one per maybeNotifyHealth kind). Without a cap,
484
521
  // 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.
522
+ // thousands of entries. Eviction: oldest lastAccessAt first — uses LRU-like
523
+ // semantics so entries that are still being actively accessed (re-accessed
524
+ // before eviction) survive longer. This is fairer than insertion-order
525
+ // eviction under high churn where many entries expire naturally before
526
+ // being re-accessed.
488
527
  const AUTO_RECOVERY_LAST_MAX_ENTRIES = 1000;
489
528
  const configureDeliveryCoordinator = (): void => {
490
529
  deliveryCoordinator?.dispose();
@@ -812,8 +851,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
812
851
  .then(({ startForegroundWatchdog }) => {
813
852
  startForegroundWatchdog({ pi, cwd: ctx.cwd, runId });
814
853
  })
815
- .catch(() => {
816
- /* non-critical */
854
+ .catch((error) => {
855
+ logInternalError("register.foreground-watchdog-import", error);
817
856
  });
818
857
  }
819
858
  setImmediate(() => {
@@ -823,7 +862,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
823
862
  error instanceof Error ? error.message : String(error);
824
863
  if (runId) {
825
864
  try {
826
- const loaded = loadRunManifestById(ctx.cwd, runId);
865
+ 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
866
  if (
828
867
  loaded &&
829
868
  loaded.manifest.status !== "completed" &&
@@ -877,7 +916,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
877
916
  }
878
917
  }
879
918
  if (ownerCurrent && runId) {
880
- const loaded = loadRunManifestById(ctx.cwd, runId);
919
+ 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
920
  const status = loaded?.manifest.status ?? "finished";
882
921
  const level =
883
922
  status === "failed" || status === "blocked"
@@ -971,9 +1010,25 @@ export function registerPiTeams(pi: ExtensionAPI): void {
971
1010
  const registry = (globalThis as Record<symbol | string, unknown>)[
972
1011
  CREW_REGISTRY_KEY
973
1012
  ] as Record<string, unknown>;
974
- registry.getRecord = (runId: string) =>
1013
+ // Phase 3b (defensive): Validate registry structure before patching methods.
1014
+ // If a previous occupant left a non-conforming object, replace it entirely.
1015
+ // This prevents runtime failures if getRecord/listRuns/etc. are called on a
1016
+ // malformed predecessor value.
1017
+ if (
1018
+ registry === null ||
1019
+ typeof registry !== "object" ||
1020
+ Array.isArray(registry)
1021
+ ) {
1022
+ (globalThis as Record<symbol | string, unknown>)[
1023
+ CREW_REGISTRY_KEY
1024
+ ] = {};
1025
+ }
1026
+ const validatedRegistry = (globalThis as Record<symbol | string, unknown>)[
1027
+ CREW_REGISTRY_KEY
1028
+ ] as Record<string, unknown>;
1029
+ validatedRegistry.getRecord = (runId: string) =>
975
1030
  manifestCacheForRegistry.get(runId);
976
- registry.listRuns = () =>
1031
+ validatedRegistry.listRuns = () =>
977
1032
  manifestCacheForRegistry
978
1033
  .list(100)
979
1034
  .map(
@@ -987,7 +1042,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
987
1042
  goal: m.goal,
988
1043
  }),
989
1044
  );
990
- registry.appendEvent = (
1045
+ validatedRegistry.appendEvent = (
991
1046
  runId: string,
992
1047
  event: Record<string, unknown>,
993
1048
  ) => {
@@ -1003,7 +1058,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1003
1058
  ),
1004
1059
  );
1005
1060
  };
1006
- registry.waitForAll = async (runId: string) => {
1061
+ validatedRegistry.waitForAll = async (runId: string) => {
1007
1062
  // LAZY: state-store only needed for post-completion polling (waitForAll) and sync hasRunning check; avoid at startup.
1008
1063
  const { loadRunManifestById } = await import(
1009
1064
  "../state/state-store.ts"
@@ -1022,7 +1077,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1022
1077
  while (!check())
1023
1078
  await new Promise((resolve) => setTimeout(resolve, 500));
1024
1079
  };
1025
- registry.hasRunning = async (runId: string) => {
1080
+ validatedRegistry.hasRunning = async (runId: string) => {
1026
1081
  const manifest = manifestCacheForRegistry.get(runId);
1027
1082
  if (!manifest) return false;
1028
1083
  // LAZY: state-store only needed in hasRunning; avoid at startup.
@@ -1112,7 +1167,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1112
1167
  otlpExporter = undefined;
1113
1168
  metricRegistry = undefined;
1114
1169
  deliveryCoordinator?.dispose();
1115
- clearHooks();
1170
+ clearHooksScoped();
1116
1171
  uninstallCrewGlobalRegistry();
1117
1172
  overflowTracker?.dispose();
1118
1173
  deliveryCoordinator = undefined;
@@ -1140,6 +1195,17 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1140
1195
 
1141
1196
  pi.on("session_start", (_event, ctx) => {
1142
1197
  runArtifactCleanup(ctx.cwd);
1198
+
1199
+ // Restore brief mode state from session entries
1200
+ try {
1201
+ const entries = ctx.sessionManager?.getEntries?.();
1202
+ if (entries) {
1203
+ import("../ui/tool-renderers/brief-mode.ts").then(({ restoreBriefState }) => {
1204
+ restoreBriefState(entries);
1205
+ }).catch(() => {/* non-critical */});
1206
+ }
1207
+ } catch { /* non-critical */ }
1208
+
1143
1209
  time("register.session-start");
1144
1210
  cleanedUp = false;
1145
1211
  sessionGeneration++;
@@ -1150,10 +1216,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1150
1216
  notifyActiveRuns(ctx);
1151
1217
 
1152
1218
  // Auto-cancel orphaned runs from dead sessions
1153
- // Extract sessionId from context — validate runtime type instead of unsafe cast.
1219
+ // Extract sessionId from context — use Object.getOwnPropertyDescriptor
1220
+ // to safely access property without triggering Proxy traps, then validate.
1154
1221
  const rawSessionId =
1155
- typeof ctx === "object" && ctx !== null && "sessionId" in ctx
1156
- ? (ctx as Record<string, unknown>).sessionId
1222
+ typeof ctx === "object" && ctx !== null
1223
+ ? Object.getOwnPropertyDescriptor(ctx, "sessionId")?.value
1157
1224
  : undefined;
1158
1225
  const currentSessionId =
1159
1226
  typeof rawSessionId === "string" && rawSessionId.length > 0
@@ -1219,6 +1286,54 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1219
1286
  }
1220
1287
  }
1221
1288
 
1289
+ // Startup cleanup (Fix A): run orphan-temp-dir cleanup
1290
+ // immediately on session_start so we don't wait 5 minutes
1291
+ // for the first timer tick. Especially important after
1292
+ // a SIGKILL'd previous session that left thousands of
1293
+ // orphan temp dirs behind.
1294
+ try {
1295
+ const orphanTmp = cleanupOrphanTempDirs();
1296
+ const legacyTmp = cleanupLegacyOrphanTempDirs();
1297
+ if (orphanTmp.cleaned > 0 || legacyTmp.cleaned > 0) {
1298
+ notifyOperator({
1299
+ id: `startup_temp_cleanup_${Date.now()}`,
1300
+ severity: "info",
1301
+ source: "temp-cleanup",
1302
+ title: `Startup cleanup: removed ${orphanTmp.cleaned + legacyTmp.cleaned} orphan temp dir(s)`,
1303
+ body: `${orphanTmp.cleaned} from ~/.pi/agent/pi-crew/tmp/ + ${legacyTmp.cleaned} legacy /tmp/pi-crew-*`,
1304
+ });
1305
+ }
1306
+ } catch (error) {
1307
+ logInternalError(
1308
+ "register.sessionStart.startupTempCleanup",
1309
+ error,
1310
+ );
1311
+ }
1312
+
1313
+ // Orphan worker cleanup (Fix B): kill stale background-runner
1314
+ // processes from previous (SIGKILL'd) sessions. Workers
1315
+ // detached via setsid+unref outlive the spawning pi
1316
+ // session, and the per-worker parent-guard is intentionally
1317
+ // disabled for background-runner (BUG #17 design). So
1318
+ // orphans can only be cleaned from the next session_start.
1319
+ try {
1320
+ const orphanWorkers = cleanupOrphanWorkers(currentSessionId);
1321
+ if (orphanWorkers.killed > 0) {
1322
+ notifyOperator({
1323
+ id: `orphan_workers_cleanup`,
1324
+ severity: "info",
1325
+ source: "worker-cleanup",
1326
+ title: `Cleaned up ${orphanWorkers.killed} orphan worker(s)`,
1327
+ body: `Background workers from previous (SIGKILL'd) sessions were terminated (pruned ${orphanWorkers.pruned} dead, kept ${orphanWorkers.kept}).`,
1328
+ });
1329
+ }
1330
+ } catch (error) {
1331
+ logInternalError(
1332
+ "register.sessionStart.orphanWorkers",
1333
+ error,
1334
+ );
1335
+ }
1336
+
1222
1337
  // Global purge of stale active-run-index entries
1223
1338
  try {
1224
1339
  const { purged } = purgeStaleActiveRunIndexFn();
@@ -1324,19 +1439,60 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1324
1439
  runParams = { action: "run", team: "default", goal: job.prompt };
1325
1440
  }
1326
1441
  if (runParams.action !== "run") return `scheduled-${job.id}-${Date.now()}`;
1442
+ const agentId = `scheduled-${job.id}-${Date.now()}`;
1327
1443
  setImmediate(async () => {
1328
1444
  try {
1329
- await handleTeamTool(
1445
+ const runResult = await handleTeamTool(
1330
1446
  { action: "run", team: runParams.team, goal: runParams.goal, async: true },
1331
1447
  { cwd: ctx.cwd, sessionId },
1332
1448
  );
1449
+ // Track the runId so remove() can cancel spawned runs
1450
+ const runId = runResult?.details?.runId;
1451
+ if (runId && typeof runId === "string") {
1452
+ crewScheduler?.recordSpawnedRun(job.id, runId);
1453
+ // Update run manifest with scheduler provenance for traceability
1454
+ try {
1455
+ const cwd = ctx.cwd ?? process.cwd();
1456
+ const loaded = loadRunManifestById(cwd, runId);
1457
+ if (loaded) {
1458
+ const { atomicWriteJson } = await import("../state/atomic-write.ts");
1459
+ atomicWriteJson(loaded.manifest.stateRoot + "/manifest.json", {
1460
+ ...loaded.manifest,
1461
+ schedulerJobId: job.id,
1462
+ schedulerName: job.name,
1463
+ });
1464
+ }
1465
+ } catch { /* best-effort provenance tracking */ }
1466
+ }
1467
+ // Persist updated job with spawnedRunIds to settings
1468
+ try {
1469
+ const updatedJob = crewScheduler?.list().find((j) => j.id === job.id);
1470
+ if (updatedJob) persistScheduledJobUpdate(ctx.cwd, updatedJob);
1471
+ } catch { /* best-effort */ }
1472
+ // Update run count
1473
+ crewScheduler?.update(job.id, {
1474
+ runCount: job.runCount + 1,
1475
+ lastRun: new Date().toISOString(),
1476
+ lastStatus: "success",
1477
+ });
1333
1478
  } catch (err) {
1334
1479
  logInternalError("scheduler.execute", err);
1480
+ crewScheduler?.update(job.id, { lastStatus: "error" });
1335
1481
  }
1336
1482
  });
1337
- return `scheduled-${job.id}-${Date.now()}`;
1483
+ return agentId;
1338
1484
  },
1339
1485
  finalizer: () => {},
1486
+ runCancelFn: (runId: string) => {
1487
+ try {
1488
+ handleTeamTool(
1489
+ { action: "cancel", runId, confirm: true },
1490
+ { cwd: ctx.cwd, sessionId },
1491
+ ).catch((err) => logInternalError("scheduler.runCancelFn", err, `runId=${runId}`));
1492
+ } catch (err) {
1493
+ logInternalError("scheduler.runCancelFn.sync", err, `runId=${runId}`);
1494
+ }
1495
+ },
1340
1496
  });
1341
1497
  // Wire scheduler into handle-schedule.ts so handlers can add/list jobs.
1342
1498
  // Uses a global symbol so the module doesn't need a direct circular import.
@@ -1534,18 +1690,26 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1534
1690
  const previous = autoRecoveryLast.get(key);
1535
1691
  if (
1536
1692
  previous !== undefined &&
1537
- now - previous < 5 * 60_000
1693
+ now - previous.lastAccessAt < 5 * 60_000
1538
1694
  )
1539
1695
  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.
1696
+ // Defensive cap: evict entry with oldest lastAccessAt before
1697
+ // inserting/updating when size exceeds the limit. Uses LRU
1698
+ // semantics so entries that are still being actively
1699
+ // accessed survive longer than insertion-order eviction.
1543
1700
  while (autoRecoveryLast.size >= AUTO_RECOVERY_LAST_MAX_ENTRIES) {
1544
- const oldest = autoRecoveryLast.keys().next().value;
1545
- if (oldest === undefined) break;
1546
- autoRecoveryLast.delete(oldest);
1701
+ let oldestKey: string | undefined;
1702
+ let oldestAccess = Infinity;
1703
+ for (const [k, v] of autoRecoveryLast) {
1704
+ if (v.lastAccessAt < oldestAccess) {
1705
+ oldestAccess = v.lastAccessAt;
1706
+ oldestKey = k;
1707
+ }
1708
+ }
1709
+ if (oldestKey === undefined) break;
1710
+ autoRecoveryLast.delete(oldestKey);
1547
1711
  }
1548
- autoRecoveryLast.set(key, now);
1712
+ autoRecoveryLast.set(key, { insertedAt: now, lastAccessAt: now });
1549
1713
  notifyOperator({
1550
1714
  id: key,
1551
1715
  severity: "warning",
@@ -1752,8 +1916,15 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1752
1916
  );
1753
1917
  const paths: string[] = [];
1754
1918
  if (fs.existsSync(extSkillDir)) paths.push(extSkillDir);
1755
- if (skillDir !== extSkillDir && fs.existsSync(skillDir))
1756
- paths.push(skillDir);
1919
+ if (skillDir !== extSkillDir && fs.existsSync(skillDir)) {
1920
+ // Validate skillDir is within sessionCwd to prevent path traversal
1921
+ try {
1922
+ resolveContainedPath(sessionCwd, "skills");
1923
+ paths.push(skillDir);
1924
+ } catch {
1925
+ // skillDir outside sessionCwd boundary — skip
1926
+ }
1927
+ }
1757
1928
  return paths.length > 0 ? { skillPaths: paths } : {};
1758
1929
  });
1759
1930
  } catch {
@@ -1777,7 +1948,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1777
1948
  if (event.toolName !== "team") return;
1778
1949
  const rawInput = event.input;
1779
1950
  if (!rawInput || typeof rawInput !== "object") return;
1780
- const input = rawInput as { action?: unknown; confirm?: unknown; force?: unknown };
1951
+ const input = asRecord(rawInput);
1952
+ if (!input) return;
1781
1953
  const action = typeof input.action === "string" ? input.action : undefined;
1782
1954
  const destructiveActions = new Set(["delete", "forget", "prune", "cleanup"]);
1783
1955
  if (!action || !destructiveActions.has(action)) return;
@@ -1814,6 +1986,12 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1814
1986
 
1815
1987
  registerCleanupHandler(pi);
1816
1988
 
1989
+ // Brief tool overrides DISABLED: re-registering built-in tools replaced Pi's
1990
+ // superior native renderers (syntax highlighting, diff views, full file
1991
+ // content) with inferior custom ones, and caused renderCall/renderResult
1992
+ // to duplicate path/command info. Pi's native rendering is better.
1993
+ // To re-enable, uncomment: registerBriefToolOverrides(pi, process.cwd());
1994
+
1817
1995
  registerTeamCommands(pi, {
1818
1996
  startForegroundRun,
1819
1997
  abortForegroundRun,