diff --git a/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts b/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts index a2e9ce146f..5d7cdbe790 100755 --- a/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts +++ b/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts @@ -8,7 +8,16 @@ * The deterministic layer detects WHAT changed. The inference layer understands * HOW docs need updating — generating surgical edit pairs, never full rewrites. * - * TRIGGER: Stop hook (via DocIntegrity.hook.ts) + * TRIGGER: SessionEnd (via DocIntegrity.hook.ts) + * + * TEARDOWN BUDGET (2026-07-19): the harness kills the whole hook at 30s and + * reports only "Hook cancelled" — the hook cannot self-report, because its + * fatal path exits 0 and every handler call is individually try/caught. + * Measured worst case before this change: up to 15s inference (a spawned + * `claude` subprocess) + a deliberate 3s sleep + a 3s voice fetch = ~21s, + * BEFORE RebuildArchSummary's then-unbounded generator subprocess even + * started. The inference layer is therefore OFF on the SessionEnd path. + * Set DOCINTEGRITY_INFERENCE=1 to opt back in for a manual run. * * PATTERN TYPES CHECKED (deterministic): * 1. Hook file references (*.hook.ts) - diff against disk @@ -797,17 +806,27 @@ export async function handleDocCrossRefIntegrity( } } - // Step 6: Inference-powered semantic analysis - // Run inference to catch what grep can't: semantic drift in descriptions. - // Always runs when system files are modified — deterministic checks only catch - // broken refs/counts, not semantic drift (e.g., "this hook does X" when it now does Y). - console.error(`${TAG} === Running inference analysis ===`); - const inferenceEdits = await runInferenceAnalysis(modifiedFiles, docsToCheck); - if (inferenceEdits.length > 0) { - const inferenceApplied = applyInferenceEdits(inferenceEdits); - updatesApplied.push(...inferenceApplied); + // Step 6: Inference-powered semantic analysis — NOT on teardown. + // Catches what grep can't (a doc saying "this hook does X" when it now does Y), + // but it spawns a `claude` subprocess capped at 15s, which is half the harness's + // 30s kill window for the whole hook. On SessionEnd the session is already + // ending, so a semantic correction bought at the cost of losing the entire + // deterministic pass — which is the part that actually fixes broken refs and + // counts — is a bad trade. Opt in explicitly for a manual run. + const inferenceEnabled = + hookInput.hook_event_name !== 'SessionEnd' || process.env.DOCINTEGRITY_INFERENCE === '1'; + + if (!inferenceEnabled) { + console.error(`${TAG} [INFERENCE] Skipped on SessionEnd (teardown budget). Set DOCINTEGRITY_INFERENCE=1 to force.`); } else { - console.error(`${TAG} [INFERENCE] No semantic corrections needed`); + console.error(`${TAG} === Running inference analysis ===`); + const inferenceEdits = await runInferenceAnalysis(modifiedFiles, docsToCheck); + if (inferenceEdits.length > 0) { + const inferenceApplied = applyInferenceEdits(inferenceEdits); + updatesApplied.push(...inferenceApplied); + } else { + console.error(`${TAG} [INFERENCE] No semantic corrections needed`); + } } // Step 7: Summary @@ -827,8 +846,13 @@ export async function handleDocCrossRefIntegrity( // Step 10: Voice notification — ONLY when actual documentation edits were applied // No voice for "queued for review" or "in sync" — that's noise if (updatesApplied.length > 0) { - // Delay 3s so the main 🗣️ {{DA_NAME}} voice line plays first - await new Promise(resolve => setTimeout(resolve, 3000)); + // The 3s delay below exists to let the main 🗣️ voice line play first. On + // SessionEnd there is no main voice line to sequence against — the session + // is ending — so it was 3 seconds of a 30s teardown budget spent ordering + // audio nobody is waiting for. Sequence only where sequencing is real. + if (hookInput.hook_event_name !== 'SessionEnd') { + await new Promise(resolve => setTimeout(resolve, 3000)); + } const affectedDocs = new Set(); for (const update of updatesApplied) { diff --git a/LifeOS/install/hooks/handlers/RebuildArchSummary.ts b/LifeOS/install/hooks/handlers/RebuildArchSummary.ts index 8fac19387f..046fa7290d 100755 --- a/LifeOS/install/hooks/handlers/RebuildArchSummary.ts +++ b/LifeOS/install/hooks/handlers/RebuildArchSummary.ts @@ -94,25 +94,45 @@ export async function handleRebuildArchSummary(): Promise { } } +/** + * Wall-clock ceiling for the generator subprocess. + * + * This runs LAST on the SessionEnd path, after the cross-ref handler. The + * harness kills the whole hook at 30s and reports only "Hook cancelled" — the + * hook cannot self-report, so an unbounded child here surfaced as an + * unexplained cancellation rather than as a slow generator. Bounded so the + * failure is legible and the remaining budget is never consumed silently. + */ +const REBUILD_TIMEOUT_MS = 8000; + async function rebuild(generator: string, cwd: string): Promise { return new Promise((resolve) => { const proc = spawn("bun", [generator, "generate"], { cwd, stdio: "pipe" }); let stderr = ""; + let settled = false; + const finish = () => { if (!settled) { settled = true; clearTimeout(timer); resolve(); } }; + + const timer = setTimeout(() => { + console.error(`[RebuildArchSummary] Generator exceeded ${REBUILD_TIMEOUT_MS}ms — killed. Summary left unchanged.`); + try { proc.kill("SIGKILL"); } catch { /* already gone */ } + finish(); + }, REBUILD_TIMEOUT_MS); + proc.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); proc.on("close", (code) => { if (code === 0) { console.error("[RebuildArchSummary] Regenerated DOCUMENTATION/ARCHITECTURE_SUMMARY.md"); - } else { + } else if (!settled) { console.error(`[RebuildArchSummary] Regeneration failed (exit ${code}): ${stderr.trim()}`); } - resolve(); + finish(); }); proc.on("error", (err) => { console.error("[RebuildArchSummary] Spawn error:", err); - resolve(); + finish(); }); }); } diff --git a/LifeOS/install/skills/Interceptor/SKILL.md b/LifeOS/install/skills/Interceptor/SKILL.md index 6ab3d8dff5..5a1aac562a 100644 --- a/LifeOS/install/skills/Interceptor/SKILL.md +++ b/LifeOS/install/skills/Interceptor/SKILL.md @@ -402,6 +402,59 @@ Agent(subagent_type="general-purpose", prompt=" - **Auto-launch recovery (`EnsureTestProfile.sh`) — safety is the re-verification loop, not the profile arg (2026-07-07).** When the test profile window isn't open, `EnsureTestProfile.sh` launches the configured `--profile-directory` and polls `PreflightIsolation.sh` until the PINNED context connects, then proceeds. It can auto-launch the *wrong* profile with zero risk, because it only ever exits 0 after preflight whole-field-matches `INTERCEPTOR_TEST_CONTEXT_ID` and hard-denies Default — a wrong launch just keeps failing preflight, so the agent never drives it. Two real limits: (1) macOS `open -a "Google Chrome" --args --profile-directory=…` is a **no-op for the args when Chrome is already running** (it just activates) — `LaunchTestProfile.sh` sidesteps this via its direct-`CHROME_BIN` branch, which opens a new profile window even against a running Chrome; if a launch "succeeds" but no new window appears, that's the `open`-already-running case and the direct-binary branch is what actually works. (2) If the launch connects a NEW context UUID (extension was reloaded → UUID rot), preflight won't match the pin and `EnsureTestProfile` STOPS with the durable fix (name the context `interceptor-test` in the popup) — it never rebinds to an unidentified UUID. Only exit 5/6 auto-launch; exit 7 (Default/working target) and 8 (unset config) never do. +### Linux + Flatpak browser — the four-hour class (2026-07-19, Fedora) + +All four surfaced in one session on Fedora with Chrome installed as a Flatpak +(`com.google.Chrome`, reached through a `~/.local/bin/google-chrome` shim that +adds `--filesystem=/tmp --filesystem=home --die-with-parent`). Read this block +before debugging a Linux install; the first symptom looks like a dead extension +and is not. + +- **A daemon socket created INSIDE a Flatpak sandbox is not connectable from the + host CLI, even though the socket file is plainly visible.** `/tmp` is genuinely + shared both directions (a host-written marker reads inside the sandbox and vice + versa), so `ls -la /tmp/interceptor.sock` shows the file — and `interceptor + status` reports `daemon: not running` in the same second. The endpoint lives in + the sandbox's namespace; the visible file is a shadow. **The fix is a + non-sandboxed browser**, not a Flatpak permission tweak. On this machine that + meant native Vivaldi (`/opt/vivaldi-snapshot/`), which Interceptor supports; + its relay's socket is host-accessible and the CLI connects immediately. Copy + the NMH manifest to that browser's own dir first + (`~/.config/vivaldi/NativeMessagingHosts/`). +- **A windowless orphaned Flatpak instance can hold port 19222 invisibly, and + `pkill` on the PID you can see does not clear it.** The operator sees no Chrome + window and reasonably reports "no Chrome is open" while + `bwrap --args N -- chrome --profile-directory=X` is still alive with its daemon + owning the port, starving every relay that starts afterwards — including a + different browser's. `kill ` on the visible child does not propagate + through `bwrap`. **`flatpak kill com.google.Chrome` is the reliable clear.** + Check for this whenever a relay reports the port held but no browser is + visibly running. +- **The relay only attaches to a singleton it spawned itself; a losing duplicate + unlinks the winner's socket on exit.** The log signature is + `spawning detached standalone daemon` → `ws port 19222 already held ... exiting + this duplicate` → `detached standalone daemon did not become ready before + timeout` → `falling back to in-process singleton` → `exiting so the existing + daemon serves everyone`. It never falls back to connecting as a plain ws + client. Consequence: **CLI calls are self-destructive while another daemon owns + the port** — each one auto-spawns a duplicate that deletes the live socket, so + diagnosing the problem re-causes it. If the socket keeps vanishing, stop + running CLI commands and fix ownership first. (Upstream-worthy: a daemon should + never unlink a socket it did not create.) +- **`--load-extension` is dead (Chrome 137+; confirmed ignored on 150).** The + profile comes up carrying only the five default component extensions. The + `chrome://extensions` → Load unpacked picker is the only route — and in a + Flatpak browser it goes through the XDG Document Portal, so the chosen path + appears as `/run/flatpak/doc//...` in any error. That prefix is normal and + is not the fault. **`~/.claude` is a dotfolder and GTK pickers hide dotfiles: + press Ctrl+H in the picker** or the operator cannot navigate to the extension + at all. + +**Agent-side footgun from the same session:** `pkill -f interceptor-daemon` run +from Bash matches the invoking shell's own command line and kills it — the +command dies at exit 144 with truncated output, which reads like a wedge. Split +the literal (`PAT="intercep""tor-daemon"; pkill -f "$PAT"`) or kill by PID. + ## Stealth Verification Passes all major bot detection: