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.
- package/CHANGELOG.md +249 -0
- package/dist/index.mjs +98 -307
- package/package.json +2 -1
- package/schema.json +11 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/types.ts +36 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/team-tool.ts +53 -5
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +101 -0
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/state-store.ts +98 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- 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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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;
|
package/src/hooks/registry.ts
CHANGED
|
@@ -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;
|