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.
Files changed (66) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/index.mjs +22609 -21373
  3. package/package.json +1 -1
  4. package/skills/distill-software/SKILL.md +7 -1
  5. package/src/agents/discover-agents.ts +6 -0
  6. package/src/config/config.ts +13 -2
  7. package/src/config/markers.ts +9 -1
  8. package/src/extension/action-suggestions.ts +1 -1
  9. package/src/extension/async-notifier.ts +13 -5
  10. package/src/extension/registration/context-builder.ts +5 -2
  11. package/src/extension/registration/crash-recovery-cache.ts +2 -2
  12. package/src/extension/registration/lazy-configurers.ts +1 -1
  13. package/src/extension/registration/lifecycle-handlers.ts +25 -10
  14. package/src/extension/registration/observability.ts +8 -4
  15. package/src/extension/registration/registration-types.ts +2 -2
  16. package/src/extension/registration/runtime-cleanup.ts +10 -2
  17. package/src/extension/registration/subagent-manager-setup.ts +9 -4
  18. package/src/extension/registration/subagent-tools.ts +15 -3
  19. package/src/extension/run-import.ts +19 -2
  20. package/src/extension/run-maintenance.ts +25 -9
  21. package/src/extension/session-summary.ts +5 -0
  22. package/src/extension/team-tool/destructive-gate.ts +12 -6
  23. package/src/extension/team-tool/health-monitor.ts +7 -5
  24. package/src/extension/team-tool/intent-policy.ts +9 -0
  25. package/src/extension/team-tool/lifecycle-actions.ts +27 -4
  26. package/src/extension/team-tool/run-deadline.ts +7 -2
  27. package/src/extension/team-tool/run.ts +23 -4
  28. package/src/extension/team-tool/status.ts +2 -2
  29. package/src/extension/team-tool.ts +126 -51
  30. package/src/runtime/background-runner.ts +36 -2
  31. package/src/runtime/delivery-coordinator.ts +24 -3
  32. package/src/runtime/foreground-watchdog.ts +2 -2
  33. package/src/runtime/model/model-fallback.ts +3 -1
  34. package/src/runtime/model/provider-extensions.ts +36 -0
  35. package/src/runtime/model/runtime-warmup.ts +41 -0
  36. package/src/runtime/peer-dep.ts +35 -8
  37. package/src/runtime/recovery/crash-recovery.ts +34 -4
  38. package/src/runtime/skill-instructions.ts +1 -1
  39. package/src/runtime/stale-reconciler.ts +11 -19
  40. package/src/runtime/subagent-manager.ts +15 -6
  41. package/src/runtime/task-packet.ts +26 -4
  42. package/src/runtime/task-runner/run-projection.ts +4 -1
  43. package/src/runtime/team-runner.ts +94 -72
  44. package/src/runtime/verification/completion-guard.ts +10 -1
  45. package/src/schema/team-tool-schema.ts +152 -38
  46. package/src/skills/validate.ts +28 -2
  47. package/src/state/atomic-write.ts +23 -12
  48. package/src/state/contracts.ts +1 -0
  49. package/src/state/coordination/locks.ts +20 -7
  50. package/src/state/coordination/mailbox.ts +20 -2
  51. package/src/state/gitignore-manager.ts +5 -1
  52. package/src/state/stores/artifact-store.ts +5 -5
  53. package/src/state/stores/run-cache.ts +9 -1
  54. package/src/state/stores/state-store.ts +64 -10
  55. package/src/ui/deploy-bundled-themes.ts +11 -0
  56. package/src/ui/powerbar-publisher.ts +31 -6
  57. package/src/ui/run-dashboard.ts +7 -1
  58. package/src/ui/syntax-highlight.ts +31 -12
  59. package/src/ui/widget/index.ts +9 -1
  60. package/src/ui/widget/widget-model.ts +1 -1
  61. package/src/ui/widget/widget-types.ts +3 -0
  62. package/src/utils/env-filter.ts +25 -11
  63. package/src/utils/paths.ts +20 -1
  64. package/src/utils/redaction.ts +22 -1
  65. package/src/utils/session-utils.ts +42 -19
  66. 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
- * unconditionally removes the file because we know we created it in the same
244
- * withRunLock* call — token matching is only needed for multi-process scenarios
245
- * (where a different holder's token must not be deleted). Within the same process
246
- * the new acquire uses a fresh randomUUID token, so token matching would falsely
247
- * fail and leak the file across releases.
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.rmSync(filePath, { force: true });
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
- if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "", "utf-8");
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
- fs.writeFileSync(gitignorePath, current + appended, "utf-8");
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
- // Compute hash on written bytes for integrity verification.
220
- // Read back the actual file content to handle atomicWrite fallback path
221
- // where the written content might differ from the input (e.g., concurrent writes).
222
- const writtenContent = fs.readFileSync(filePath, "utf-8");
223
- const contentHash = hashContent(writtenContent);
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
- const index: CacheIndex = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf-8")) : {};
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, undefined, skipCoalesce);
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(a.events, a.cwd, a.config, a.manifestCache, a.snapshotCache, a.ctx, a.notificationCount, a.preloadedManifests);
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
- const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
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 liveRunning = listLiveAgents().filter((a) => a.status === "running").length;
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. */
@@ -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
- this.runs = Array.isArray(next) ? next : this.runs;
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 { highlight, supportsLanguage } from "cli-highlight";
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 highlight(code, {
43
- language,
44
- ignoreIllegals: true,
45
- theme: buildCliTheme(theme),
46
- }).trimEnd();
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 highlight(payload, {
116
- language: "json",
117
- ignoreIllegals: true,
118
- theme: buildCliTheme(theme),
119
- }).trimEnd();
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);
@@ -270,7 +270,13 @@ class CrewWidgetComponent implements WidgetComponent {
270
270
  }
271
271
 
272
272
  render(width: number): string[] {
273
- const runs = activeWidgetRuns(this.model.cwd, this.model.manifestCache, this.model.snapshotCache, this.model.preloadManifests);
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 {
@@ -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
- const matchers = options.allowList.map((p) => {
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
- // Glob pattern: matches keys that start with the prefix AND have
137
- // at least one additional character (distinguishes "PI_CREW_*" from "PI_CREW_").
138
- // For example, "PI_CREW_*" matches "PI_CREW_DEPTH" but not "PI_CREW_".
139
- // This ensures trailing glob patterns require extra chars, not exact-prefix-only matches.
140
- const prefix = p.slice(0, -1);
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
- // Exact match is case-sensitive; Unix env vars are uppercase by convention.
144
- return (k: string) => k === p;
145
- });
147
+ }
146
148
  for (const [key, value] of Object.entries(env)) {
147
- if (value !== undefined && matchers.some((fn) => fn(key))) filtered[key] = 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
  }