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 +12 -0
- package/package.json +1 -1
- package/src/extension/register.ts +66 -1
- package/src/state/locks.ts +23 -0
- package/src/state/state-store.ts +3 -0
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
|
@@ -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 {
|
package/src/state/locks.ts
CHANGED
|
@@ -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;
|
package/src/state/state-store.ts
CHANGED
|
@@ -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");
|