pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. package/src/worktree/worktree-manager.ts +65 -4
@@ -6,13 +6,18 @@ import { loadConfig } from "../../config/config.ts";
6
6
  import { DEFAULT_PATHS } from "../../config/defaults.ts";
7
7
  import { type DriftReport, detectDrift, formatDriftReport } from "../../config/drift-detector.ts";
8
8
  import { buildConfiguredModelRouting, resolveModelFallbackPolicy } from "../../runtime/model/model-fallback.ts";
9
+ import { getPiTempBase } from "../../runtime/model/pi-args.ts";
9
10
  import { getRuntimeWarmupStatus } from "../../runtime/model/runtime-warmup.ts";
10
11
  import { currentSessionModel, sessionModelSnapshot } from "../../runtime/model/session-model.ts";
11
12
  import { getPiSpawnCommand } from "../../runtime/pi-spawn.ts";
12
- import { formatZombieReport, scanZombieSubagents } from "../../runtime/process/zombie-scanner.ts";
13
+ import { formatZombieReport, scanZombieSubagents, type ZombieScanResult } from "../../runtime/process/zombie-scanner.ts";
14
+ import { launchScriptRegistry, sweepLaunchScripts, sweepOrphanLaunchScriptFiles } from "../../runtime/surface/launch-script.ts";
15
+ import { surfaceProviderForCleanup } from "../../runtime/surface/resolve-surface.ts";
16
+ import type { SurfaceProvider } from "../../runtime/surface/surface-provider.ts";
13
17
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
14
18
  import { TeamToolParams } from "../../schema/team-tool-schema.ts";
15
- import { atomicWriteFile } from "../../state/atomic-write.ts";
19
+ import { atomicWriteFile, atomicWriteJson } from "../../state/atomic-write.ts";
20
+ import { TEAM_TERMINAL_RUN_STATUSES } from "../../state/contracts.ts";
16
21
  import { allTeams, discoverTeams } from "../../teams/discover-teams.ts";
17
22
  import { type FatalFsCause, fsFailureLabel } from "../../utils/fs-errno.ts";
18
23
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
@@ -514,13 +519,356 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
514
519
  };
515
520
  }
516
521
 
