pi-crew 0.9.38 → 0.9.39

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.39] — foreground abort fix + security hardening (2026-07-15)
4
+
5
+ ### Fixes
6
+
7
+ - **Foreground abort semantics** — `src/extension/register.ts`. The `session_shutdown` handler now checks `event.reason` before aborting foreground team runs. Session switch (`reason="resume"/"new"/"fork"`) preserves foreground runs per P0 fix intent; only actual shutdown (`reason="quit"/"reload"`) aborts them. Extracted `cleanupSessionResourcesOnly()` for session-switch cleanup path.
8
+ - **`createRunPaths` cwd validation** — `src/state/state-store.ts`. Added defensive check rejecting `undefined`/empty/null `cwd` to prevent accidental `undefined/` directory creation.
9
+ - **EPERM lock handling documented** — `src/state/locks.ts`. Added detailed JSDoc explaining the EPERM-as-stealable trade-off rationale. Added `SEC-008` entry in `SECURITY-ISSUES.md` as accepted risk.
10
+
11
+ ### Cleanup
12
+
13
+ - Removed stale `undefined/` directory and added to `.gitignore`.
14
+
3
15
  ## [0.9.38] — cold context activation + UI polish (2026-07-15)
4
16
 
5
17
  Fixes and UX polish following v0.9.37's cold-context groundwork:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.38",
3
+ "version": "0.9.39",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -702,6 +702,58 @@ export function registerPiTeams(pi: ExtensionAPI): void {
702
702
  };
703
703
  });
704
704
 
