pi-crew 0.9.57 → 0.9.59
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 +49 -0
- package/dist/index.mjs +22609 -21373
- package/package.json +1 -1
- package/skills/distill-software/SKILL.md +7 -1
- package/src/agents/discover-agents.ts +6 -0
- package/src/config/config.ts +13 -2
- package/src/config/markers.ts +9 -1
- package/src/extension/action-suggestions.ts +1 -1
- package/src/extension/async-notifier.ts +13 -5
- package/src/extension/registration/context-builder.ts +5 -2
- package/src/extension/registration/crash-recovery-cache.ts +2 -2
- package/src/extension/registration/lazy-configurers.ts +1 -1
- package/src/extension/registration/lifecycle-handlers.ts +25 -10
- package/src/extension/registration/observability.ts +8 -4
- package/src/extension/registration/registration-types.ts +2 -2
- package/src/extension/registration/runtime-cleanup.ts +10 -2
- package/src/extension/registration/subagent-manager-setup.ts +9 -4
- package/src/extension/registration/subagent-tools.ts +15 -3
- package/src/extension/run-import.ts +19 -2
- package/src/extension/run-maintenance.ts +25 -9
- package/src/extension/session-summary.ts +5 -0
- package/src/extension/team-tool/destructive-gate.ts +12 -6
- package/src/extension/team-tool/health-monitor.ts +7 -5
- package/src/extension/team-tool/intent-policy.ts +9 -0
- package/src/extension/team-tool/lifecycle-actions.ts +27 -4
- package/src/extension/team-tool/run-deadline.ts +7 -2
- package/src/extension/team-tool/run.ts +23 -4
- package/src/extension/team-tool/status.ts +2 -2
- package/src/extension/team-tool.ts +126 -51
- package/src/runtime/background-runner.ts +36 -2
- package/src/runtime/delivery-coordinator.ts +24 -3
- package/src/runtime/foreground-watchdog.ts +2 -2
- package/src/runtime/model/model-fallback.ts +3 -1
- package/src/runtime/model/provider-extensions.ts +36 -0
- package/src/runtime/model/runtime-warmup.ts +41 -0
- package/src/runtime/peer-dep.ts +35 -8
- package/src/runtime/recovery/crash-recovery.ts +34 -4
- package/src/runtime/skill-instructions.ts +1 -1
- package/src/runtime/stale-reconciler.ts +11 -19
- package/src/runtime/subagent-manager.ts +15 -6
- package/src/runtime/task-packet.ts +26 -4
- package/src/runtime/task-runner/run-projection.ts +4 -1
- package/src/runtime/team-runner.ts +94 -72
- package/src/runtime/verification/completion-guard.ts +10 -1
- package/src/schema/team-tool-schema.ts +152 -38
- package/src/skills/validate.ts +28 -2
- package/src/state/atomic-write.ts +23 -12
- package/src/state/contracts.ts +1 -0
- package/src/state/coordination/locks.ts +20 -7
- package/src/state/coordination/mailbox.ts +20 -2
- package/src/state/gitignore-manager.ts +5 -1
- package/src/state/stores/artifact-store.ts +5 -5
- package/src/state/stores/run-cache.ts +9 -1
- package/src/state/stores/state-store.ts +64 -10
- package/src/ui/deploy-bundled-themes.ts +11 -0
- package/src/ui/powerbar-publisher.ts +31 -6
- package/src/ui/run-dashboard.ts +7 -1
- package/src/ui/syntax-highlight.ts +31 -12
- package/src/ui/widget/index.ts +9 -1
- package/src/ui/widget/widget-model.ts +1 -1
- package/src/ui/widget/widget-types.ts +3 -0
- package/src/utils/env-filter.ts +25 -11
- package/src/utils/paths.ts +20 -1
- package/src/utils/redaction.ts +22 -1
- package/src/utils/session-utils.ts +42 -19
- package/src/worktree/worktree-manager.ts +2 -2
|
@@ -239,12 +239,20 @@ function timingSafeTokenMatch(a: string, b: string): boolean {
|
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
/**
|
|
242
|
-
* Release the lock we (this process) just acquired. Unlike releaseLock, this
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
242
|
+
* Release the lock we (this process) just acquired. Unlike releaseLock, this is
|
|
243
|
+
* used in the `finally` blocks of withRunLock and withRunLockSync so the file was
|
|
244
|
+
* created earlier in the SAME call. Within the same process the new acquire uses
|
|
245
|
+
* a fresh randomUUID token, so token matching would falsely fail and leak the
|
|
246
|
+
* file across releases.
|
|
247
|
+
*
|
|
248
|
+
* LOCK-1 (Round 2): PID-guarded release. Previously this deleted
|
|
249
|
+
* UNCONDITIONALLY — if our critical section exceeded staleMs, another process
|
|
250
|
+
* could steal our lock (overwrite the file with its own pid); our finally would
|
|
251
|
+
* then DELETE THE STEALER's lock, breaking mutual exclusion. We now verify the
|
|
252
|
+
* lock file still records OUR pid before removing; if it records a different pid
|
|
253
|
+
* the lock was stolen and the current holder owns it. Same-process re-acquire is
|
|
254
|
+
* preserved (pid matches → delete). Mirrors the proven pattern in event-log.ts
|
|
255
|
+
* (Round 26, BUG 5).
|
|
248
256
|
*
|
|
249
257
|
* Symlink guard is preserved: if a symlink appeared since our writeLockFile, we
|
|
250
258
|
* don't rm it (defense against attacker-planted symlinks).
|
|
@@ -257,7 +265,12 @@ export function releaseOwnLock(filePath: string, _token: string): void {
|
|
|
257
265
|
/* ENOENT is fine */
|
|
258
266
|
}
|
|
259
267
|
try {
|
|
260
|
-
fs.
|
|
268
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
269
|
+
const holderPid = (JSON.parse(raw) as { pid?: unknown })?.pid;
|
|
270
|
+
if (holderPid === process.pid) {
|
|
271
|
+
fs.rmSync(filePath, { force: true });
|
|
272
|
+
}
|
|
273
|
+
// holderPid !== process.pid → lock stolen by another process; do NOT touch.
|
|
261
274
|
} catch (error) {
|
|
262
275
|
const code = (error as NodeJS.ErrnoException).code;
|
|
263
276
|
if (code !== "ENOENT") {
|
|
@@ -238,7 +238,9 @@ function ensureTaskMailbox(manifest: TeamRunManifest, taskId: string): void {
|
|
|
238
238
|
taskMailboxDir(manifest, taskId, true);
|
|
239
239
|
for (const direction of ["inbox", "outbox"] as const) {
|
|
240
240
|
const filePath = mailboxFile(manifest, direction, taskId, true);
|
|
241
|
-
|
|
241
|
+
// STATE-10: use atomicWriteFile for consistency with ensureRunMailbox —
|
|
242
|
+
// the old fs.writeFileSync was non-atomic (TOCTOU on concurrent reads).
|
|
243
|
+
if (!fs.existsSync(filePath)) atomicWriteFile(filePath, "");
|
|
242
244
|
}
|
|
243
245
|
}
|
|
244
246
|
|
|
@@ -472,7 +474,23 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
|
|
|
472
474
|
};
|
|
473
475
|
setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, state });
|
|
474
476
|
return state;
|
|
475
|
-
} catch {
|
|
477
|
+
} catch (error) {
|
|
478
|
+
// NEW-R4: a corrupt delivery.json was previously swallowed silently, returning
|
|
479
|
+
// empty → messages appear undelivered → re-delivery on every replay. Quarantine
|
|
480
|
+
// the corrupt file (preserve for diagnosis) + log prominently so the re-delivery
|
|
481
|
+
// risk is visible. Mirror the state-store quarantineCorruptFile pattern.
|
|
482
|
+
const quarantinePath = `${filePath}.corrupt-${Date.now()}`;
|
|
483
|
+
try {
|
|
484
|
+
fs.renameSync(filePath, quarantinePath);
|
|
485
|
+
} catch (renameError) {
|
|
486
|
+
logInternalError("mailbox.readDeliveryState.quarantine", renameError, `filePath=${filePath}`);
|
|
487
|
+
}
|
|
488
|
+
// NEW-R4: prominent (ungated) error so corrupt-delivery re-delivery risk is
|
|
489
|
+
// visible even without PI_TEAMS_DEBUG — messages may be re-delivered.
|
|
490
|
+
console.error(
|
|
491
|
+
`[pi-crew:mailbox.readDeliveryState] corrupt delivery.json quarantined to ${quarantinePath} — delivery state reset to empty; messages may be re-delivered. Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
492
|
+
);
|
|
493
|
+
deliveryCache.delete(filePath);
|
|
476
494
|
return { messages: {}, updatedAt: new Date().toISOString() };
|
|
477
495
|
}
|
|
478
496
|
}
|
|
@@ -41,6 +41,10 @@ export async function updateGitignore(gitignorePath: string): Promise<void> {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
if (appended) {
|
|
44
|
-
|
|
44
|
+
// NEW-R5: atomic read-modify-write of the user-visible .gitignore — the old
|
|
45
|
+
// writeFileSync left a truncate-then-write window (crash ⇒ empty/partial file)
|
|
46
|
+
// and a TOCTOU race with concurrent readers. atomicWriteFile (temp+rename)
|
|
47
|
+
// makes the final write atomic while preserving the read-append logic above.
|
|
48
|
+
atomicWriteFile(gitignorePath, current + appended);
|
|
45
49
|
}
|
|
46
50
|
}
|
|
@@ -216,11 +216,11 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
|
|
|
216
216
|
}
|
|
217
217
|
content = redactSecretString(content);
|
|
218
218
|
atomicWriteFile(filePath, content);
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
const contentHash = hashContent(
|
|
219
|
+
// STATE-9: compute the hash on the in-memory content (post-redaction) instead of
|
|
220
|
+
// reading the file back. atomicWriteFile writes the exact content atomically, so
|
|
221
|
+
// the read-back was only ever "defense" for a concurrent-write case that cannot
|
|
222
|
+
// occur — the file is replaced via atomic rename, never partially written.
|
|
223
|
+
const contentHash = hashContent(content);
|
|
224
224
|
const stats = fs.statSync(filePath);
|
|
225
225
|
return {
|
|
226
226
|
kind: options.kind,
|
|
@@ -117,7 +117,15 @@ export function saveRunToCache(
|
|
|
117
117
|
// name collision the old manual temp+rename had).
|
|
118
118
|
const indexPath = path.join(dir, "index.json");
|
|
119
119
|
withFileLockSync(indexPath, () => {
|
|
120
|
-
|
|
120
|
+
// NEW-P4: TOCTOU fix — readFileSync + ENOENT catch instead of existsSync+read
|
|
121
|
+
// (1 syscall, no race). Only ENOENT falls back to {}; a corrupt-but-present
|
|
122
|
+
// index still throws, matching the old existsSync → JSON.parse behavior.
|
|
123
|
+
let index: CacheIndex = {};
|
|
124
|
+
try {
|
|
125
|
+
index = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
128
|
+
}
|
|
121
129
|
index[cacheKey] = entryPath;
|
|
122
130
|
atomicWriteJson(indexPath, index);
|
|
123
131
|
});
|
|
@@ -468,7 +468,7 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
|
|
|
468
468
|
return;
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
-
atomicWriteJson(manifest.tasksPath, tasks);
|
|
471
|
+
atomicWriteJson(manifest.tasksPath, tasks, { compact: true });
|
|
472
472
|
// FIX: Re-populate cache with actual mtime/size for manifest and tasks.
|
|
473
473
|
// Note: We re-read manifest from disk to get its current mtime/size
|
|
474
474
|
// since we only wrote tasks here.
|
|
@@ -542,7 +542,7 @@ export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTask
|
|
|
542
542
|
} catch {
|
|
543
543
|
return;
|
|
544
544
|
}
|
|
545
|
-
atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined,
|
|
545
|
+
atomicWriteJsonCoalesced(manifest.tasksPath, tasks, undefined, { compact: true }, skipCoalesce);
|
|
546
546
|
}
|
|
547
547
|
|
|
548
548
|
export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<void> {
|
|
@@ -555,7 +555,7 @@ export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTa
|
|
|
555
555
|
} catch {
|
|
556
556
|
return;
|
|
557
557
|
}
|
|
558
|
-
await atomicWriteJsonAsync(manifest.tasksPath, tasks);
|
|
558
|
+
await atomicWriteJsonAsync(manifest.tasksPath, tasks, { compact: true });
|
|
559
559
|
}
|
|
560
560
|
|
|
561
561
|
/**
|
|
@@ -589,7 +589,7 @@ async function saveManifestAndTasksAtomic(manifest: TeamRunManifest, tasks: Team
|
|
|
589
589
|
invalidateRunCache(manifest.stateRoot);
|
|
590
590
|
await atomicWriteJsonAsync(path.join(manifest.stateRoot, "manifest.json"), manifest);
|
|
591
591
|
manifestWritten = true;
|
|
592
|
-
await atomicWriteJsonAsync(manifest.tasksPath, tasks);
|
|
592
|
+
await atomicWriteJsonAsync(manifest.tasksPath, tasks, { compact: true });
|
|
593
593
|
tasksWritten = true;
|
|
594
594
|
});
|
|
595
595
|
} catch (err) {
|
|
@@ -615,7 +615,7 @@ function saveManifestAndTasksAtomicSync(manifest: TeamRunManifest, tasks: TeamTa
|
|
|
615
615
|
invalidateRunCache(manifest.stateRoot);
|
|
616
616
|
atomicWriteJson(path.join(manifest.stateRoot, "manifest.json"), manifest);
|
|
617
617
|
manifestWritten = true;
|
|
618
|
-
atomicWriteJson(manifest.tasksPath, tasks);
|
|
618
|
+
atomicWriteJson(manifest.tasksPath, tasks, { compact: true });
|
|
619
619
|
tasksWritten = true;
|
|
620
620
|
});
|
|
621
621
|
} catch (err) {
|
|
@@ -891,7 +891,7 @@ function migrateTasksFile(parsed: unknown, runId: string): TeamTaskState[] {
|
|
|
891
891
|
}
|
|
892
892
|
return extractTaskArray(parsed);
|
|
893
893
|
}
|
|
894
|
-
function loadTasksWithRecovery(tasksPath: string, eventsPath: string, runId: string): TeamTaskState[] {
|
|
894
|
+
export function loadTasksWithRecovery(tasksPath: string, eventsPath: string, runId: string): TeamTaskState[] {
|
|
895
895
|
let content: string;
|
|
896
896
|
try {
|
|
897
897
|
content = fs.readFileSync(tasksPath, "utf-8");
|
|
@@ -907,19 +907,49 @@ function loadTasksWithRecovery(tasksPath: string, eventsPath: string, runId: str
|
|
|
907
907
|
// SyntaxError — corrupt file.
|
|
908
908
|
quarantineCorruptFile(tasksPath);
|
|
909
909
|
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
910
|
-
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed);
|
|
910
|
+
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
|
|
911
911
|
return reconstructed;
|
|
912
912
|
}
|
|
913
913
|
if (!isRecognizableTasksPayload(parsed)) {
|
|
914
914
|
// Neither v0 bare array nor v1+ envelope (e.g. `{}`) — corrupt.
|
|
915
915
|
quarantineCorruptFile(tasksPath);
|
|
916
916
|
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
917
|
-
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed);
|
|
917
|
+
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
|
|
918
918
|
return reconstructed;
|
|
919
919
|
}
|
|
920
920
|
return migrateTasksFile(parsed, runId);
|
|
921
921
|
}
|
|
922
922
|
|
|
923
|
+
/**
|
|
924
|
+
* STATE-3: Load manifest.json with corruption quarantine (sync). Distinguishes:
|
|
925
|
+
* - ENOENT / read error → undefined (legitimate missing run — NOT quarantined).
|
|
926
|
+
* - SyntaxError (unparseable) → CORRUPT → quarantine `.corrupt-<ts>` + log + undefined.
|
|
927
|
+
* Manifest CANNOT be reconstructed from events.jsonl (run.created only carries
|
|
928
|
+
* {team, workflow}), so quarantine + visible log is the recovery — do NOT attempt
|
|
929
|
+
* reconstruction (unlike loadTasksWithRecovery). This prevents a corrupt manifest
|
|
930
|
+
* from silently making a run invisible (STATE-3).
|
|
931
|
+
*/
|
|
932
|
+
export function loadManifestWithRecovery(manifestPath: string, runId: string): TeamRunManifest | undefined {
|
|
933
|
+
let content: string;
|
|
934
|
+
try {
|
|
935
|
+
content = fs.readFileSync(manifestPath, "utf-8");
|
|
936
|
+
} catch {
|
|
937
|
+
// ENOENT / ENOTDIR / other read error → legitimate missing run.
|
|
938
|
+
return undefined;
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
return JSON.parse(content) as TeamRunManifest;
|
|
942
|
+
} catch {
|
|
943
|
+
// SyntaxError → corrupt manifest. Quarantine (preserve for diagnosis) + log,
|
|
944
|
+
// then treat as missing. Do NOT reconstruct (infeasible from events).
|
|
945
|
+
quarantineCorruptFile(manifestPath);
|
|
946
|
+
console.error(
|
|
947
|
+
`[state-store] STATE-3: manifest.json for run ${runId} is corrupt (unparseable) — quarantined to ${manifestPath}.corrupt-*. Run is now treated as missing. Preserve the .corrupt-* file for diagnosis.`,
|
|
948
|
+
);
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
923
953
|
/**
|
|
924
954
|
* ST-4: async twin of {@link loadTasksWithRecovery}.
|
|
925
955
|
*/
|
|
@@ -936,13 +966,13 @@ async function loadTasksWithRecoveryAsync(tasksPath: string, eventsPath: string,
|
|
|
936
966
|
} catch {
|
|
937
967
|
quarantineCorruptFile(tasksPath);
|
|
938
968
|
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
939
|
-
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed);
|
|
969
|
+
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
|
|
940
970
|
return reconstructed;
|
|
941
971
|
}
|
|
942
972
|
if (!isRecognizableTasksPayload(parsed)) {
|
|
943
973
|
quarantineCorruptFile(tasksPath);
|
|
944
974
|
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
945
|
-
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed);
|
|
975
|
+
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
|
|
946
976
|
return reconstructed;
|
|
947
977
|
}
|
|
948
978
|
return migrateTasksFile(parsed, runId);
|
|
@@ -1068,6 +1098,18 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
1068
1098
|
`[state-store] Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
|
|
1069
1099
|
);
|
|
1070
1100
|
}
|
|
1101
|
+
// STATE-3: readJsonFile returns undefined for BOTH missing (ENOENT) and corrupt
|
|
1102
|
+
// (SyntaxError). A corrupt manifest currently makes the run silently invisible.
|
|
1103
|
+
// If the file EXISTS but readJsonFile returned undefined, it is corrupt → quarantine
|
|
1104
|
+
// it (preserve for diagnosis) + log + bail. Manifest reconstruction from events is
|
|
1105
|
+
// infeasible (run.created lacks manifest fields), so quarantine+log is the recovery.
|
|
1106
|
+
if (!manifest && fs.existsSync(manifestPath)) {
|
|
1107
|
+
quarantineCorruptFile(manifestPath);
|
|
1108
|
+
console.error(
|
|
1109
|
+
`[state-store] STATE-3: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
|
|
1110
|
+
);
|
|
1111
|
+
return undefined;
|
|
1112
|
+
}
|
|
1071
1113
|
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
|
|
1072
1114
|
setManifestCache(stateRoot, {
|
|
1073
1115
|
manifest,
|
|
@@ -1193,6 +1235,18 @@ export async function loadRunManifestByIdAsync(
|
|
|
1193
1235
|
`[state-store] Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
|
|
1194
1236
|
);
|
|
1195
1237
|
}
|
|
1238
|
+
// STATE-3 (async twin): readJsonFileAsync returns undefined for BOTH missing (ENOENT)
|
|
1239
|
+
// and corrupt (SyntaxError). Mirror the sync loadRunManifestById STATE-3 fix — if the
|
|
1240
|
+
// file EXISTS but the read returned undefined, it is corrupt → quarantine (preserve for
|
|
1241
|
+
// diagnosis) + log + bail. Manifest reconstruction from events is infeasible (run.created
|
|
1242
|
+
// lacks manifest fields), so quarantine+log is the recovery.
|
|
1243
|
+
if (!manifest && fs.existsSync(manifestPath)) {
|
|
1244
|
+
quarantineCorruptFile(manifestPath);
|
|
1245
|
+
console.error(
|
|
1246
|
+
`[state-store] STATE-3 async: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
|
|
1247
|
+
);
|
|
1248
|
+
return undefined;
|
|
1249
|
+
}
|
|
1196
1250
|
if (!manifest || !validateRunManifestPaths(cwd, runId, manifest, stateRoot, tasksPath)) return undefined;
|
|
1197
1251
|
setManifestCache(stateRoot, {
|
|
1198
1252
|
manifest,
|
|
@@ -40,6 +40,17 @@ export function deployBundledThemes(): number {
|
|
|
40
40
|
|
|
41
41
|
if (!fs.existsSync(srcDir)) return 0;
|
|
42
42
|
|
|
43
|
+
// PERF-7: mtime fast-path. If the destination dir is at least as new as the
|
|
44
|
+
// source dir, the deployed themes are already current — skip the per-file
|
|
45
|
+
// read+compare loop entirely (~22 sync fs ops -> 2 statSync on the steady
|
|
46
|
+
// path). Falls through to the per-file deploy if either stat is missing or
|
|
47
|
+
// the source is newer (e.g. a new pi-crew version shipped updated themes).
|
|
48
|
+
try {
|
|
49
|
+
if (fs.statSync(srcDir).mtimeMs <= fs.statSync(dstDir).mtimeMs) return 0;
|
|
50
|
+
} catch {
|
|
51
|
+
/* dst missing or stat failed -> fall through to per-file deploy */
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
const files = (fs.readdirSync(srcDir) as string[]).filter((f) => f.endsWith(".json"));
|
|
44
55
|
if (files.length === 0) return 0;
|
|
45
56
|
|
|
@@ -2,13 +2,14 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import type { CrewUiConfig } from "../config/config.ts";
|
|
3
3
|
import { listRecentRuns } from "../extension/run-index.ts";
|
|
4
4
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
5
|
-
import { listLiveAgents } from "../runtime/live-session/live-agent-manager.ts";
|
|
5
|
+
import { listLiveAgents, listLiveAgentsByWorkspace } from "../runtime/live-session/live-agent-manager.ts";
|
|
6
6
|
import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
7
7
|
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
8
8
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
9
9
|
import { aggregateUsage } from "../state/usage.ts";
|
|
10
10
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
11
11
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
12
|
+
import { extractSessionId } from "../utils/session-utils.ts";
|
|
12
13
|
import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
13
14
|
import { RenderCoalescer } from "./render-coalescer.ts";
|
|
14
15
|
import type { RunSnapshotCache, RunUiSnapshot } from "./snapshot-types.ts";
|
|
@@ -97,8 +98,9 @@ export function updatePiCrewPowerbar(
|
|
|
97
98
|
ctx?: StatusContext,
|
|
98
99
|
notificationCount = 0,
|
|
99
100
|
preloadedManifests?: TeamRunManifest[],
|
|
101
|
+
workspaceId?: string,
|
|
100
102
|
): void {
|
|
101
|
-
powerbarPublisher.update(events, cwd, config, manifestCache, snapshotCache, ctx, notificationCount, preloadedManifests);
|
|
103
|
+
powerbarPublisher.update(events, cwd, config, manifestCache, snapshotCache, ctx, notificationCount, preloadedManifests, workspaceId);
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
// --- Dedup state + coalescer live on a class instance so they survive hot-reload
|
|
@@ -191,6 +193,7 @@ interface PowerbarUpdateArgs {
|
|
|
191
193
|
ctx?: StatusContext;
|
|
192
194
|
notificationCount: number;
|
|
193
195
|
preloadedManifests?: TeamRunManifest[];
|
|
196
|
+
workspaceId?: string;
|
|
194
197
|
}
|
|
195
198
|
|
|
196
199
|
/**
|
|
@@ -211,7 +214,17 @@ class PowerbarPublisher {
|
|
|
211
214
|
const a = this.#latestArgs;
|
|
212
215
|
this.#latestArgs = null;
|
|
213
216
|
if (!a) return;
|
|
214
|
-
this.update(
|
|
217
|
+
this.update(
|
|
218
|
+
a.events,
|
|
219
|
+
a.cwd,
|
|
220
|
+
a.config,
|
|
221
|
+
a.manifestCache,
|
|
222
|
+
a.snapshotCache,
|
|
223
|
+
a.ctx,
|
|
224
|
+
a.notificationCount,
|
|
225
|
+
a.preloadedManifests,
|
|
226
|
+
a.workspaceId,
|
|
227
|
+
);
|
|
215
228
|
}, 200);
|
|
216
229
|
}
|
|
217
230
|
|
|
@@ -224,10 +237,18 @@ class PowerbarPublisher {
|
|
|
224
237
|
ctx?: StatusContext,
|
|
225
238
|
notificationCount = 0,
|
|
226
239
|
preloadedManifests?: TeamRunManifest[],
|
|
240
|
+
workspaceId?: string,
|
|
227
241
|
): void {
|
|
228
242
|
if (config?.powerbar === false) return;
|
|
229
243
|
const useStatusFallback = !hasPowerbarConsumer(events);
|
|
230
|
-
|
|
244
|
+
// P3 (#10): prefer an explicit workspaceId arg; otherwise self-derive
|
|
245
|
+
// from the ctx every caller already passes (ExtensionContext exposes
|
|
246
|
+
// sessionManager). Back-compat: when neither is present, no filtering.
|
|
247
|
+
const effectiveWorkspaceId = workspaceId ?? extractSessionId(ctx);
|
|
248
|
+
const allRuns = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
|
|
249
|
+
const runs = effectiveWorkspaceId
|
|
250
|
+
? allRuns.filter((run) => !run.ownerSessionId || run.ownerSessionId === effectiveWorkspaceId)
|
|
251
|
+
: allRuns;
|
|
231
252
|
const active = runs
|
|
232
253
|
.map((run) => {
|
|
233
254
|
let snapshot: RunUiSnapshot | undefined;
|
|
@@ -300,7 +321,8 @@ class PowerbarPublisher {
|
|
|
300
321
|
?.model?.split("/")
|
|
301
322
|
.at(-1);
|
|
302
323
|
const tokenText = config?.showTokens === false || !tokenTotal ? undefined : compactTokens(tokenTotal);
|
|
303
|
-
const
|
|
324
|
+
const liveAgents = effectiveWorkspaceId ? listLiveAgentsByWorkspace(effectiveWorkspaceId) : listLiveAgents();
|
|
325
|
+
const liveRunning = liveAgents.filter((a) => a.status === "running").length;
|
|
304
326
|
// Always show consistent status: running count + queued count from live tasks only
|
|
305
327
|
// Avoid snapshot cache for counts to prevent UI jumping
|
|
306
328
|
const runningCount = agents.filter((a) => a.status === "running").length;
|
|
@@ -388,6 +410,7 @@ class PowerbarPublisher {
|
|
|
388
410
|
ctx?: StatusContext,
|
|
389
411
|
notificationCount = 0,
|
|
390
412
|
preloadedManifests?: TeamRunManifest[],
|
|
413
|
+
workspaceId?: string,
|
|
391
414
|
): void {
|
|
392
415
|
if (config?.powerbar === false) return;
|
|
393
416
|
this.#latestArgs = {
|
|
@@ -399,6 +422,7 @@ class PowerbarPublisher {
|
|
|
399
422
|
ctx,
|
|
400
423
|
notificationCount,
|
|
401
424
|
preloadedManifests,
|
|
425
|
+
workspaceId,
|
|
402
426
|
};
|
|
403
427
|
this.#coalescer.request();
|
|
404
428
|
}
|
|
@@ -440,8 +464,9 @@ export function requestPowerbarUpdate(
|
|
|
440
464
|
ctx?: StatusContext,
|
|
441
465
|
notificationCount = 0,
|
|
442
466
|
preloadedManifests?: TeamRunManifest[],
|
|
467
|
+
workspaceId?: string,
|
|
443
468
|
): void {
|
|
444
|
-
powerbarPublisher.request(events, cwd, config, manifestCache, snapshotCache, ctx, notificationCount, preloadedManifests);
|
|
469
|
+
powerbarPublisher.request(events, cwd, config, manifestCache, snapshotCache, ctx, notificationCount, preloadedManifests, workspaceId);
|
|
445
470
|
}
|
|
446
471
|
|
|
447
472
|
/** Dispose the powerbar coalescer. Call during extension cleanup. */
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -518,7 +518,13 @@ export class RunDashboard implements DashboardComponent {
|
|
|
518
518
|
if (!this.options.runProvider) return;
|
|
519
519
|
const selectedRunId = this.selectedRunId();
|
|
520
520
|
const next = this.options.runProvider();
|
|
521
|
-
|
|
521
|
+
// P3 (#8): re-apply the workspaceId filter on EVERY refresh, not just
|
|
522
|
+
// the constructor. Without this, runs from other sessions leak back in
|
|
523
|
+
// on frame 2+ once the runProvider returns the unfiltered manifest list.
|
|
524
|
+
const unfiltered = Array.isArray(next) ? next : this.runs;
|
|
525
|
+
this.runs = this.options.workspaceId
|
|
526
|
+
? unfiltered.filter((run) => !run.ownerSessionId || run.ownerSessionId === this.options.workspaceId)
|
|
527
|
+
: unfiltered;
|
|
522
528
|
if (selectedRunId) {
|
|
523
529
|
const nextIndex = groupedRuns(this.runs, this.options.snapshotCache)
|
|
524
530
|
.filter((row) => row.run)
|
|
@@ -5,10 +5,25 @@
|
|
|
5
5
|
* Falls back to plain text (theme.fg on every line) if the language is
|
|
6
6
|
* unsupported or highlighting fails.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
9
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
10
10
|
import { asCrewTheme } from "./theme-adapter.ts";
|
|
11
11
|
|
|
12
|
+
// PERF-1: cli-highlight loads highlight.js (~190 grammars) at require() time,
|
|
13
|
+
// adding 50-150ms to every cold start if imported eagerly at bundle load.
|
|
14
|
+
// highlight() is only called from SYNC render paths (highlightCode /
|
|
15
|
+
// highlightJson), so it MUST stay sync — lazy-load via a synchronous
|
|
16
|
+
// createRequire shim on first use instead (same mechanism as
|
|
17
|
+
// skills/validate.ts's yaml / utils/git.ts's hosted-git-info). In the esbuild
|
|
18
|
+
// bundle cli-highlight stays external and resolves through this same shim.
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
type CliHighlight = typeof import("cli-highlight");
|
|
21
|
+
let cached: CliHighlight | undefined;
|
|
22
|
+
function cliHighlight(): CliHighlight {
|
|
23
|
+
if (!cached) cached = require("cli-highlight") as CliHighlight;
|
|
24
|
+
return cached;
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
// ── cli-highlight theme bridge ──────────────────────────────────────────
|
|
13
28
|
|
|
14
29
|
function buildCliTheme(theme: CrewTheme): Record<string, (text: string) => string> {
|
|
@@ -39,11 +54,13 @@ function hlCliSync(code: string, language: string | undefined, theme: CrewTheme)
|
|
|
39
54
|
.join("\n");
|
|
40
55
|
}
|
|
41
56
|
try {
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
return cliHighlight()
|
|
58
|
+
.highlight(code, {
|
|
59
|
+
language,
|
|
60
|
+
ignoreIllegals: true,
|
|
61
|
+
theme: buildCliTheme(theme),
|
|
62
|
+
})
|
|
63
|
+
.trimEnd();
|
|
47
64
|
} catch {
|
|
48
65
|
return code
|
|
49
66
|
.split("\n")
|
|
@@ -105,18 +122,20 @@ function detectLanguageFromPath(filePath: string): string | undefined {
|
|
|
105
122
|
|
|
106
123
|
export function highlightCode(code: string, language: string | undefined, themeLike: unknown = undefined): string {
|
|
107
124
|
const theme = asCrewTheme(themeLike);
|
|
108
|
-
const validLanguage = language && supportsLanguage(language) ? language : undefined;
|
|
125
|
+
const validLanguage = language && cliHighlight().supportsLanguage(language) ? language : undefined;
|
|
109
126
|
return hlCliSync(code, validLanguage, theme);
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
export function highlightJson(payload: string, themeLike: unknown = undefined): string {
|
|
113
130
|
const theme = asCrewTheme(themeLike);
|
|
114
131
|
try {
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
return cliHighlight()
|
|
133
|
+
.highlight(payload, {
|
|
134
|
+
language: "json",
|
|
135
|
+
ignoreIllegals: true,
|
|
136
|
+
theme: buildCliTheme(theme),
|
|
137
|
+
})
|
|
138
|
+
.trimEnd();
|
|
120
139
|
} catch {
|
|
121
140
|
try {
|
|
122
141
|
const parsed = JSON.parse(payload);
|
package/src/ui/widget/index.ts
CHANGED
|
@@ -270,7 +270,13 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
render(width: number): string[] {
|
|
273
|
-
const runs = activeWidgetRuns(
|
|
273
|
+
const runs = activeWidgetRuns(
|
|
274
|
+
this.model.cwd,
|
|
275
|
+
this.model.manifestCache,
|
|
276
|
+
this.model.snapshotCache,
|
|
277
|
+
this.model.preloadManifests,
|
|
278
|
+
this.model.workspaceId,
|
|
279
|
+
);
|
|
274
280
|
// C4: invalidate-on-write signature cache. buildSignature() is
|
|
275
281
|
// O(runs×agents) string work called on every ~160ms host-driven render
|
|
276
282
|
// tick. Within one state snapshot (no event arrived → cache not cleared
|
|
@@ -391,6 +397,7 @@ export function updateCrewWidget(
|
|
|
391
397
|
manifestCache,
|
|
392
398
|
snapshotCache,
|
|
393
399
|
preloadManifests: preloadedManifests,
|
|
400
|
+
workspaceId,
|
|
394
401
|
};
|
|
395
402
|
else {
|
|
396
403
|
state.model.cwd = ctx.cwd;
|
|
@@ -400,6 +407,7 @@ export function updateCrewWidget(
|
|
|
400
407
|
state.model.manifestCache = manifestCache;
|
|
401
408
|
state.model.snapshotCache = snapshotCache;
|
|
402
409
|
state.model.preloadManifests = preloadedManifests;
|
|
410
|
+
state.model.workspaceId = workspaceId;
|
|
403
411
|
}
|
|
404
412
|
|
|
405
413
|
if (needsWidgetInstall) {
|
|
@@ -42,7 +42,7 @@ export function activeWidgetRuns(
|
|
|
42
42
|
if (now - lastStaleReconcileAt > STALE_RECONCILE_INTERVAL_MS && manifestCache) {
|
|
43
43
|
lastStaleReconcileAt = now;
|
|
44
44
|
try {
|
|
45
|
-
reconcileAllStaleRuns(cwd, manifestCache);
|
|
45
|
+
reconcileAllStaleRuns(cwd, manifestCache, Date.now(), workspaceId);
|
|
46
46
|
} catch {
|
|
47
47
|
/* non-critical */
|
|
48
48
|
}
|
|
@@ -21,6 +21,9 @@ export interface CrewWidgetModel {
|
|
|
21
21
|
manifestCache?: ManifestCache;
|
|
22
22
|
snapshotCache?: RunSnapshotCache;
|
|
23
23
|
preloadManifests?: TeamRunManifest[];
|
|
24
|
+
/** P3 (#9): workspace/session ID for filtering runs by ownerSessionId
|
|
25
|
+
* on every render, not just the first frame. */
|
|
26
|
+
workspaceId?: string;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface CrewWidgetState {
|
package/src/utils/env-filter.ts
CHANGED
|
@@ -131,20 +131,34 @@ export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnv
|
|
|
131
131
|
throw new Error(`Allowlist entry "${pattern}" looks like a secret key. Use a more specific pattern.`);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
|
|
134
|
+
// Separate exact entries (explicitly trusted) from glob patterns.
|
|
135
|
+
// Exact entries (e.g. provider API keys) are deliberately listed by the
|
|
136
|
+
// caller and validated above; they must pass through unchanged.
|
|
137
|
+
const exact = new Set<string>();
|
|
138
|
+
const globPrefixes: string[] = [];
|
|
139
|
+
for (const p of options.allowList) {
|
|
135
140
|
if (p.endsWith("*")) {
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return (k: string) => k.startsWith(prefix) && k.length > prefix.length;
|
|
141
|
+
// Trailing glob: match keys that start with the prefix AND have at
|
|
142
|
+
// least one additional character (distinguishes "PI_CREW_*" from "PI_CREW_").
|
|
143
|
+
globPrefixes.push(p.slice(0, -1));
|
|
144
|
+
} else {
|
|
145
|
+
exact.add(p);
|
|
142
146
|
}
|
|
143
|
-
|
|
144
|
-
return (k: string) => k === p;
|
|
145
|
-
});
|
|
147
|
+
}
|
|
146
148
|
for (const [key, value] of Object.entries(env)) {
|
|
147
|
-
if (value
|
|
149
|
+
if (value === undefined) continue;
|
|
150
|
+
// Exact allow-list entries are explicitly trusted (e.g. ANTHROPIC_API_KEY
|
|
151
|
+
// scoped to a model). No secondary secret check — the caller vouches for them.
|
|
152
|
+
if (exact.has(key)) {
|
|
153
|
+
filtered[key] = value;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// Glob-matched keys (e.g. PI_CREW_*): NEW-S1 secondary guard — a broad glob
|
|
157
|
+
// must not forward secret-named keys (PI_CREW_OPENAI_API_KEY,
|
|
158
|
+
// PI_CREW_SECRET_TOKEN, ...) to spawned hook scripts with process access.
|
|
159
|
+
if (globPrefixes.some((prefix) => key.startsWith(prefix) && key.length > prefix.length) && !isSecretKey(key)) {
|
|
160
|
+
filtered[key] = value;
|
|
161
|
+
}
|
|
148
162
|
}
|
|
149
163
|
return filtered;
|
|
150
164
|
}
|