517
- export function handleDoctor(ctx: TeamContext, params: TeamToolParamsValue = {}): PiTeamsToolResult {
518
- // Sub-focus: zombie sub-agent scan. READ-ONLY — never kills. Returns a table of
519
- // orphaned pi-crew sub-agents identified by the authoritative PI_CREW_KIND=subagent
520
- // marker. The user's main session never carries that marker, so it can never appear.
522
+ // ── T12: orphan surface-pane cleanup (doctor focus=zombies) ─────────────────
523
+ //
524
+ // Three orphan sources:
525
+ // 1. zombie scan — a sub-agent whose crew parent died while carrying
526
+ // PI_CREW_SURFACE/PI_CREW_SURFACE_PANE: the pane outlived its host.
527
+ // 2. terminal-run manifests — a finished run whose manifest.surface.panes
528
+ // still has entries (host died before releaseSurfacePane). Those panes
529
+ // hold the live-pane cap hostage for the rest of the run (T11 residual);
530
+ // doctor is the sweep that finally releases them.
531
+ // 3. terminal-run manifests' surface.tabs (tab-layout Task 6) — a finished
532
+ // run whose tabs entry still carries tab ids: the host died before
533
+ // closeTabForRun ran in its finally block. Doctor closes each tab BY ID
534
+ // (its own process never owned the provider's tabKey map) and clears the
535
+ // manifest entry only once the mux confirmed every tab id resolved.
536
+ //
537
+ // Closing is gated on provider.detect() — if the mux is unavailable the panes
538
+ // are listed without any close attempt (fail-open list-only, never close blind).
539
+
540
+ /** How many most-recent run manifests to scan for orphan panes. */
541
+ const ORPHAN_RUN_SCAN_LIMIT = 10;
542
+
543
+ export interface OrphanSurfacePane {
544
+ paneId: string;
545
+ kind: "tmux" | "herdr";
546
+ /** Provenance line for the human, e.g. `zombie-scan pid 4242`. */
547
+ source: string;
548
+ }
549
+
550
+ /**
551
+ * Tab-layout Task 6: một entry `surface.tabs[tabKey]` còn tabIds trên manifest
552
+ * của run TERMINAL — ứng viên orphan, phải liveness-check từng tabId qua mux
553
+ * (closeTabById) trước khi đóng (doctor chạy ở process khác host đã spawn nên
554
+ * map nội bộ tabKey của provider trống ở đây — KHÔNG dùng closeTab(tabKey)).
555
+ */
556
+ export interface OrphanSurfaceTab {
557
+ runId: string;
558
+ tabKey: string;
559
+ /** Mọi tab/window id của entry (run dài vượt MAX_PANES_PER_TAB có nhiều). */
560
+ tabIds: string[];
561
+ kind: "tmux" | "herdr";
562
+ manifestPath: string;
563
+ }
564
+
565
+ export interface DoctorSurfaceCleanupDeps {
566
+ /** Provider per kind — injectable so tests never touch a real mux. */
567
+ providers?: Partial<Record<"tmux" | "herdr", SurfaceProvider>>;
568
+ /** Clock (ms epoch) for the script TTL sweep — default Date.now. */
569
+ now?: () => number;
570
+ /** Launch-script temp base (default getPiTempBase()). */
571
+ tempBase?: string;
572
+ /** Max recent run manifests scanned — 0 skips the manifest source. */
573
+ runScanLimit?: number;
574
+ }
575
+
576
+ export interface DoctorSurfaceCleanupResult {
577
+ orphans: OrphanSurfacePane[];
578
+ /** Pane ids successfully closed via provider.closeSurface. */
579
+ closed: string[];
580
+ /** Pane ids the mux no longer knows — nothing to close, not a failure. */
581
+ gone: string[];
582
+ failures: { paneId: string; error: string }[];
583
+ /** Tabs của terminal runs còn tabIds trên manifest (tab-layout Task 6). */
584
+ orphanTabs: OrphanSurfaceTab[];
585
+ /** Tab ids closed directly by id via provider.closeTabById. */
586
+ tabsClosed: string[];
587
+ /** Tab ids the mux no longer knows — liveness confirmed dead, not a failure. */
588
+ tabsGone: string[];
589
+ tabFailures: { tabId: string; error: string }[];
590
+ /** Launch scripts removed from disk + registry (orphan script sweep, T5). */
591
+ scriptsSwept: number;
592
+ /** Why a provider kind was skipped (listed-only). */
593
+ providerNotes: string[];
594
+ }
595
+
596
+ /** TERMINAL-run manifest record đọc từ đĩa — nguồn chung cho pane + tab orphans. */
597
+ interface TerminalRunManifestRecord {
598
+ runId: string;
599
+ manifestPath: string;
600
+ manifest: Record<string, unknown>;
601
+ kind: "tmux" | "herdr";
602
+ }
603
+
604
+ /** Panes recorded on TERMINAL runs' manifests — the T11 residual leak. */
605
+ function collectTerminalRunOrphanPanes(records: TerminalRunManifestRecord[]): OrphanSurfacePane[] {
606
+ const orphans: OrphanSurfacePane[] = [];
607
+ for (const record of records) {
608
+ const surface = record.manifest.surface as { panes?: unknown } | undefined;
609
+ if (!surface?.panes || typeof surface.panes !== "object") continue;
610
+ for (const [taskId, paneId] of Object.entries(surface.panes as Record<string, unknown>)) {
611
+ if (typeof paneId !== "string" || paneId === "") continue;
612
+ orphans.push({ paneId, kind: record.kind, source: `run ${record.runId} task ${taskId} (terminal)` });
613
+ }
614
+ }
615
+ return orphans;
616
+ }
617
+
618
+ /**
619
+ * Tabs recorded on TERMINAL runs' manifests (tab-layout Task 5/6). Manifest
620
+ * TRÊN ĐĨA GIỮ tabIds sau run end làm evidence — entry non-empty trên run
621
+ * terminal nghĩa là host chết trước khi closeTabForRun chạy ở finally. Đây
622
+ * chỉ là DANH SÁCH ứng viên; doctor liveness-check từng tabId qua mux
623
+ * (closeTabById) rồi mới close-by-ID idempotent — không bao giờ close mù.
624
+ */
625
+ function collectTerminalRunOrphanTabs(records: TerminalRunManifestRecord[]): OrphanSurfaceTab[] {
626
+ const tabs: OrphanSurfaceTab[] = [];
627
+ for (const record of records) {
628
+ const surface = record.manifest.surface as { tabs?: unknown } | undefined;
629
+ if (!surface?.tabs || typeof surface.tabs !== "object") continue;
630
+ for (const [tabKey, tabIds] of Object.entries(surface.tabs as Record<string, unknown>)) {
631
+ if (!Array.isArray(tabIds)) continue;
632
+ const ids = tabIds.filter((id): id is string => typeof id === "string" && id !== "");
633
+ if (ids.length === 0) continue; // host đã closeTabForRun — không phải orphan
634
+ tabs.push({ runId: record.runId, tabKey, tabIds: ids, kind: record.kind, manifestPath: record.manifestPath });
635
+ }
636
+ }
637
+ return tabs;
638
+ }
639
+
640
+ /** TERMINAL-run manifests gần nhất (mới nhất trước) — đọc MỘT lần cho cả pane + tab orphans. */
641
+ function readRecentRunManifests(cwd: string, limit: number): TerminalRunManifestRecord[] {
642
+ const runsRoot = path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir);
643
+ let recentRunIds: string[];
644
+ try {
645
+ recentRunIds = fs
646
+ .readdirSync(runsRoot, { withFileTypes: true })
647
+ .filter((entry) => entry.isDirectory())
648
+ .map((entry) => {
649
+ let mtimeMs = 0;
650
+ try {
651
+ mtimeMs = fs.statSync(path.join(runsRoot, entry.name)).mtimeMs;
652
+ } catch {
653
+ /* unreadable run dir — mtime 0 sorts last */
654
+ }
655
+ return { runId: entry.name, mtimeMs };
656
+ })
657
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
658
+ .slice(0, limit)
659
+ .map((entry) => entry.runId);
660
+ } catch {
661
+ return [];
662
+ }
663
+ const records: TerminalRunManifestRecord[] = [];
664
+ for (const runId of recentRunIds) {
665
+ const manifestPath = path.join(runsRoot, runId, "manifest.json");
666
+ let manifest: Record<string, unknown>;
667
+ try {
668
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
669
+ } catch {
670
+ continue; // unreadable/absent manifest — nothing this run can tell us
671
+ }
672
+ if (typeof manifest.status !== "string" || !TEAM_TERMINAL_RUN_STATUSES.has(manifest.status as never)) continue;
673
+ const kind = (manifest.surface as { provider?: unknown } | undefined)?.provider;
674
+ if (kind !== "tmux" && kind !== "herdr") continue;
675
+ records.push({ runId, manifestPath, manifest, kind });
676
+ }
677
+ return records;
678
+ }
679
+
680
+ /**
681
+ * Collect + close orphan surface panes and sweep orphan launch scripts.
682
+ * Best-effort throughout: an error on one pane never aborts the rest, and a
683
+ * provider that fails detect() downgrades that kind to list-only.
684
+ */
685
+ export async function cleanupOrphanSurfacePanes(input: {
686
+ cwd: string;
687
+ scan: ZombieScanResult;
688
+ deps?: DoctorSurfaceCleanupDeps;
689
+ }): Promise<DoctorSurfaceCleanupResult> {
690
+ const deps = input.deps ?? {};
691
+ const now = deps.now ?? Date.now;
692
+
693
+ // Orphan launch-script sweep (optional T5): registry covers this process's
694
+ // scripts, disk glob covers scripts left by a dead host (they carry broker
695
+ // tokens, so a doctor run is the right moment to purge them).
696
+ let scriptsSwept = 0;
697
+ try {
698
+ scriptsSwept += sweepLaunchScripts(launchScriptRegistry, now());
699
+ scriptsSwept += sweepOrphanLaunchScriptFiles(deps.tempBase ?? getPiTempBase(), now());
700
+ } catch {
701
+ // best-effort — sweeping must never break the pane report
702
+ }
703
+
704
+ const orphans: OrphanSurfacePane[] = [];
705
+ const seen = new Set<string>();
706
+ for (const zombie of input.scan.zombies) {
707
+ if (!zombie.surface || !zombie.surfacePaneId || seen.has(zombie.surfacePaneId)) continue;
708
+ seen.add(zombie.surfacePaneId);
709
+ orphans.push({ paneId: zombie.surfacePaneId, kind: zombie.surface, source: `zombie-scan pid ${zombie.pid}` });
710
+ }
711
+ const runScanLimit = deps.runScanLimit ?? ORPHAN_RUN_SCAN_LIMIT;
712
+ // Tab-layout Task 6: terminal runs còn surface.tabs entry non-empty. Đây
713
+ // chỉ là ứng viên — KHÔNG đóng mù theo "tabs non-empty" (manifest giữ
714
+ // tabIds sau run end by-design); liveness + close-by-ID từng tabId qua mux.
715
+ const terminalRunManifests = runScanLimit > 0 ? readRecentRunManifests(input.cwd, runScanLimit) : [];
716
+ if (terminalRunManifests.length > 0) {
717
+ for (const orphan of collectTerminalRunOrphanPanes(terminalRunManifests)) {
718
+ if (seen.has(orphan.paneId)) continue;
719
+ seen.add(orphan.paneId);
720
+ orphans.push(orphan);
721
+ }
722
+ }
723
+ const orphanTabs = collectTerminalRunOrphanTabs(terminalRunManifests);
724
+
725
+ const providerNotes: string[] = [];
726
+ const providers = new Map<"tmux" | "herdr", SurfaceProvider>();
727
+ for (const kind of [...new Set([...orphans.map((orphan) => orphan.kind), ...orphanTabs.map((tab) => tab.kind)])]) {
728
+ const provider = deps.providers?.[kind] ?? surfaceProviderForCleanup(kind);
729
+ if (!provider) {
730
+ providerNotes.push(`${kind}: provider unavailable — panes listed only`);
731
+ continue;
732
+ }
733
+ try {
734
+ const detection = provider.detect();
735
+ if (!detection.ok) {
736
+ providerNotes.push(`${kind}: ${detection.reason ?? "not detected"} — panes listed only`);
737
+ continue;
738
+ }
739
+ } catch (error) {
740
+ providerNotes.push(`${kind}: detect threw (${error instanceof Error ? error.message : String(error)}) — panes listed only`);
741
+ continue;
742
+ }
743
+ providers.set(kind, provider);
744
+ }
745
+
746
+ const closed: string[] = [];
747
+ const gone: string[] = [];
748
+ const failures: { paneId: string; error: string }[] = [];
749
+ for (const orphan of orphans) {
750
+ const provider = providers.get(orphan.kind);
751
+ if (!provider) continue; // list-only — note already recorded per kind
752
+ try {
753
+ const handle = provider.attach(orphan.paneId);
754
+ if (!handle) {
755
+ gone.push(orphan.paneId);
756
+ continue;
757
+ }
758
+ // Attach của herdr là optimistic (interface sync không round-trip
759
+ // socket được) — xác minh pane còn sống trước khi đóng. tmux attach
760
+ // đã xác minh sync nhưng readScreen thêm một lần vẫn vô hại.
761
+ try {
762
+ await provider.readScreen(handle, 1);
763
+ } catch {
764
+ gone.push(orphan.paneId);
765
+ continue;
766
+ }
767
+ await provider.closeSurface(handle, { force: true });
768
+ closed.push(orphan.paneId);
769
+ } catch (error) {
770
+ failures.push({ paneId: orphan.paneId, error: error instanceof Error ? error.message : String(error) });
771
+ }
772
+ }
773
+
774
+ // Tab-layout Task 6: đóng tab orphan theo tabId TRỰC TIẾP. Doctor chạy ở
775
+ // process khác host đã spawn nên map nội bộ tabKey của provider.closeTab
776
+ // TRỐNG ở đây — bắt buộc đường closeTabById theo id trên manifest. Entry
777
+ // manifest chỉ được clear (giữ key rỗng, cùng shape closeTabForRun) khi
778
+ // MỌI tabId đã được mux xác nhận (closed/gone); còn failure thì giữ
779
+ // nguyên để lần doctor sau thử lại (close-by-ID idempotent nên an toàn).
780
+ const tabsClosed: string[] = [];
781
+ const tabsGone: string[] = [];
782
+ const tabFailures: { tabId: string; error: string }[] = [];
783
+ const tabCloseUnsupported = new Set<string>();
784
+ for (const orphan of orphanTabs) {
785
+ const provider = providers.get(orphan.kind);
786
+ if (!provider) continue; // list-only — note already recorded per kind
787
+ if (typeof provider.closeTabById !== "function") {
788
+ if (!tabCloseUnsupported.has(orphan.kind)) {
789
+ tabCloseUnsupported.add(orphan.kind);
790
+ providerNotes.push(`${orphan.kind}: closeTabById unavailable — run tabs listed only`);
791
+ }
792
+ continue;
793
+ }
794
+ let allResolved = true;
795
+ for (const tabId of orphan.tabIds) {
796
+ try {
797
+ const outcome = await provider.closeTabById(tabId);
798
+ if (outcome === "gone") tabsGone.push(tabId);
799
+ else tabsClosed.push(tabId);
800
+ } catch (error) {
801
+ allResolved = false;
802
+ tabFailures.push({ tabId, error: error instanceof Error ? error.message : String(error) });
803
+ }
804
+ }
805
+ if (!allResolved) continue; // giữ nguyên entry manifest — evidence cho lần thử sau
806
+ try {
807
+ const manifest = JSON.parse(fs.readFileSync(orphan.manifestPath, "utf-8")) as {
808
+ surface?: { tabs?: Record<string, unknown> };
809
+ };
810
+ if (Array.isArray(manifest.surface?.tabs?.[orphan.tabKey])) {
811
+ manifest.surface.tabs[orphan.tabKey] = [];
812
+ atomicWriteJson(orphan.manifestPath, manifest);
813
+ }
814
+ } catch (error) {
815
+ tabFailures.push({
816
+ tabId: orphan.tabIds[0] ?? orphan.tabKey,
817
+ error: `manifest persist failed: ${error instanceof Error ? error.message : String(error)}`,
818
+ });
819
+ }
820
+ }
821
+
822
+ return { orphans, closed, gone, failures, orphanTabs, tabsClosed, tabsGone, tabFailures, scriptsSwept, providerNotes };
823
+ }
824
+
825
+ export function formatOrphanPaneReport(cleanup: DoctorSurfaceCleanupResult): string {
826
+ const lines: string[] = [];
827
+ lines.push("## Orphan surface-pane cleanup");
828
+ lines.push("");
829
+ if (cleanup.orphans.length === 0) {
830
+ lines.push("No orphan surface panes found (zombie scan + terminal-run manifests).");
831
+ } else {
832
+ lines.push(`Orphan panes (${cleanup.orphans.length}):`);
833
+ for (const orphan of cleanup.orphans) {
834
+ lines.push(` - ${orphan.kind} pane ${orphan.paneId} — ${orphan.source}`);
835
+ }
836
+ lines.push("");
837
+ }
838
+ if (cleanup.closed.length > 0) lines.push(`Closed: ${cleanup.closed.join(", ")}`);
839
+ if (cleanup.gone.length > 0) lines.push(`Already gone (mux no longer tracks them): ${cleanup.gone.join(", ")}`);
840
+ if (cleanup.failures.length > 0) {
841
+ lines.push(`Close failures (${cleanup.failures.length}):`);
842
+ for (const failure of cleanup.failures) lines.push(` - ${failure.paneId}: ${failure.error}`);
843
+ }
844
+ if (cleanup.orphanTabs.length > 0) {
845
+ lines.push("");
846
+ lines.push(`Orphan run tabs (${cleanup.orphanTabs.length}) — terminal runs whose surface.tabs still carry tab ids:`);
847
+ for (const tab of cleanup.orphanTabs) {
848
+ lines.push(` - ${tab.kind} tab ${tab.tabIds.join(", ")} — run ${tab.runId} tabKey ${tab.tabKey} (terminal)`);
849
+ }
850
+ }
851
+ if (cleanup.tabsClosed.length > 0) lines.push(`Tabs closed by id: ${cleanup.tabsClosed.join(", ")}`);
852
+ if (cleanup.tabsGone.length > 0) lines.push(`Tabs already gone (mux no longer tracks them): ${cleanup.tabsGone.join(", ")}`);
853
+ if (cleanup.tabFailures.length > 0) {
854
+ lines.push(`Tab close failures (${cleanup.tabFailures.length}):`);
855
+ for (const failure of cleanup.tabFailures) lines.push(` - ${failure.tabId}: ${failure.error}`);
856
+ }
857
+ for (const note of cleanup.providerNotes) lines.push(`Note: ${note}`);
858
+ lines.push(`Orphan launch scripts swept: ${cleanup.scriptsSwept}`);
859
+ return lines.join("\n");
860
+ }
861
+
862
+ export async function handleDoctor(ctx: TeamContext, params: TeamToolParamsValue = {}): Promise<PiTeamsToolResult> {
863
+ // Sub-focus: zombie sub-agent scan + orphan surface-pane cleanup (T12). The
864
+ // process scan itself stays READ-ONLY — never kills a process. The pane
865
+ // cleanup only closes multiplexer panes (zombie workers' panes + terminal
866
+ // runs' leaked panes) through the provider, gated on detect(). The user's
867
+ // main session never carries PI_CREW_KIND, so it can never appear here.
521
868
  if (params.focus === "zombies") {
522
869
  const scan = scanZombieSubagents();
523
- const text = formatZombieReport(scan);
870
+ const cleanup = await cleanupOrphanSurfacePanes({ cwd: ctx.cwd, scan });
871
+ const text = `${formatZombieReport(scan)}\n\n${formatOrphanPaneReport(cleanup)}`;
524
872
  return result(
525
873
  text,
526
874
  {
@@ -530,6 +878,15 @@ export function handleDoctor(ctx: TeamContext, params: TeamToolParamsValue = {})
530
878
  zombies: scan.zombies.length,
531
879
  live: scan.live.length,
532
880
  errors: scan.errors.length,
881
+ orphanPanes: cleanup.orphans.length,
882
+ panesClosed: cleanup.closed.length,
883
+ panesGone: cleanup.gone.length,
884
+ paneCloseFailures: cleanup.failures.length,
885
+ orphanTabs: cleanup.orphanTabs.length,
886
+ tabsClosed: cleanup.tabsClosed.length,
887
+ tabsGone: cleanup.tabsGone.length,
888
+ tabCloseFailures: cleanup.tabFailures.length,
889
+ scriptsSwept: cleanup.scriptsSwept,
533
890
  },
534
891
  },
535
892
  false,
@@ -18,6 +18,12 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
18
18
  "runtime.promptMode": "replace",
19
19
  "runtime.completionMutationGuard": "warn",
20
20
  "runtime.isolationPolicy": undefined,
21
+ // Mux-surface policy (spec v0.7 §8.2.2): auto-detect tmux/herdr; A1 shows
22
+ // panes to nobody until visibleAgents opts roles in (["*"] at A2 GA).
23
+ "runtime.surface.mode": "auto",
24
+ "runtime.surface.visibleAgents": [],
25
+ // Inter-pi broker (Phase 0): default-on after ADR-0 "asked then flipped true".
26
+ "broker.enabled": true,
21
27
  "limits.maxConcurrentWorkers": 1024,
22
28
  "limits.maxTaskDepth": 100,
23
29
  "limits.maxRunMinutes": 1440,
@@ -152,6 +158,16 @@ const KNOWN_KEYS = new Set([
152
158
  "runtime.completionMutationGuard",
153
159
  "runtime.effectivenessGuard",
154
160
  "runtime.isolationPolicy",
161
+ // Mux-surface policy (spec v0.7 §8.2.1)
162
+ "runtime.surface.mode",
163
+ "runtime.surface.visibleAgents",
164
+ // governed nesting (ADR-5 §10; default-on since D8 — user config may close)
165
+ "nesting.enabled",
166
+ "nesting.maxSlots",
167
+ "nesting.maxDepth",
168
+ // inter-pi broker (Phase 0) — wait.* gate + master switch
169
+ "broker.enabled",
170
+ "broker.waitMethodsEnabled",
155
171
  // limits
156
172
  "limits.maxConcurrentWorkers",
157
173
  "limits.allowUnboundedConcurrency",
@@ -487,6 +503,9 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
487
503
  "autonomous.injectPolicy",
488
504
  "agents.overrides",
489
505
  "agents.disableBuiltins",
506
+ // Privilege-raising (spawns grandchildren) — schema marks it
507
+ // sensitive; project config is sanitized (ADR-5 §12 posture).
508
+ "nesting.enabled",
490
509
  ];
491
510
  if (sensitiveKeys.some((k) => key === k || key.startsWith(k + "."))) {
492
511
  warning +=
@@ -1,5 +1,5 @@
1
1
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
2
- import { readEvents } from "../../state/event-log/event-log.ts";
2
+ import { readEventsCursor } from "../../state/event-log/event-log.ts";
3
3
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
4
4
  import { aggregateUsage, formatCostReport, formatUsage } from "../../state/usage.ts";
5
5
  import { locateRunCwd } from "../team-tool.ts";
@@ -20,9 +20,17 @@ export function handleEvents(params: TeamToolParamsValue, ctx: TeamContext): PiT
20
20
  if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "events", status: "error" }, true);
21
21
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
22
22
  if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "events", status: "error" }, true);
23
- const events = readEvents(loaded.manifest.eventsPath);
23
+ // PERF (2026-08-24): `readEvents` parses full history (every archive + the
24
+ // whole live file) and exposes no limit option, and the events action params
25
+ // carry no full-history flag — so read the bounded cursor tail (4 MB /
26
+ // 5000-event cap) and display the last 500 events by default.
27
+ const cursor = readEventsCursor(loaded.manifest.eventsPath);
28
+ const events = cursor.events.slice(-500);
24
29
  const lines = [
25
30
  `Events for ${loaded.manifest.runId}:`,
31
+ // Truncation indicator: `total` is the cursor's event count before the
32
+ // display slice — surface it whenever the slice dropped events.
33
+ ...(cursor.total > events.length ? [`(showing last ${events.length} of ${cursor.total} events)`] : []),
26
34
  ...(events.length
27
35
  ? events.map(
28
36
  (event) =>
@@ -149,6 +149,13 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
149
149
  const counts = new Map<string, number>();
150
150
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
151
151
  const phaseProgress = computePhaseProgress(tasks);
152
+ // PERF (2026-08-24): intentionally NOT passing `limit` here — readEventsCursor's
153
+ // limit is a HEAD cap (oldest-first slice for streaming pagination), not a tail
154
+ // window, so it would hide recent events from the ack-timeout dedupe below and
155
+ // re-append duplicate ack_timeout events on every poll. The manifest carries no
156
+ // last-seq anchor for a sinceSeq-based tail either. The reader is already bounded
157
+ // internally (4 MB / 5000-event tail), and the downstream filters
158
+ // (ackTimeoutRequestIds, attentionByTask) intentionally operate on that recent window.
152
159
  const { events: allEvents } = readEventsCursor(manifest.eventsPath);
153
160
  const events = allEvents.slice(-8);
154
161
  // P1-8: pre-build the ack-timeout requestId set once (was O(events × messages)
@@ -19,7 +19,7 @@ import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
19
19
  import { logInternalError } from "../utils/internal-error.ts";
20
20
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
21
21
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
22
- import { listRuns } from "./run-index.ts";
22
+ import { listRecentRuns } from "./run-index.ts";
23
23
  import type { PiTeamsToolResult } from "./tool-result.ts";
24
24
 
25
25
  type ExecuteTeamRunFn = typeof _executeTeamRunFn;
@@ -124,7 +124,10 @@ export function handleList(params: TeamToolParamsValue, ctx: TeamContext): PiTea
124
124
  );
125
125
  }
126
126
  if (!resource) {
127
- const runs = listRuns(ctx.cwd).slice(0, 10);
127
+ // PERF (2026-08-24): listRecentRuns caps at source — collectRuns slices the
128
+ // run-directory listing before reading manifests, instead of parsing every
129
+ // manifest in scope and discarding all but 10.
130
+ const runs = listRecentRuns(ctx.cwd, 10);
128
131
  blocks.push(
129
132
  "",
130
133
  "Recent runs:",
@@ -627,7 +630,37 @@ export function handleInvalidate(params: TeamToolParamsValue, ctx: TeamContext):
627
630
  const MAX_SCAN_ENTRIES = 1000;
628
631
  const SKIP_SCAN_DIRS = new Set(["node_modules", ".git", ".npm", ".cache", ".local", "proc", "sys", "dev", "Library", "Applications"]);
629
632
 
633
+ // PERF (2026-08-24): a stale/typo'd runId from a looping LLM caller paid the
634
+ // full 1000-entry directory sweep on EVERY attempt. Resolution results (hits
635
+ // AND misses) are cached briefly; TTL bounds staleness for runs created in a
636
+ // sibling cwd after a cached miss.
637
+ const runCwdCache = new Map<string, { cwd: string | undefined; expiresAt: number }>();
638
+ const RUN_CWD_TTL_MS = 30_000;
639
+ const RUN_CWD_CACHE_MAX = 128;
630
640
  export function locateRunCwd(runId: string, baseCwd: string): string | undefined {
641
+ const key = `${baseCwd}\0${runId}`;
642
+ const cached = runCwdCache.get(key);
643
+ if (cached && cached.expiresAt > Date.now()) return cached.cwd;
644
+ const cwd = locateRunCwdUncached(runId, baseCwd); // original body, renamed
645
+ if (runCwdCache.size >= RUN_CWD_CACHE_MAX) {
646
+ const oldest = runCwdCache.keys().next().value;
647
+ if (oldest !== undefined) runCwdCache.delete(oldest);
648
+ }
649
+ runCwdCache.set(key, { cwd, expiresAt: Date.now() + RUN_CWD_TTL_MS });
650
+ return cwd;
651
+ }
652
+
653
+ /**
654
+ * Locate the CWD where a run's state is stored.
655
+ * Tries ctx.cwd first, then scans immediate child directories for .crew/state/runs/<runId>.
656
+ *
657
+ * Defensive bounds (prevent hang on large dirs like /tmp in CI):
658
+ * - Skips entries that are well-known system/ephemeral dirs (e.g. .npm, node_modules, .git)
659
+ * - Caps the scan at MAX_SCAN_ENTRIES to avoid pathological scans
660
+ * - Skips hidden entries (starting with `.`) unless they look like run directories
661
+ * (e.g. .crew, .pi, .tmp-crew-runs)
662
+ */
663
+ export function locateRunCwdUncached(runId: string, baseCwd: string): string | undefined {
631
664
  // Fast path: run is in the current CWD
632
665
  if (loadRunManifestById(baseCwd, runId)) {
633
666
  return baseCwd;
@@ -42,6 +42,65 @@ export function getHooks(name: HookName): HookDefinition[] {
42
42
  return registry.get(name) ?? [];
43
43
  }
44
44
 
45
+ // PERF (2026-08-24): constant sanitizer state hoisted to module scope — it was
46
+ // rebuilt (12 normalize+toLowerCase + Set + 3 closures) on EVERY hook execution.
47
+ const POLLUTED_KEYS = new Set(
48
+ [
49
+ "__proto__",
50
+ "constructor",
51
+ "prototype",
52
+ "hasOwnProperty",
53
+ "toString",
54
+ "valueOf",
55
+ "isPrototypeOf",
56
+ "propertyIsEnumerable",
57
+ "__defineGetter__",
58
+ "__defineSetter__",
59
+ "__lookupGetter__",
60
+ "__lookupSetter__",
61
+ ].map((k) => k.toLowerCase().normalize("NFKC")),
62
+ );
63
+ function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
64
+ const clean: Record<string, unknown> = {};
65
+ for (const [k, v] of Object.entries(data)) {
66
+ if (!POLLUTED_KEYS.has(k.toLowerCase().normalize("NFKC"))) {
67
+ if (v !== null && typeof v === "object") {
68
+ if (Array.isArray(v)) {
69
+ // Sanitize array elements that are objects
70
+ clean[k] = v.map((item) =>
71
+ item !== null && typeof item === "object" && !Array.isArray(item)
72
+ ? sanitizeMergeData(item as Record<string, unknown>)
73
+ : item,
74
+ );
75
+ } else {
76
+ clean[k] = sanitizeMergeData(v as Record<string, unknown>);
77
+ }
78
+ } else {
79
+ clean[k] = v;
80
+ }
81
+ }
82
+ }
83
+ return clean;
84
+ }
85
+ // Sanitize ctx by stripping dangerous property names before passing to handlers.
86
+ // Hook authors must NOT set these keys directly on ctx: [...POLLUTED_KEYS]
87
+ // This sanitization runs at the start of executeHook to prevent prototype pollution attacks.
88
+ function sanitizeContext(ctx: HookContext): HookContext {
89
+ for (const key of Object.keys(ctx)) {
90
+ if (POLLUTED_KEYS.has(key.toLowerCase().normalize("NFKC"))) {
91
+ delete ctx[key];
92
+ }
93
+ }
94
+ return ctx;
95
+ }
96
+ function sanitizeErrorMessage(message: string): string {
97
+ // Remove file paths, environment variable references, and other potentially sensitive data
98
+ return message
99
+ .replace(/\/[^:\s]+/g, "[path]")
100
+ .replace(/\b[A-Z_0-9]+\s*=/g, "[env]")
101
+ .replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "[ip]");
102
+ }
103
+
45
104
  export async function executeHook(name: HookName, ctx: HookContext): Promise<HookExecutionReport> {
46
105
  const hooks = getHooks(name);
47
106
  if (hooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
@@ -62,62 +121,6 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
62
121
  return ctx.includeGlobalHooks !== false;
63
122
  });
64
123
  if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
65
- const POLLUTED_KEYS = new Set(
66
- [
67
- "__proto__",
68
- "constructor",
69
- "prototype",
70
- "hasOwnProperty",
71
- "toString",
72
- "valueOf",
73
- "isPrototypeOf",
74
- "propertyIsEnumerable",
75
- "__defineGetter__",
76
- "__defineSetter__",
77
- "__lookupGetter__",
78
- "__lookupSetter__",
79
- ].map((k) => k.toLowerCase().normalize("NFKC")),
80
- );
81
- function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
82
- const clean: Record<string, unknown> = {};
83
- for (const [k, v] of Object.entries(data)) {
84
- if (!POLLUTED_KEYS.has(k.toLowerCase().normalize("NFKC"))) {
85
- if (v !== null && typeof v === "object") {
86
- if (Array.isArray(v)) {
87
- // Sanitize array elements that are objects
88
- clean[k] = v.map((item) =>
89
- item !== null && typeof item === "object" && !Array.isArray(item)
90
- ? sanitizeMergeData(item as Record<string, unknown>)
91
- : item,
92
- );
93
- } else {
94
- clean[k] = sanitizeMergeData(v as Record<string, unknown>);
95
- }
96
- } else {
97
- clean[k] = v;
98
- }
99
- }
100
- }
101
- return clean;
102
- }
103
- // Sanitize ctx by stripping dangerous property names before passing to handlers.
104
- // Hook authors must NOT set these keys directly on ctx: [...POLLUTED_KEYS]
105
- // This sanitization runs at the start of executeHook to prevent prototype pollution attacks.
106
- function sanitizeContext(ctx: HookContext): HookContext {
107
- for (const key of Object.keys(ctx)) {
108
- if (POLLUTED_KEYS.has(key.toLowerCase().normalize("NFKC"))) {
109
- delete ctx[key];
110
- }
111
- }
112
- return ctx;
113
- }
114
- function sanitizeErrorMessage(message: string): string {
115
- // Remove file paths, environment variable references, and other potentially sensitive data
116
- return message
117
- .replace(/\/[^:\s]+/g, "[path]")
118
- .replace(/\b[A-Z_0-9]+\s*=/g, "[env]")
119
- .replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "[ip]");
120
- }
121
124
  const start = Date.now();
122
125
  const diagnostics: string[] = [];
123
126
  let capturedModifications: Record<string, unknown> | undefined;