705
+ /**
706
+ * Cleanup session resources WITHOUT aborting foreground team runs.
707
+ * Used during session switch (reason="resume"/"new"/"fork") to let foreground
708
+ * runs complete naturally when the session context is torn down.
709
+ */
710
+ const cleanupSessionResourcesOnly = (): void => {
711
+ if (cleanedUp) return;
712
+ cleanedUp = true;
713
+ if (preloadTimer) {
714
+ clearTimeout(preloadTimer);
715
+ preloadTimer = undefined;
716
+ }
717
+ crewRunWatchers?.closeAll();
718
+ crewRunWatchers = undefined;
719
+ userCrewWatchers?.closeAll();
720
+ userCrewWatchers = undefined;
721
+ stopSessionBoundSubagents();
722
+ // P0 fix: Do NOT abort foreground team runs on session switch.
723
+ // Foreground team runs run in the same process as the session; they naturally clean up
724
+ // when the session context is torn down. Only subagents need explicit abort on switch.
725
+ // Foreground runs will be aborted by cleanupRuntime() during actual session shutdown.
726
+ crewScheduler?.stop();
727
+ stopAsyncRunNotifier(notifierState);
728
+
729
+ // P0: Purge all stale active-run-index entries on session cleanup.
730
+ purgeStaleActiveRunIndexSyncIfLoaded();
731
+
732
+ stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
733
+ clearPiCrewPowerbar(pi.events);
734
+ disposePowerbarCoalescer();
735
+ disposeObservability(observabilityState, cleanedUp);
736
+ lifecycleState.deliveryCoordinator?.dispose();
737
+ clearHooksScoped();
738
+ uninstallCrewGlobalRegistry();
739
+ lifecycleState.overflowTracker?.dispose();
740
+ lifecycleState.deliveryCoordinator = undefined;
741
+ lifecycleState.overflowTracker = undefined;
742
+ manifestCache.dispose();
743
+ runSnapshotCache.dispose?.();
744
+ clearProjectRootCache();
745
+ renderScheduler?.dispose();
746
+ renderScheduler = undefined;
747
+ autoRecoveryLast.clear();
748
+ disposeNotifications(lifecycleState);
749
+ rpcHandle?.unsubscribe();
750
+ rpcHandle = undefined;
751
+ disposeI18n();
752
+ sessionGeneration += 1;
753
+ currentCtx = undefined;
754
+ if (globalStore[runtimeCleanupStoreKey] === cleanupSessionResourcesOnly) delete globalStore[runtimeCleanupStoreKey];
755
+ };
756
+
705
757
  const cleanupRuntime = (): void => {
706
758
  if (cleanedUp) return;
707
759
  cleanedUp = true;
@@ -774,6 +826,20 @@ export function registerPiTeams(pi: ExtensionAPI): void {
774
826
  };
775
827
  globalStore[runtimeCleanupStoreKey] = cleanupRuntime;
776
828
 
829
+ // P0 fix: Check session_shutdown reason to determine whether to abort foreground runs.
830
+ // - reason="quit" or "reload": Actual shutdown — abort foreground runs.
831
+ // - reason="resume"/"new"/"fork": Session switch — let foreground runs complete.
832
+ pi.on("session_shutdown", (event) => {
833
+ const reason = typeof event === "object" && event !== null && "reason" in event ? (event as { reason: string }).reason : undefined;
834
+ if (reason === "quit" || reason === "reload") {
835
+ // Actual shutdown — abort foreground runs and cleanup everything
836
+ cleanupRuntime();
837
+ } else {
838
+ // Session switch (resume/new/fork) — cleanup resources but preserve foreground runs
839
+ cleanupSessionResourcesOnly();
840
+ }
841
+ });
842
+
777
843
  pi.on("session_start", (_event, ctx) => {
778
844
  runArtifactCleanup(ctx.cwd);
779
845
 
@@ -1385,7 +1451,6 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1385
1451
  stopAsyncRunNotifier(notifierState);
1386
1452
  stopSessionBoundSubagents();
1387
1453
  });
1388
- pi.on("session_shutdown", () => cleanupRuntime());
1389
1454
 
1390
1455
  // Phase 11a: Dynamic resource discovery — inject pi-crew skill paths.
1391
1456
  try {
@@ -79,6 +79,29 @@ function isLockHolderAlive(filePath: string): boolean {
79
79
  * Returns `{ canSteal: true }` if the lock is stale OR the holder is dead
80
80
  * (safe to forcibly remove); `{ canSteal: false }` if it is fresh AND held by
81
81
  * a live process (must keep waiting).
82
+ *
83
+ * ## EPERM Handling (Accepted Risk)
84
+ *
85
+ * When `process.kill(pid, 0)` returns EPERM, it means the holder process
86
+ * EXISTS but we lack permission to signal it. We treat this as "not alive"
87
+ * (stealable) because:
88
+ *
89
+ * 1. **Blocking indefinitely is worse** — on shared systems, a holder running
90
+ * under a different user/permission context would block us forever.
91
+ * 2. **EPERM requires elevated privileges** — on single-user workstations
92
+ * (the typical pi-crew environment), EPERM is rare.
93
+ * 3. **Defense in depth** — the lock staleness check provides a secondary
94
+ * safeguard; we only steal if the lock is also stale.
95
+ *
96
+ * **Trade-off:** On multi-user systems, this could theoretically allow two
97
+ * processes in the critical section simultaneously if: (a) holder is alive
98
+ * under different permissions, AND (b) our steal attempt races with holder's
99
+ * release. The practical risk is low because:
100
+ * - Lock holders typically complete quickly
101
+ * - Stale timeout provides a safety window
102
+ * - Critical sections are designed to tolerate rare races
103
+ *
104
+ * See also: SECURITY-ISSUES.md SEC-008 for documented acceptance.
82
105
  */
83
106
  function readLockSnapshot(filePath: string, staleMs: number): { canSteal: boolean } {
84
107
  let stat: fs.Stats | undefined;
@@ -203,6 +203,9 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
203
203
  }
204
204
 
205
205
  export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
206
+ if (!cwd || typeof cwd !== "string") {
207
+ throw new Error(`Invalid cwd: ${cwd}`);
208
+ }
206
209
  assertSafePathId("runId", runId);
207
210
  const baseRoot = scopeBaseRoot(cwd);
208
211
  const stateRoot = resolveContainedRelativePath(path.join(baseRoot, DEFAULT_PATHS.state.runsSubdir), runId, "runId");