@tekmidian/pai 0.36.2 → 0.37.0
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/dist/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
- package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
- package/dist/cli/index.mjs +5 -4
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +5 -4
- package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
- package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
- package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
- package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
- package/dist/daemon/index.mjs +8 -8
- package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
- package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
- package/dist/daemon-DsGGiIJM.mjs +20 -0
- package/dist/daemon-mcp/index.mjs +428 -1
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
- package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
- package/dist/detector-DtLExmHN.mjs +5 -0
- package/dist/factory-BXzqRYVZ.mjs +3 -0
- package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
- package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +164 -14
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/initialize-session.mjs +6 -0
- package/dist/hooks/initialize-session.mjs.map +3 -3
- package/dist/hooks/load-core-context.mjs +6 -0
- package/dist/hooks/load-core-context.mjs.map +3 -3
- package/dist/hooks/load-project-context.mjs +6 -0
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/post-compact-inject.mjs +6 -0
- package/dist/hooks/post-compact-inject.mjs.map +3 -3
- package/dist/hooks/route-agents-to-worker.mjs +456 -0
- package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
- package/dist/hooks/status-line.mjs +400 -0
- package/dist/hooks/status-line.mjs.map +7 -0
- package/dist/hooks/stop-hook.mjs +6 -0
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/subagent-stop-hook.mjs +6 -0
- package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs +6 -0
- package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
- package/dist/hooks/update-tab-on-action.mjs +6 -0
- package/dist/hooks/update-tab-on-action.mjs.map +3 -3
- package/dist/hooks/update-tab-titles.mjs +6 -0
- package/dist/hooks/update-tab-titles.mjs.map +3 -3
- package/dist/hooks/worker-proxy.mjs +796 -0
- package/dist/hooks/worker-proxy.mjs.map +7 -0
- package/dist/hooks/worker-status-line.mjs +483 -0
- package/dist/hooks/worker-status-line.mjs.map +7 -0
- package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
- package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
- package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
- package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
- package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
- package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
- package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
- package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
- package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
- package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
- package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
- package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
- package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
- package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
- package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
- package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
- package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
- package/dist/{program-C-fUghPv.mjs → program-CEIHn_Ma.mjs} +693 -101
- package/dist/program-CEIHn_Ma.mjs.map +1 -0
- package/dist/providers-sXcK5bDZ.mjs +3067 -0
- package/dist/providers-sXcK5bDZ.mjs.map +1 -0
- package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
- package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
- package/dist/query-feedback-DhyLOe5S.mjs +3 -0
- package/dist/router-1zi8jiNF.mjs +3 -0
- package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
- package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
- package/dist/skills/Worker/SKILL.md +41 -0
- package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
- package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
- package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
- package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
- package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
- package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
- package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
- package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
- package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
- package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
- package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
- package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
- package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
- package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
- package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
- package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
- package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
- package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
- package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
- package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
- package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
- package/docs/commands/README.md +28 -0
- package/docs/commands/backup.md +1 -1
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/daemon.md +1 -1
- package/docs/commands/db.md +1 -1
- package/docs/commands/end.md +1 -1
- package/docs/commands/help.md +1 -1
- package/docs/commands/identity.md +1 -1
- package/docs/commands/kg.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +1 -1
- package/docs/commands/notify.md +1 -1
- package/docs/commands/observation.md +1 -1
- package/docs/commands/obsidian.md +1 -1
- package/docs/commands/pause.md +1 -1
- package/docs/commands/project.md +1 -1
- package/docs/commands/projects.md +1 -1
- package/docs/commands/registry.md +1 -1
- package/docs/commands/restore.md +1 -1
- package/docs/commands/session.md +1 -1
- package/docs/commands/sessions.md +1 -1
- package/docs/commands/setup.md +1 -1
- package/docs/commands/shell-init.md +1 -1
- package/docs/commands/skill.md +1 -1
- package/docs/commands/task.md +1 -1
- package/docs/commands/topic.md +1 -1
- package/docs/commands/update.md +1 -1
- package/docs/commands/worker.md +361 -0
- package/docs/commands/zettel.md +1 -1
- package/docs/worker.md +303 -0
- package/package.json +1 -1
- package/scripts/build-hooks.mjs +46 -1
- package/src/hooks/pre-compact.sh +1 -0
- package/src/hooks/session-autosave.sh +1 -0
- package/src/hooks/session-stop.sh +1 -0
- package/src/hooks/ts/lib/context-fill.test.ts +104 -0
- package/src/hooks/ts/lib/context-fill.ts +73 -0
- package/src/hooks/ts/lib/handover-evidence.ts +227 -0
- package/src/hooks/ts/lib/worker-session.test.ts +24 -0
- package/src/hooks/ts/lib/worker-session.ts +23 -0
- package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
- package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
- package/src/hooks/ts/session-start/initialize-session.ts +2 -0
- package/src/hooks/ts/session-start/load-core-context.ts +2 -0
- package/src/hooks/ts/session-start/load-project-context.ts +2 -0
- package/src/hooks/ts/session-start/post-compact-inject.ts +2 -0
- package/src/hooks/ts/stop/stop-hook.ts +2 -0
- package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
- package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
- package/statusline-command.sh +17 -0
- package/dist/daemon-DRdoA489.mjs +0 -20
- package/dist/detector-DGAk1iBR.mjs +0 -5
- package/dist/factory-CrokPMk2.mjs +0 -3
- package/dist/program-C-fUghPv.mjs.map +0 -1
- package/dist/query-feedback-BBMBp96K.mjs +0 -3
- package/dist/router-BMkOb62X.mjs +0 -3
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/hooks/ts/lib/worker-session.ts
|
|
4
|
+
function isWorkerSession(env = process.env) {
|
|
5
|
+
return env.PAI_WORKER === "1";
|
|
6
|
+
}
|
|
7
|
+
|
|
3
8
|
// src/hooks/ts/session-start/post-compact-inject.ts
|
|
4
9
|
import { readFileSync as readFileSync2, existsSync as existsSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
5
10
|
import { join as join3 } from "path";
|
|
@@ -39,6 +44,7 @@ function resetHandoverTriggerState(sessionId) {
|
|
|
39
44
|
|
|
40
45
|
// src/hooks/ts/session-start/post-compact-inject.ts
|
|
41
46
|
async function main() {
|
|
47
|
+
if (isWorkerSession()) return;
|
|
42
48
|
let hookInput = null;
|
|
43
49
|
try {
|
|
44
50
|
const decoder = new TextDecoder();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../../../src/hooks/ts/session-start/post-compact-inject.ts", "../../../src/session/context-handover-trigger.ts", "../../../src/hooks/ts/lib/context-fill.ts", "../../../src/runtime-paths.ts", "../../../src/daemon/ipc-client.ts"],
|
|
4
|
-
"sourcesContent": ["#!/usr/bin/env node\n\n/**\n * post-compact-inject.ts \u2014 SessionStart hook (matcher: \"compact\")\n *\n * Fires AFTER auto/manual compaction. Reads the session state that\n * the PreCompact hook saved to a temp file and outputs it to stdout,\n * which Claude Code injects into the post-compaction context.\n *\n * This is the ONLY way to influence what Claude knows after compaction:\n * PreCompact hooks have no stdout support, but SessionStart does.\n *\n * Flow:\n * PreCompact \u2192 context-compression-hook.ts saves state to /tmp/pai-compact-state-{sessionId}.txt\n * Compaction runs (conversation is summarized)\n * SessionStart(compact) \u2192 THIS HOOK reads that file \u2192 stdout \u2192 context\n */\n\nimport { readFileSync, existsSync, unlinkSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { resetHandoverTriggerState } from '../../../session/context-handover-trigger.js';\n\ninterface HookInput {\n session_id: string;\n transcript_path?: string;\n cwd?: string;\n hook_event_name: string;\n source?: string;\n}\n\nasync function main() {\n let hookInput: HookInput | null = null;\n\n try {\n const decoder = new TextDecoder();\n let input = '';\n const timeoutPromise = new Promise<void>((resolve) => { setTimeout(resolve, 500); });\n const readPromise = (async () => {\n for await (const chunk of process.stdin) {\n input += decoder.decode(chunk, { stream: true });\n }\n })();\n await Promise.race([readPromise, timeoutPromise]);\n if (input.trim()) {\n hookInput = JSON.parse(input) as HookInput;\n }\n } catch {\n // Silently handle input errors\n }\n\n if (!hookInput?.session_id) {\n console.error('post-compact-inject: no session_id, exiting');\n process.exit(0);\n }\n\n // This hook fires exactly once per compaction for this session \u2014 the\n // right moment to reset the handover-trigger marker (confirmed/pending\n // thresholds) so the NEXT context-fill cycle gets its own handover\n // instead of being judged \"already done\" forever. Unconditional: it must\n // run even when no compact-state digest file was found below, since a\n // compaction still happened. Never touches the separate handover CACHE\n // file \u2014 that is what gets injected below and must survive this call.\n // See resetHandoverTriggerState() for the full rationale.\n resetHandoverTriggerState(hookInput.session_id);\n\n // Look for the state file saved by context-compression-hook during PreCompact\n const stateFile = join(tmpdir(), `pai-compact-state-${hookInput.session_id}.txt`);\n\n if (!existsSync(stateFile)) {\n console.error(`post-compact-inject: no state file found at ${stateFile}`);\n process.exit(0);\n }\n\n try {\n const state = readFileSync(stateFile, 'utf-8').trim();\n\n if (state.length > 0) {\n // Output to stdout \u2014 Claude Code injects this into the post-compaction context\n console.log(state);\n console.error(`post-compact-inject: injected ${state.length} chars of session state`);\n }\n\n // Clean up the temp file\n unlinkSync(stateFile);\n console.error(`post-compact-inject: cleaned up ${stateFile}`);\n } catch (err) {\n console.error(`post-compact-inject: error reading state file: ${err}`);\n }\n\n process.exit(0);\n}\n\nmain().catch(() => {\n process.exit(0);\n});\n", "/**\n * context-handover-trigger.ts \u2014 decide whether THIS check is the moment a\n * session should get its threshold-triggered pre-compaction handover, and\n * enqueue it with the daemon if so.\n *\n * Called from the same live hooks that already drive the AG2 rolling\n * autosave (UserPromptSubmit, PostToolUse) \u2014 see\n * cli/commands/session/autosave.ts. Reuses the context-fill reading and the\n * derived thresholds from hooks/ts/lib/context-fill.ts; this module only\n * adds the \"have we already fired this one\" bookkeeping and the enqueue.\n *\n * BUG FIXED (found in live use, session 77084e72-...): a threshold used to\n * be marked fired the moment the ENQUEUE call succeeded \u2014 recording intent,\n * not outcome. When the daemon worker then failed or was restarted mid-spawn\n * (observed: a daemon restart 90s after the enqueue), no handover cache was\n * ever written, but the marker already said \"done\" \u2014 so the session\n * compacted with the mechanical scrape only, and would never have retried,\n * ever, for that session. A threshold is now marked CONFIRMED only after a\n * handover cache file actually appears; an enqueue with no cache to show for\n * it within PENDING_TIMEOUT_MS is treated as failed and retried.\n *\n * Never throws. A daemon that isn't running, a stale reading, an unknown\n * fill \u2014 every one of those is a reason to do nothing this check and try\n * again next time, not a reason to interrupt the hook that called this.\n */\n\nimport { existsSync, readFileSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\nimport {\n getContextFill,\n contextFillThresholds,\n crossedThresholds,\n isImmediate,\n type ContextFillReading,\n type ThresholdName,\n} from \"../hooks/ts/lib/context-fill.js\";\nimport { readContextHandoverCache, type ContextHandoverCache } from \"../hooks/ts/lib/context-handover-cache.js\";\nimport { PaiClient } from \"../daemon/ipc-client.js\";\n\n/** The enqueue only needs to hand the item to the daemon's queue file \u2014 the\n * actual LLM call happens later, asynchronously, in the worker loop. A\n * short bound keeps a rare (at most twice per session) daemon hiccup from\n * making the calling hook wait anywhere near as long as a normal IPC call\n * is allowed to. */\nconst ENQUEUE_TIMEOUT_MS = 2_000;\n\n/**\n * How long to wait for a handover cache to appear after an enqueue before\n * treating it as failed and retrying. Generous over the worker's own sonnet\n * timeout (120s, see session-summary-worker.ts's CLAUDE_TIMEOUT_MS) plus\n * queue latency, so a slow-but-working run isn't retried out from under\n * itself \u2014 and short enough that a genuinely dead attempt (a daemon restart\n * mid-spawn, exactly what happened in the live failure this fixes) is\n * retried well before the next compaction, not \"never\".\n */\nconst PENDING_TIMEOUT_MS = 5 * 60 * 1000;\n\n/** Both thresholds confirmed \u2014 nothing left to check for the rest of the session. */\nconst ALL_THRESHOLDS: ThresholdName[] = [\"warmup\", \"refresh\"];\n\n// ---------------------------------------------------------------------------\n// Persisted state \u2014 outcome-based, not intent-based\n// ---------------------------------------------------------------------------\n\n/**\n * `confirmed`: thresholds whose handover was actually verified written.\n * `pending`: an enqueue attempt awaiting outcome confirmation \u2014 cleared\n * either when a fresher cache appears (success) or when PENDING_TIMEOUT_MS\n * elapses with nothing to show for it (treated as failed; retried).\n *\n * A file in the OLD shape (`{fired: [...]}`, no `confirmed` key \u2014 exactly\n * what the live failure left behind) parses with `confirmed` defaulting to\n * `[]` and `pending` to `null`: it is treated as \"nothing confirmed yet\",\n * which self-heals the stuck session that bug left behind rather than\n * requiring a manual cleanup step.\n */\nexport interface HandoverTriggerState {\n confirmed: ThresholdName[];\n pending: { thresholds: ThresholdName[]; enqueuedAt: string } | null;\n}\n\nexport function triggerStatePath(sessionId: string): string {\n return join(tmpdir(), `pai-context-handover-fired-${sessionId}.json`);\n}\n\nexport function loadTriggerState(sessionId: string): HandoverTriggerState {\n const path = triggerStatePath(sessionId);\n if (!existsSync(path)) return { confirmed: [], pending: null };\n try {\n const raw = JSON.parse(readFileSync(path, \"utf-8\")) as Partial<HandoverTriggerState>;\n return {\n confirmed: Array.isArray(raw.confirmed) ? (raw.confirmed as ThresholdName[]) : [],\n pending: raw.pending && Array.isArray(raw.pending.thresholds) && typeof raw.pending.enqueuedAt === \"string\"\n ? raw.pending\n : null,\n };\n } catch {\n return { confirmed: [], pending: null };\n }\n}\n\nfunction saveTriggerState(sessionId: string, state: HandoverTriggerState): void {\n try {\n writeFileSync(triggerStatePath(sessionId), JSON.stringify(state), \"utf-8\");\n } catch {\n // Best-effort \u2014 a missing write only costs a possible re-attempt later.\n }\n}\n\n/**\n * Reset a session's fired/confirmed marker after a compaction, so the next\n * fill cycle gets its own handover instead of being judged against\n * thresholds this session already used up. Deliberately touches ONLY the\n * marker file (`triggerStatePath`) \u2014 never the handover cache\n * (`context-handover-cache.ts`), which is what actually gets injected at\n * the compaction boundary and must survive until that injection reads it.\n *\n * WHY THIS EXISTS (do not \"simplify\" it away): without this reset,\n * `confirmed` was permanent for the lifetime of a session id \u2014 nothing\n * anywhere ever cleared it. A session observed live on 2026-09-17\n * compacted once, both thresholds got confirmed, and every check for the\n * rest of that session's life then hit the `confirmed` short-circuit at\n * the top of `checkAndEnqueueContextHandover` and returned immediately \u2014\n * even after a SECOND compaction, ~15 hours and a full context refill\n * later, whose injected \"handover\" was still the first one, generated\n * long before, and which said in its own text that nothing had changed\n * since it. A long-lived session compacts more than once; each compaction\n * starts a new fill cycle and deserves its own handover attempt.\n *\n * Call this exactly once per compaction, from the post-compact SessionStart\n * hook that already runs at that moment for other cleanup \u2014 see\n * post-compact-inject.ts.\n *\n * If a `pending` enqueue was outstanding at reset time, it is simply\n * dropped along with the rest of the marker \u2014 the in-flight daemon job\n * itself is NOT cancelled and will still write its cache entry when it\n * finishes (harmless: cache files are read by generation time, not by\n * whether a marker was watching for them). Because the marker is gone,\n * the next check has no pending entry to resolve, so it does not confirm\n * against that stray cache \u2014 it simply runs a fresh crossing check. So\n * this cannot double-enqueue: the only thing that ever calls `enqueue`\n * again is a genuinely fresh threshold crossing after the reset, of which\n * there is exactly one code path.\n *\n * Never throws: a session with no marker file (fresh session, or one\n * already reset) is a plain no-op, not an error.\n */\nexport function resetHandoverTriggerState(sessionId: string): void {\n const path = triggerStatePath(sessionId);\n try {\n if (existsSync(path)) unlinkSync(path);\n } catch {\n // Best-effort \u2014 see saveTriggerState above. Worst case on failure: the\n // next compaction re-serves one stale handover, exactly the bug this\n // function fixes, not a corrupted or crashed session.\n }\n}\n\n// ---------------------------------------------------------------------------\n// Enqueue\n// ---------------------------------------------------------------------------\n\nexport interface HandoverTriggerInput {\n sessionId: string;\n cwd: string;\n transcriptPath?: string;\n}\n\nasync function enqueueContextHandover(payload: {\n sessionId: string;\n cwd: string;\n transcriptPath?: string;\n threshold: ThresholdName;\n urgent: boolean;\n}): Promise<void> {\n const client = new PaiClient();\n await client.call(\n \"work_queue_enqueue\",\n {\n type: \"context-handover\",\n // Below session-end (2), above session-summary (4) \u2014 this is rarer\n // than session-summary (at most twice per session) and time-sensitive\n // (it exists specifically to beat the compaction it is racing).\n priority: 3,\n payload: {\n sessionId: payload.sessionId,\n cwd: payload.cwd,\n transcriptPath: payload.transcriptPath,\n threshold: payload.threshold,\n urgent: payload.urgent,\n },\n },\n ENQUEUE_TIMEOUT_MS\n );\n}\n\n// ---------------------------------------------------------------------------\n// Dependency injection \u2014 for testing without a real daemon or filesystem\n// ---------------------------------------------------------------------------\n\nexport interface HandoverTriggerDeps {\n getReading: (input: HandoverTriggerInput) => ContextFillReading;\n loadState: (sessionId: string) => HandoverTriggerState;\n saveState: (sessionId: string, state: HandoverTriggerState) => void;\n readCache: (sessionId: string) => ContextHandoverCache | null;\n enqueue: typeof enqueueContextHandover;\n now: () => number;\n}\n\nconst defaultDeps: HandoverTriggerDeps = {\n getReading: (input) =>\n getContextFill({ sessionId: input.sessionId, transcriptPath: input.transcriptPath }),\n loadState: loadTriggerState,\n saveState: saveTriggerState,\n readCache: readContextHandoverCache,\n enqueue: enqueueContextHandover,\n now: () => Date.now(),\n};\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\nexport interface HandoverTriggerResult {\n /** Newly enqueued this check \u2014 an attempt, not yet a confirmed outcome. */\n attempted: ThresholdName[];\n /** Newly CONFIRMED this check \u2014 a handover cache was actually verified. */\n confirmed: ThresholdName[];\n}\n\nconst NOTHING: HandoverTriggerResult = { attempted: [], confirmed: [] };\n\n/**\n * Check this session's current context fill against the derived thresholds,\n * confirm or retry any in-flight attempt, and enqueue a context-handover job\n * for any newly-crossed threshold. Never marks a threshold done on the\n * strength of the enqueue call alone \u2014 only a verified cache file does that.\n *\n * A single check that crosses both warmup and refresh in one jump (a\n * session first observed already close to compaction) enqueues ONE job,\n * tagged with the more urgent of the two, and \u2014 once confirmed \u2014 marks both\n * done together, since one model-written handover already covers what\n * either alone would have.\n */\nexport async function checkAndEnqueueContextHandover(\n input: HandoverTriggerInput,\n deps: HandoverTriggerDeps = defaultDeps\n): Promise<HandoverTriggerResult> {\n let state = deps.loadState(input.sessionId);\n if (ALL_THRESHOLDS.every((t) => state.confirmed.includes(t))) return NOTHING;\n\n // Thresholds confirmed DURING this check (from the pending-resolution step\n // below) \u2014 carried through every return path so a confirm that falls\n // through to a fresh crossing-check doesn't get silently dropped.\n let confirmedThisCheck: ThresholdName[] = [];\n\n // -------------------------------------------------------------------\n // Resolve any in-flight attempt first \u2014 confirm, keep waiting, or\n // time it out and clear it for retry. This is the outcome check: a\n // handover cache generated AFTER the enqueue is proof the worker ran\n // and actually wrote something, not just that the enqueue call itself\n // succeeded.\n // -------------------------------------------------------------------\n if (state.pending) {\n const cache = deps.readCache(input.sessionId);\n const cacheIsFromThisAttempt =\n cache !== null && Date.parse(cache.generatedAt) > Date.parse(state.pending.enqueuedAt);\n\n if (cacheIsFromThisAttempt) {\n const newlyConfirmed = state.pending.thresholds.filter((t) => !state.confirmed.includes(t));\n state = { confirmed: [...state.confirmed, ...newlyConfirmed], pending: null };\n deps.saveState(input.sessionId, state);\n confirmedThisCheck = newlyConfirmed;\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: confirmed [${newlyConfirmed.join(\", \")}] ` +\n `\u2014 handover cache verified (generated ${cache!.generatedAt}).`\n );\n if (ALL_THRESHOLDS.every((t) => state.confirmed.includes(t))) {\n return { attempted: [], confirmed: confirmedThisCheck };\n }\n // Fall through \u2014 a session that jumped straight to refresh-confirmed\n // territory may already have crossed further; keep checking below.\n } else if (deps.now() - Date.parse(state.pending.enqueuedAt) < PENDING_TIMEOUT_MS) {\n // Still within the window \u2014 no news is not yet failure.\n return NOTHING;\n } else {\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: pending [${state.pending.thresholds.join(\", \")}] ` +\n `timed out after ${PENDING_TIMEOUT_MS}ms with no handover cache written \u2014 ` +\n `treating as FAILED and retrying (daemon worker likely died or was restarted mid-run).`\n );\n state = { ...state, pending: null };\n deps.saveState(input.sessionId, state);\n }\n }\n\n const reading = deps.getReading(input);\n if (reading.status !== \"ok\" || reading.usedTokens === null) {\n return { attempted: [], confirmed: confirmedThisCheck };\n }\n\n const thresholds = contextFillThresholds(reading, process.env, { cwd: input.cwd });\n if (!thresholds.windowConfirmed) {\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: window size not confirmed \u2014 ` +\n `using the assumed default (${reading.windowSize} tokens) rather than a reading Claude Code reported.`\n );\n }\n\n const newlyCrossed = crossedThresholds(reading.usedTokens, thresholds, state.confirmed);\n if (newlyCrossed.length === 0) return { attempted: [], confirmed: confirmedThisCheck };\n\n const urgent = isImmediate(reading.usedTokens, thresholds);\n const mostUrgent = newlyCrossed[newlyCrossed.length - 1];\n\n try {\n await deps.enqueue({\n sessionId: input.sessionId,\n cwd: input.cwd,\n transcriptPath: input.transcriptPath,\n threshold: mostUrgent,\n urgent,\n });\n } catch (err) {\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: enqueue FAILED \u2014 will retry next check: ${err}`\n );\n // Nothing persisted \u2014 next check sees no pending and tries again.\n return { attempted: [], confirmed: confirmedThisCheck };\n }\n\n state = { ...state, pending: { thresholds: newlyCrossed, enqueuedAt: new Date(deps.now()).toISOString() } };\n deps.saveState(input.sessionId, state);\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: enqueued [${newlyCrossed.join(\", \")}] ` +\n `at ${reading.usedTokens} tokens (trigger=${thresholds.effectiveTriggerTokens}, ` +\n `triggerSource=${thresholds.triggerSource}, autocompactPct=${thresholds.autocompactPct}, urgent=${urgent}) ` +\n `\u2014 awaiting outcome confirmation before marking done.`\n );\n\n return { attempted: newlyCrossed, confirmed: confirmedThisCheck };\n}\n", "/**\n * context-fill.ts \u2014 \"how full is this session's context window, right now?\"\n *\n * No hook payload carries this number directly (verified: PreCompact,\n * UserPromptSubmit and PostToolUse stdin never include `context_window`).\n * Two sources can reconstruct it, in order of preference:\n *\n * 1. STATUSLINE STATE FILE \u2014 statusline-command.sh receives the exact\n * figure from Claude Code (`.context_window.used_percentage` /\n * `.context_window.context_window_size`) on every render and persists\n * it to `${TMPDIR}/pai-context-<session_id>.json`. This is authoritative\n * but depends on the status line having rendered recently \u2014 a session\n * whose terminal isn't drawing a status line (headless, backgrounded)\n * leaves this file missing or stale.\n *\n * 2. TRANSCRIPT USAGE \u2014 every `message.usage` entry in the session's own\n * .jsonl transcript already reports the token accounting for that one\n * API call: `input_tokens + cache_read_input_tokens +\n * cache_creation_input_tokens` on the MOST RECENT such entry *is* the\n * current context fill, because each request resends the full context.\n * This is why it must be the last entry's fields summed, never a sum\n * across entries \u2014 summing across entries is a running token-spend\n * total, not a fill reading (see calculateSessionTokens, a different\n * metric answering a different question).\n *\n * When neither source is available, the answer is UNKNOWN, not zero. A zero\n * reads as \"plenty of room\" and would silently suppress every downstream\n * decision that depends on this number (the compaction handover chief among\n * them) for the entire session.\n */\n\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { homedir, tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\n/** Fallback window size when nothing on hand reports one. Matches the\n * default statusline-command.sh already falls back to. */\nexport const DEFAULT_CONTEXT_WINDOW = 200_000;\n\n/** How many trailing lines of the transcript to scan for a usage entry.\n * A single turn is rarely more than a handful of JSONL lines (assistant\n * text + tool_use/tool_result pairs), so 40 comfortably covers one turn\n * even a busy one, without reading the whole file on every check. */\nconst TRANSCRIPT_TAIL_LINES = 40;\n\n/** A statusline reading older than this is treated as not there at all \u2014\n * a stale number is worse than none, because it looks confident. */\nconst STATUSLINE_STALE_MS = 5 * 60 * 1000; // 5 minutes\n\nexport type ContextFillSource = \"statusline\" | \"transcript\" | \"unknown\";\n\nexport interface ContextFillReading {\n status: \"ok\" | \"unknown\";\n /** Tokens currently occupying the context window, or null when unknown. */\n usedTokens: number | null;\n windowSize: number;\n /** usedTokens / windowSize, or null when unknown. Not clamped to [0,1] \u2014\n * callers that need a clamped display value do that themselves so the\n * raw reading (which can legitimately exceed 1 for a moment) is never\n * silently rewritten here. */\n fraction: number | null;\n source: ContextFillSource;\n}\n\nfunction unknownReading(windowSize: number): ContextFillReading {\n return { status: \"unknown\", usedTokens: null, windowSize, fraction: null, source: \"unknown\" };\n}\n\n// ---------------------------------------------------------------------------\n// Source 1 \u2014 statusline state file\n// ---------------------------------------------------------------------------\n\nexport function statuslineStateFilePath(sessionId: string): string {\n return join(tmpdir(), `pai-context-${sessionId}.json`);\n}\n\ninterface StatuslineState {\n used_percentage?: number;\n context_window_size?: number;\n session_id?: string;\n timestamp?: number;\n}\n\n/**\n * Read the state file statusline-command.sh writes on every render.\n * Returns null when the file is missing, unparsable, or older than\n * STATUSLINE_STALE_MS \u2014 all three mean \"not a source right now\".\n */\nexport function readStatuslineFill(sessionId: string, now = Date.now()): ContextFillReading | null {\n if (!sessionId) return null;\n const path = statuslineStateFilePath(sessionId);\n if (!existsSync(path)) return null;\n\n let raw: StatuslineState;\n try {\n raw = JSON.parse(readFileSync(path, \"utf-8\"));\n } catch {\n return null;\n }\n\n if (typeof raw.timestamp !== \"number\" || now - raw.timestamp > STATUSLINE_STALE_MS) {\n return null; // stale \u2014 fall through to the transcript source\n }\n if (typeof raw.used_percentage !== \"number\" || typeof raw.context_window_size !== \"number\") {\n return null;\n }\n\n const windowSize = raw.context_window_size;\n const usedTokens = Math.round((raw.used_percentage / 100) * windowSize);\n return {\n status: \"ok\",\n usedTokens,\n windowSize,\n fraction: raw.used_percentage / 100,\n source: \"statusline\",\n };\n}\n\n// ---------------------------------------------------------------------------\n// Source 2 \u2014 transcript usage (fallback)\n// ---------------------------------------------------------------------------\n\ninterface UsageEntry {\n input_tokens?: number;\n cache_read_input_tokens?: number;\n cache_creation_input_tokens?: number;\n}\n\n/**\n * Sum the three context-carrying fields of ONE usage object \u2014 never across\n * usage objects. A single `message.usage` already reports what that one API\n * call sent as context; adding another entry's numbers to it produces a\n * cumulative spend figure, not a fill reading, and can exceed the window\n * size many times over on a long session (the exact defect this helper\n * exists to not repeat \u2014 see the PreCompact header fix in the same change).\n */\nfunction usageTotal(usage: UsageEntry): number {\n return (\n (usage.input_tokens || 0) +\n (usage.cache_read_input_tokens || 0) +\n (usage.cache_creation_input_tokens || 0)\n );\n}\n\n/**\n * Derive context fill from the tail of a .jsonl transcript: the most recent\n * `message.usage` entry, read from the end backwards so a trailing line with\n * no usage field (a plain text turn, a tool_result) doesn't hide one just\n * before it.\n */\nexport function contextFillFromTranscript(\n transcriptPath: string,\n windowSize = DEFAULT_CONTEXT_WINDOW\n): ContextFillReading {\n if (!transcriptPath || !existsSync(transcriptPath)) return unknownReading(windowSize);\n\n let raw: string;\n try {\n raw = readFileSync(transcriptPath, \"utf-8\");\n } catch {\n return unknownReading(windowSize);\n }\n\n const lines = raw.trim().split(\"\\n\").filter((l) => l.trim());\n const tail = lines.slice(-TRANSCRIPT_TAIL_LINES);\n\n for (let i = tail.length - 1; i >= 0; i--) {\n let entry: { message?: { usage?: UsageEntry } };\n try {\n entry = JSON.parse(tail[i]);\n } catch {\n continue;\n }\n const usage = entry?.message?.usage;\n if (usage && typeof usage === \"object\") {\n const usedTokens = usageTotal(usage);\n return {\n status: \"ok\",\n usedTokens,\n windowSize,\n fraction: usedTokens / windowSize,\n source: \"transcript\",\n };\n }\n }\n\n return unknownReading(windowSize);\n}\n\n// ---------------------------------------------------------------------------\n// Precedence \u2014 statusline (fresh) > transcript > unknown\n// ---------------------------------------------------------------------------\n\nexport function getContextFill(\n input: { sessionId?: string; transcriptPath?: string; windowSize?: number },\n now = Date.now()\n): ContextFillReading {\n const windowSize = input.windowSize ?? DEFAULT_CONTEXT_WINDOW;\n\n if (input.sessionId) {\n const fromStatusline = readStatuslineFill(input.sessionId, now);\n if (fromStatusline) return fromStatusline;\n }\n\n if (input.transcriptPath) {\n return contextFillFromTranscript(input.transcriptPath, windowSize);\n }\n\n return unknownReading(windowSize);\n}\n\n// ---------------------------------------------------------------------------\n// Display helper \u2014 clamp-and-flag rather than ever print an absurd number\n// ---------------------------------------------------------------------------\n\nexport interface FillDisplay {\n /** e.g. \"63k\" or \"unknown\". Never a number larger than the window. */\n text: string;\n /** True when the raw reading exceeded the window size and had to be\n * clamped \u2014 that reading was a bug, not a fill, and callers may want to\n * log it even though the displayed text is already safe. */\n flagged: boolean;\n}\n\nexport function formatContextFill(reading: ContextFillReading): FillDisplay {\n if (reading.status !== \"ok\" || reading.usedTokens === null) {\n return { text: \"unknown\", flagged: false };\n }\n\n let used = reading.usedTokens;\n let flagged = false;\n if (used > reading.windowSize) {\n flagged = true;\n used = reading.windowSize;\n }\n\n const text = used > 1000 ? `${Math.round(used / 1000)}k` : String(used);\n return { text: flagged ? `${text}+ (clamped \u2014 reading exceeded window)` : text, flagged };\n}\n\n// ---------------------------------------------------------------------------\n// Threshold-triggered handover \u2014 WHEN to warm up / refresh the model-written\n// summary ahead of a compaction.\n// ---------------------------------------------------------------------------\n//\n// THE CONFIGURED VALUE DOES NOT PREDICT THE TRIGGER. CLAUDE_AUTOCOMPACT_\n// PCT_OVERRIDE was 80 throughout, on this machine, across BOTH of the\n// following regimes \u2014 the same configured number, two different truths:\n//\n// 2026-08-16 .. 2026-09-10 preTokens ~ 993,096 \u2013 1,002,500 (~100% of a 1M window)\n// 2026-09-12 .. 2026-09-15 preTokens ~ 782,981 \u2013 791,995 (~78-79% of a 1M window)\n//\n// A threshold derived only from the configured override was wrong for the\n// second regime and would be wrong again the next time it moves \u2014 the\n// config is not ground truth, it is what Claude Code claims it will honor.\n//\n// GROUND TRUTH IS ON DISK ALREADY: every real compaction writes a\n// `compact_boundary` system event to the transcript with\n// `compactMetadata.preTokens` \u2014 the platform's own count of context tokens\n// immediately before IT compacted. `measureCompactionTrigger` scans a\n// project's own transcripts for these and takes the MINIMUM of the most\n// recent three (minimum, not mean: warm-up must fire before the earliest\n// plausible boundary, not the average one). A project that has compacted\n// before learns its own trigger and needs no code change when the regime\n// moves again \u2014 the same derivation produced 998,508 pre-09-12 and ~784k\n// after, from one unchanged formula.\n//\n// The configured chain \u2014 env override, then a default \u2014 is the fallback,\n// used ONLY when a project has no compaction history yet:\n//\n// effectiveTrigger = measured ?? (windowSize * (override ?? 80) / 100)\n//\n// DEFAULT IS 80, NOT 100, WHEN NOTHING IS KNOWN. 100 was considered \u2014 it\n// matches the FIRST regime above \u2014 and rejected: the costs are asymmetric.\n// Warming up too early wastes one summary; cheap, invisible. Warming up too\n// late produces exactly the degraded successor session this feature exists\n// to prevent. 80 is the conservative default until a project has its own\n// measured history to correct it.\n//\n// Margins below the effective trigger are absolute tokens, sized against the\n// largest single-turn context jump measured: 62,383 tokens (517,952 \u2192\n// 580,335, about 27 seconds). warmup sits 100k below the trigger \u2014\n// comfortably more than that jump, so a session cannot leap clean over\n// warmup straight into a compaction in one turn; refresh at 40k below\n// catches a session that sat above warmup for a while; fireNow at 15k below\n// is \"no time left for a clean crossing\" and fires immediately rather than\n// waiting for one.\nexport const DEFAULT_AUTOCOMPACT_PCT = 80;\n\n/** How many of the most recent compact_boundary events to consider, and to\n * take the minimum of. */\nconst MEASURED_TRIGGER_SAMPLE_SIZE = 3;\n\nexport const THRESHOLD_MARGIN_TOKENS = {\n warmup: 100_000,\n refresh: 40_000,\n immediate: 15_000,\n} as const;\n\n// ---------------------------------------------------------------------------\n// Measured trigger \u2014 scan a project's own transcript history for the\n// platform's own compact_boundary events, ground truth over configuration.\n// ---------------------------------------------------------------------------\n//\n// Deliberately re-implements the tiny bit of path encoding it needs (below)\n// rather than importing project-utils/paths.ts's encodePath: that module\n// chain ends in pai-paths.ts, which calls process.exit(1) if PAI_DIR does\n// not resolve to an existing directory. context-fill.ts is specifically the\n// module a hook falls back on when its environment is unreliable \u2014 pulling\n// in a dependency that can kill the process on import would defeat that.\n// Claude Code's transcript directory is a fixed, home-relative convention\n// (~/.claude/projects/<encoded-cwd>/), not something PAI_DIR governs, so\n// resolving it directly here is also just the more correct dependency, not\n// only the safer one.\n\n/** Real default \u2014 overridable per-call so tests never touch the user's\n * actual ~/.claude/projects/ (a live directory this very session writes to). */\nexport const CLAUDE_PROJECTS_DIR = join(homedir(), \".claude\", \"projects\");\n\nfunction encodeProjectPath(cwd: string): string {\n return cwd.replace(/[/\\s.-]/g, \"-\");\n}\n\nexport interface CompactBoundarySample {\n preTokens: number;\n timestampMs: number;\n /** ISO string, kept alongside timestampMs so callers can print it. */\n timestamp: string;\n /** The event's own uuid when present \u2014 the dedup key. Claude Code's\n * `sessions/` archive directory mirrors the live project transcript, so\n * the SAME compact_boundary event can legitimately appear in two files;\n * without deduping by identity, \"most recent three\" can silently become\n * three copies of one event, which is a stale reading wearing a\n * plausible-looking sample size. */\n uuid?: string;\n}\n\n/** Every `.jsonl` transcript belonging to a project \u2014 top-level (the live\n * file) and `sessions/` (Claude Code's archive, which mirrors it). No file\n * is excluded and no ordering is applied here: ordering by EVENT\n * timestamp, not by file mtime, is the whole point (see\n * readCompactBoundarySamples) \u2014 a file's mtime does not reliably track\n * which events inside it are recent, and pre-filtering by mtime is exactly\n * what caused this function to return a stale, pre-regime-change trigger\n * on real data. */\nfunction listProjectTranscripts(cwd: string, projectsDir: string): string[] {\n const projectDir = join(projectsDir, encodeProjectPath(cwd));\n if (!existsSync(projectDir)) return [];\n\n const paths: string[] = [];\n const collect = (dir: string): void => {\n if (!existsSync(dir)) return;\n let entries: string[];\n try {\n entries = readdirSync(dir);\n } catch {\n return;\n }\n for (const entry of entries) {\n if (entry.endsWith(\".jsonl\")) paths.push(join(dir, entry));\n }\n };\n collect(projectDir);\n collect(join(projectDir, \"sessions\"));\n return paths;\n}\n\n/**\n * Every DISTINCT compact_boundary sample found across ALL of a project's\n * transcripts (live + archived), newest first by the event's OWN timestamp\n * \u2014 never by which file it came from or that file's mtime. Deduplicated by\n * the event's uuid (falling back to a timestamp+preTokens key for the rare\n * line with no uuid) so an event mirrored into `sessions/` is counted once.\n */\nfunction readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBoundarySample[] {\n const byKey = new Map<string, CompactBoundarySample>();\n\n for (const path of listProjectTranscripts(cwd, projectsDir)) {\n let raw: string;\n try {\n raw = readFileSync(path, \"utf-8\");\n } catch {\n continue;\n }\n\n for (const line of raw.split(\"\\n\")) {\n if (!line.trim()) continue;\n let entry: {\n type?: string;\n subtype?: string;\n timestamp?: string;\n uuid?: string;\n compactMetadata?: { preTokens?: number };\n };\n try {\n entry = JSON.parse(line);\n } catch {\n continue;\n }\n if (entry.type !== \"system\" || entry.subtype !== \"compact_boundary\") continue;\n const preTokens = entry.compactMetadata?.preTokens;\n if (typeof preTokens !== \"number\" || !Number.isFinite(preTokens)) continue;\n const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;\n if (!Number.isFinite(timestampMs)) continue;\n\n const key = entry.uuid ?? `${timestampMs}:${preTokens}`;\n if (!byKey.has(key)) {\n byKey.set(key, { preTokens, timestampMs, timestamp: entry.timestamp!, uuid: entry.uuid });\n }\n }\n }\n\n return [...byKey.values()].sort((a, b) => b.timestampMs - a.timestampMs);\n}\n\n/**\n * The most recent MEASURED_TRIGGER_SAMPLE_SIZE distinct compact_boundary\n * events for a project, newest first \u2014 exposed on its own (not just the\n * derived minimum) so the number `measureCompactionTrigger` returns is\n * checkable: print these and the timestamps prove which three events\n * produced it, rather than asking for trust.\n */\nexport function selectedCompactionSamples(\n cwd: string,\n projectsDir: string = CLAUDE_PROJECTS_DIR\n): CompactBoundarySample[] {\n if (!cwd) return [];\n return readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);\n}\n\n/**\n * The measured compaction trigger for a project, or null when it has no\n * compaction history yet (a brand-new project, or one whose transcripts\n * this process cannot read). Minimum of the most recent\n * MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT compact_boundary events, ordered by\n * the events' own timestamps across every transcript the project has\n * (live and archived) \u2014 see the module comment above for why minimum, not\n * mean, and readCompactBoundarySamples for why \"distinct\" and \"own\n * timestamp\" both matter (a file-mtime-ordered, non-deduplicated version of\n * this returned a stale pre-regime-change trigger on real project data).\n */\nexport function measureCompactionTrigger(\n cwd: string,\n projectsDir: string = CLAUDE_PROJECTS_DIR\n): number | null {\n const samples = selectedCompactionSamples(cwd, projectsDir);\n if (samples.length === 0) return null;\n const trigger = Math.min(...samples.map((s) => s.preTokens));\n console.error(\n `[context-fill] measured trigger for ${cwd}: ${trigger} (minimum of ` +\n samples.map((s) => `${s.preTokens}@${s.timestamp}`).join(\", \") + \")\"\n );\n return trigger;\n}\n\n/**\n * Read CLAUDE_AUTOCOMPACT_PCT_OVERRIDE from the environment. Absent \u2192\n * DEFAULT_AUTOCOMPACT_PCT. Present but not a finite number in (0, 100] \u2192\n * also DEFAULT_AUTOCOMPACT_PCT, logged, so a typo in the override degrades\n * to the documented default instead of silently producing nonsense\n * thresholds (0, negative, or a fraction so large no session ever reaches\n * it).\n */\nexport function resolveAutocompactPct(env: NodeJS.ProcessEnv = process.env): number {\n const raw = env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;\n if (raw === undefined || raw === \"\") return DEFAULT_AUTOCOMPACT_PCT;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) {\n console.error(\n `[context-fill] CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=\"${raw}\" is not a usable percentage \u2014 ` +\n `falling back to the default ${DEFAULT_AUTOCOMPACT_PCT}.`\n );\n return DEFAULT_AUTOCOMPACT_PCT;\n }\n return parsed;\n}\n\n/**\n * The two thresholds that actually fire a handover job. \"immediate\" is not a\n * third job \u2014 see `isImmediate` below \u2014 it only changes how urgently a\n * crossing of these two is treated.\n */\nexport type ThresholdName = \"warmup\" | \"refresh\";\n\n/** Which basis actually produced effectiveTriggerTokens \u2014 reported so the\n * number is checkable rather than trusted. */\nexport type TriggerSource = \"measured\" | \"configured\" | \"measured-clamped\";\n\nexport interface ContextFillThresholds {\n warmupTokens: number;\n refreshTokens: number;\n immediateTokens: number;\n /** The derived compaction trigger these were measured back from \u2014\n * min(measured, configured) when a measured value exists. */\n effectiveTriggerTokens: number;\n /** The raw measured value from this project's own compact_boundary\n * history, before any clamping \u2014 null when the project has no history.\n * Kept alongside effectiveTriggerTokens so a clamp is visible rather\n * than silent: a caller can see both what was measured and what was\n * actually used. */\n measuredTriggerTokens: number | null;\n /** The raw configured-chain value (env override or default, as a\n * fraction of the window) \u2014 always computed and reported, even when it\n * wasn't what ended up being used. */\n configuredTriggerTokens: number;\n /** The autocompact percentage from the configured chain. */\n autocompactPct: number;\n /** \"measured\": a measured value existed and was <= configured, so it was\n * used directly \u2014 the better estimate, since it reflects reality the\n * configured percentage cannot know.\n * \"measured-clamped\": a measured value existed but was HIGHER than\n * configured \u2014 it reflects a regime that may no longer apply (this\n * project's last compaction predates a since-changed trigger), so the\n * lower, safer configured value was used instead.\n * \"configured\": no measured value exists yet (no compaction history for\n * this project) \u2014 the configured chain is all there is. */\n triggerSource: TriggerSource;\n /** True when these were computed against a window size Claude Code itself\n * reported (the statusline source). False when the window is only an\n * assumed default (the transcript-fallback source never learns the real\n * window size) \u2014 callers should log this: a threshold silently computed\n * against the wrong basis is the same class of fault as printing a token\n * count larger than the window. */\n windowConfirmed: boolean;\n}\n\nexport interface ContextFillThresholdOpts {\n /** The session's project directory, used to look up its own compaction\n * history for the measured trigger. Omit when unknown \u2014 falls back to\n * the configured chain, same as a project with no history yet. */\n cwd?: string;\n /** Override the measured-trigger lookup instead of scanning transcripts \u2014\n * primarily for tests. `null` forces the configured fallback even when\n * `cwd` is given; `undefined` (the default) does the real lookup. */\n measuredTrigger?: number | null;\n}\n\n/**\n * Derive warmup/refresh/immediate thresholds from a fill reading.\n *\n * effectiveTrigger = min(measured, configuredChainValue) when a measured\n * value exists \u2014 NOT the measured value outright. A project whose newest\n * compaction predates a regime change measures a stale-HIGH trigger: a real\n * case on this machine measured 998,267 for a project whose ACTUAL current\n * boundary (from a different project's fresher history, cross-checked\n * independently) is ~784,000 \u2014 using 998,267 directly would compute a\n * warm-up of 898,267, above the real boundary, so the handover would never\n * fire there. The measurement is honest; it is just old.\n *\n * The minimum is correct in both directions: the measured value is the\n * better estimate when it is LOWER than configured (it reflects reality the\n * configured percentage cannot know \u2014 see the module comment above, the\n * 100%-to-78% regime change this project itself lived through); it is\n * unsafe when it is HIGHER (it reflects a regime that no longer applies).\n * Taking the minimum costs nothing in the safe direction \u2014 one wasted\n * summary if the project's regime actually did move up \u2014 and prevents the\n * unsafe direction, where a stale-high measurement suppresses the handover\n * past the real boundary. That asymmetry is the same one the 80-not-100\n * default was chosen for.\n *\n * Margins below effectiveTrigger are clamped at 0 (and logged) for a window\n * small enough that a margin would otherwise go negative \u2014 a pathological\n * input should degrade to \"fire immediately\", never to a threshold below\n * zero.\n */\nexport function contextFillThresholds(\n reading: ContextFillReading,\n env: NodeJS.ProcessEnv = process.env,\n opts: ContextFillThresholdOpts = {}\n): ContextFillThresholds {\n const windowConfirmed = reading.source === \"statusline\";\n const autocompactPct = resolveAutocompactPct(env);\n const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));\n\n const measuredTriggerTokens = opts.measuredTrigger !== undefined\n ? opts.measuredTrigger\n : opts.cwd\n ? measureCompactionTrigger(opts.cwd)\n : null;\n\n let effectiveTriggerTokens: number;\n let triggerSource: TriggerSource;\n\n if (measuredTriggerTokens === null) {\n effectiveTriggerTokens = configuredTriggerTokens;\n triggerSource = \"configured\";\n console.error(\n `[context-fill] trigger source: CONFIGURED \u2014 no compaction history for this project yet. ` +\n `measured=none, configured=${configuredTriggerTokens} (${autocompactPct}% of a ${reading.windowSize}-token ` +\n `window) -> using ${effectiveTriggerTokens}.`\n );\n } else if (measuredTriggerTokens <= configuredTriggerTokens) {\n effectiveTriggerTokens = measuredTriggerTokens;\n triggerSource = \"measured\";\n console.error(\n `[context-fill] trigger source: MEASURED \u2014 measured=${measuredTriggerTokens} ` +\n `(minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events), ` +\n `configured=${configuredTriggerTokens} -> using ${effectiveTriggerTokens} (measured, \u2264 configured).`\n );\n } else {\n effectiveTriggerTokens = configuredTriggerTokens;\n triggerSource = \"measured-clamped\";\n console.error(\n `[context-fill] trigger source: MEASURED-CLAMPED \u2014 measured=${measuredTriggerTokens} is HIGHER than ` +\n `configured=${configuredTriggerTokens} (a stale regime this project's history predates) -> ` +\n `using ${effectiveTriggerTokens} (configured, the safer bound).`\n );\n }\n\n const clamp = (name: string, value: number): number => {\n if (value >= 0) return value;\n console.error(\n `[context-fill] ${name} threshold went negative (${value}) for a ` +\n `${reading.windowSize}-token window \u2014 clamping to 0.`\n );\n return 0;\n };\n\n return {\n warmupTokens: clamp(\"warmup\", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.warmup),\n refreshTokens: clamp(\"refresh\", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.refresh),\n immediateTokens: clamp(\"immediate\", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.immediate),\n effectiveTriggerTokens,\n measuredTriggerTokens,\n configuredTriggerTokens,\n autocompactPct,\n triggerSource,\n windowConfirmed,\n };\n}\n\n/**\n * Which named thresholds `usedTokens` has newly crossed, given the ones that\n * have already fired this session. Ascending order (warmup before refresh).\n *\n * A single check can return more than one name \u2014 e.g. a session first\n * observed at 99% of its window crosses warmup and refresh in the same tick,\n * because there was no earlier clean crossing to catch it at. That is the\n * \"fire immediately\" case: the caller enqueues one handover and marks every\n * newly-crossed name fired, rather than waiting for a crossing that already\n * happened.\n */\nexport function crossedThresholds(\n usedTokens: number,\n thresholds: ContextFillThresholds,\n alreadyFired: ThresholdName[]\n): ThresholdName[] {\n const fired = new Set(alreadyFired);\n const ordered: Array<[ThresholdName, number]> = [\n [\"warmup\", thresholds.warmupTokens],\n [\"refresh\", thresholds.refreshTokens],\n ];\n return ordered.filter(([name, tokens]) => !fired.has(name) && usedTokens >= tokens).map(([name]) => name);\n}\n\n/**\n * True once a session is at or above the \"no time left for a clean crossing\"\n * floor (0.985 of the window). Purely informational for callers \u2014 it never\n * gates whether `crossedThresholds` fires (a poll-based check already fires\n * any unfired threshold the moment `usedTokens` reaches it, on whatever tick\n * observes it) \u2014 but it distinguishes \"this crossed on schedule\" from \"this\n * was first observed already almost out of room\", which is worth a different\n * log line and, for a caller that queues work, a higher priority.\n */\nexport function isImmediate(usedTokens: number, thresholds: ContextFillThresholds): boolean {\n return usedTokens >= thresholds.immediateTokens;\n}\n", "/**\n * runtime-paths.ts \u2014 the shared files PAI talks to the running daemon through.\n *\n * Sockets, logs and pidfiles live at fixed absolute paths under /tmp, and every\n * one of them is shared with a *live* daemon. That makes them the same hazard\n * as any other piece of real user state a test can reach: writing one from a\n * test does not fail, it corrupts something already running, silently.\n *\n * The home guard in test/setup-home-guard.ts does not cover them. It works by\n * redirecting HOME, so it protects paths *derived* from the home directory and\n * nothing else \u2014 a hardcoded \"/tmp/pai.sock\" walks straight past it. Verified\n * on 2026-08-01 by snapshotting all five and running the suite: nothing writes\n * to them today. But \"no test does this yet\" is not a property, and the next\n * daemon test to be written would clobber the live socket with nothing to stop\n * it.\n *\n * So each path is read from an environment variable with the historical /tmp\n * value as its default. Behaviour is unchanged for every real invocation; the\n * test setup overrides the variables and the whole class becomes unreachable\n * rather than merely absent, the same way the home guard works.\n *\n * Read at call time, not at module load: the test setup file runs before any\n * module is imported, but resolving these eagerly would still bake in whatever\n * the environment held at import and make the override order matter.\n */\n\n/** IPC socket for the PAI daemon. */\nexport function paiSocketPath(): string {\n return process.env.PAI_SOCKET_PATH ?? \"/tmp/pai.sock\";\n}\n\n/** IPC socket for the AIBroker daemon \u2014 read by PAI, owned by AIBroker. */\nexport function aibrokerSocketPath(): string {\n return process.env.PAI_AIBROKER_SOCKET_PATH ?? \"/tmp/aibroker.sock\";\n}\n\n/** Where the daemon's stdout and stderr are collected. */\nexport function daemonLogPath(): string {\n return process.env.PAI_DAEMON_LOG_PATH ?? \"/tmp/pai-daemon.log\";\n}\n\n/** Pidfile for the running daemon. */\nexport function daemonPidPath(): string {\n return process.env.PAI_DAEMON_PID_PATH ?? \"/tmp/pai-daemon.pid\";\n}\n\n/** Where the launchd scheduler tick writes its output. */\nexport function schedulerLogPath(): string {\n return process.env.PAI_SCHEDULER_LOG_PATH ?? \"/tmp/pai-scheduler.log\";\n}\n\n/**\n * Every runtime path, for the test guard to redirect in one place.\n *\n * Listed here rather than in the guard so that adding a path and forgetting to\n * protect it is not possible: the guard iterates this.\n */\nexport const RUNTIME_PATH_ENV_VARS = [\n \"PAI_SOCKET_PATH\",\n \"PAI_AIBROKER_SOCKET_PATH\",\n \"PAI_DAEMON_LOG_PATH\",\n \"PAI_DAEMON_PID_PATH\",\n \"PAI_SCHEDULER_LOG_PATH\",\n] as const;\n", "/**\n * ipc-client.ts \u2014 IPC client for the PAI Daemon MCP shim\n *\n * PaiClient connects to the Unix Domain Socket served by daemon.ts\n * and forwards tool calls to the daemon. Uses a fresh socket connection per\n * call (connect \u2192 write JSON + newline \u2192 read response line \u2192 parse \u2192 destroy).\n * This keeps the client stateless and avoids connection management complexity.\n *\n * Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).\n */\n\nimport { connect, Socket } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n NotificationConfig,\n NotificationMode,\n NotificationEvent,\n SendResult,\n} from \"../notifications/types.js\";\nimport type { TopicCheckParams, TopicCheckResult } from \"../topics/detector.js\";\nimport type { AutoRouteResult } from \"../session/auto-route.js\";\nimport { paiSocketPath } from \"../runtime-paths.js\";\n\n// ---------------------------------------------------------------------------\n// Protocol types\n// ---------------------------------------------------------------------------\n\n/** Default socket path */\nexport const IPC_SOCKET_PATH = paiSocketPath();\n\n/** Timeout for IPC calls (60 seconds) */\nconst IPC_TIMEOUT_MS = 60_000;\n\ninterface IpcRequest {\n id: string;\n method: string;\n params: Record<string, unknown>;\n}\n\ninterface IpcResponse {\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/**\n * Thin IPC proxy that forwards tool calls to pai-daemon over a Unix\n * Domain Socket. Each call opens a fresh connection, sends one NDJSON request,\n * reads the response, and closes. Stateless and simple.\n */\nexport class PaiClient {\n private readonly socketPath: string;\n\n constructor(socketPath?: string) {\n this.socketPath = socketPath ?? IPC_SOCKET_PATH;\n }\n\n /**\n * Call a PAI tool by name with the given params.\n * Returns the tool result or throws on error.\n *\n * `timeoutMs` overrides the default 60s wait \u2014 for a caller on a hook's\n * critical path (e.g. the threshold-triggered handover enqueue in\n * `cli/commands/session/autosave.ts`) where the actual work happens later,\n * asynchronously, in the daemon's worker loop, and only the cheap\n * enqueue handshake itself should ever be waited on.\n */\n async call(method: string, params: Record<string, unknown>, timeoutMs?: number): Promise<unknown> {\n return this.send(method, params, timeoutMs);\n }\n\n /**\n * Check daemon status.\n */\n async status(): Promise<Record<string, unknown>> {\n const result = await this.send(\"status\", {});\n return result as Record<string, unknown>;\n }\n\n /**\n * Trigger an immediate index run.\n */\n async triggerIndex(): Promise<void> {\n await this.send(\"index_now\", {});\n }\n\n // -------------------------------------------------------------------------\n // Notification methods\n // -------------------------------------------------------------------------\n\n /**\n * Get the current notification config from the daemon.\n */\n async getNotificationConfig(): Promise<{\n config: NotificationConfig;\n activeChannels: string[];\n }> {\n const result = await this.send(\"notification_get_config\", {});\n return result as { config: NotificationConfig; activeChannels: string[] };\n }\n\n /**\n * Patch the notification config on the daemon (and persist to disk).\n */\n async setNotificationConfig(patch: {\n mode?: NotificationMode;\n channels?: Partial<NotificationConfig[\"channels\"]>;\n routing?: Partial<NotificationConfig[\"routing\"]>;\n }): Promise<{ config: NotificationConfig }> {\n const result = await this.send(\"notification_set_config\", patch as Record<string, unknown>);\n return result as { config: NotificationConfig };\n }\n\n /**\n * Send a notification via the daemon (routes to configured channels).\n */\n async sendNotification(payload: {\n event: NotificationEvent;\n message: string;\n title?: string;\n }): Promise<SendResult> {\n const result = await this.send(\"notification_send\", payload as Record<string, unknown>);\n return result as SendResult;\n }\n\n // -------------------------------------------------------------------------\n // Topic detection methods\n // -------------------------------------------------------------------------\n\n /**\n * Check whether the provided context text has drifted to a different project\n * than the session's current routing.\n */\n async topicCheck(params: TopicCheckParams): Promise<TopicCheckResult> {\n const result = await this.send(\"topic_check\", params as unknown as Record<string, unknown>);\n return result as TopicCheckResult;\n }\n\n // -------------------------------------------------------------------------\n // Session routing methods\n // -------------------------------------------------------------------------\n\n /**\n * Automatically detect which project a session belongs to.\n * Tries path match, PAI.md marker walk, then topic detection (if context given).\n */\n async sessionAutoRoute(params: {\n cwd?: string;\n context?: string;\n }): Promise<AutoRouteResult | null> {\n // session_auto_route returns a ToolResult (content array). Extract the text\n // and parse JSON from it.\n const result = await this.send(\"session_auto_route\", params as Record<string, unknown>);\n const toolResult = result as { content?: Array<{ text: string }>; isError?: boolean };\n if (toolResult.isError) return null;\n const text = toolResult.content?.[0]?.text ?? \"\";\n // Text is either JSON (on match) or a human-readable \"no match\" message\n try {\n return JSON.parse(text) as AutoRouteResult;\n } catch {\n return null;\n }\n }\n\n // -------------------------------------------------------------------------\n // Internal transport\n // -------------------------------------------------------------------------\n\n /**\n * Send a single IPC request and wait for the response.\n * Opens a new socket connection per call \u2014 simple and reliable.\n */\n private send(\n method: string,\n params: Record<string, unknown>,\n timeoutMs: number = IPC_TIMEOUT_MS\n ): Promise<unknown> {\n const socketPath = this.socketPath;\n\n return new Promise((resolve, reject) => {\n let socket: Socket | null = null;\n let done = false;\n let buffer = \"\";\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n function finish(error: Error | null, value?: unknown): void {\n if (done) return;\n done = true;\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n try {\n socket?.destroy();\n } catch {\n // ignore\n }\n if (error) {\n reject(error);\n } else {\n resolve(value);\n }\n }\n\n socket = connect(socketPath, () => {\n const request: IpcRequest = {\n id: randomUUID(),\n method,\n params,\n };\n socket!.write(JSON.stringify(request) + \"\\n\");\n });\n\n socket.on(\"data\", (chunk: Buffer) => {\n buffer += chunk.toString();\n const nl = buffer.indexOf(\"\\n\");\n if (nl === -1) return;\n\n const line = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 1);\n\n let response: IpcResponse;\n try {\n response = JSON.parse(line) as IpcResponse;\n } catch {\n finish(new Error(`IPC parse error: ${line}`));\n return;\n }\n\n if (!response.ok) {\n finish(new Error(response.error ?? \"IPC call failed\"));\n } else {\n finish(null, response.result);\n }\n });\n\n socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n finish(\n new Error(\n \"PAI daemon not running. Start it with: pai daemon serve\"\n )\n );\n } else {\n finish(e);\n }\n });\n\n socket.on(\"end\", () => {\n if (!done) {\n finish(new Error(\"IPC connection closed before response\"));\n }\n });\n\n timer = setTimeout(() => {\n finish(new Error(`IPC call timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n });\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;
|
|
3
|
+
"sources": ["../../../src/hooks/ts/lib/worker-session.ts", "../../../src/hooks/ts/session-start/post-compact-inject.ts", "../../../src/session/context-handover-trigger.ts", "../../../src/hooks/ts/lib/context-fill.ts", "../../../src/runtime-paths.ts", "../../../src/daemon/ipc-client.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Worker-session detection.\n *\n * A disposable headless worker (a `claude -p` run started by an orchestrating\n * session, possibly against a different model provider with a different\n * context window) shares the project directory and the hook configuration\n * with the real session that spawned it. Left alone, the hooks treat it as a\n * session in its own right: they inject project context into it, create and\n * rename a numbered session note for it, autosave it, and enqueue a\n * model-written handover for it. Its compactions also land in the project's\n * transcript folder, where they are indistinguishable from a real session's\n * and drag the measured compaction trigger down (observed 2026-09-17: two\n * workers compacting at ~151k tokens pulled a project's trigger from ~784k\n * to ~151k, and the real session's handover fired at ~50k tokens).\n *\n * The launcher marks such sessions with `PAI_WORKER=1`. Every hook that does\n * per-session bookkeeping returns immediately when this predicate is true.\n * Deliberately NOT guarded: the security validator (a worker's shell\n * commands must still be checked) and observability capture.\n */\nexport function isWorkerSession(env: NodeJS.ProcessEnv = process.env): boolean {\n return env.PAI_WORKER === \"1\";\n}\n", "#!/usr/bin/env node\n\n/**\n * post-compact-inject.ts \u2014 SessionStart hook (matcher: \"compact\")\n *\n * Fires AFTER auto/manual compaction. Reads the session state that\n * the PreCompact hook saved to a temp file and outputs it to stdout,\n * which Claude Code injects into the post-compaction context.\n *\n * This is the ONLY way to influence what Claude knows after compaction:\n * PreCompact hooks have no stdout support, but SessionStart does.\n *\n * Flow:\n * PreCompact \u2192 context-compression-hook.ts saves state to /tmp/pai-compact-state-{sessionId}.txt\n * Compaction runs (conversation is summarized)\n * SessionStart(compact) \u2192 THIS HOOK reads that file \u2192 stdout \u2192 context\n */\n\nimport { isWorkerSession } from \"../lib/worker-session.js\";\nimport { readFileSync, existsSync, unlinkSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { resetHandoverTriggerState } from '../../../session/context-handover-trigger.js';\n\ninterface HookInput {\n session_id: string;\n transcript_path?: string;\n cwd?: string;\n hook_event_name: string;\n source?: string;\n}\n\nasync function main() {\n if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping\n let hookInput: HookInput | null = null;\n\n try {\n const decoder = new TextDecoder();\n let input = '';\n const timeoutPromise = new Promise<void>((resolve) => { setTimeout(resolve, 500); });\n const readPromise = (async () => {\n for await (const chunk of process.stdin) {\n input += decoder.decode(chunk, { stream: true });\n }\n })();\n await Promise.race([readPromise, timeoutPromise]);\n if (input.trim()) {\n hookInput = JSON.parse(input) as HookInput;\n }\n } catch {\n // Silently handle input errors\n }\n\n if (!hookInput?.session_id) {\n console.error('post-compact-inject: no session_id, exiting');\n process.exit(0);\n }\n\n // This hook fires exactly once per compaction for this session \u2014 the\n // right moment to reset the handover-trigger marker (confirmed/pending\n // thresholds) so the NEXT context-fill cycle gets its own handover\n // instead of being judged \"already done\" forever. Unconditional: it must\n // run even when no compact-state digest file was found below, since a\n // compaction still happened. Never touches the separate handover CACHE\n // file \u2014 that is what gets injected below and must survive this call.\n // See resetHandoverTriggerState() for the full rationale.\n resetHandoverTriggerState(hookInput.session_id);\n\n // Look for the state file saved by context-compression-hook during PreCompact\n const stateFile = join(tmpdir(), `pai-compact-state-${hookInput.session_id}.txt`);\n\n if (!existsSync(stateFile)) {\n console.error(`post-compact-inject: no state file found at ${stateFile}`);\n process.exit(0);\n }\n\n try {\n const state = readFileSync(stateFile, 'utf-8').trim();\n\n if (state.length > 0) {\n // Output to stdout \u2014 Claude Code injects this into the post-compaction context\n console.log(state);\n console.error(`post-compact-inject: injected ${state.length} chars of session state`);\n }\n\n // Clean up the temp file\n unlinkSync(stateFile);\n console.error(`post-compact-inject: cleaned up ${stateFile}`);\n } catch (err) {\n console.error(`post-compact-inject: error reading state file: ${err}`);\n }\n\n process.exit(0);\n}\n\nmain().catch(() => {\n process.exit(0);\n});\n", "/**\n * context-handover-trigger.ts \u2014 decide whether THIS check is the moment a\n * session should get its threshold-triggered pre-compaction handover, and\n * enqueue it with the daemon if so.\n *\n * Called from the same live hooks that already drive the AG2 rolling\n * autosave (UserPromptSubmit, PostToolUse) \u2014 see\n * cli/commands/session/autosave.ts. Reuses the context-fill reading and the\n * derived thresholds from hooks/ts/lib/context-fill.ts; this module only\n * adds the \"have we already fired this one\" bookkeeping and the enqueue.\n *\n * BUG FIXED (found in live use, session 77084e72-...): a threshold used to\n * be marked fired the moment the ENQUEUE call succeeded \u2014 recording intent,\n * not outcome. When the daemon worker then failed or was restarted mid-spawn\n * (observed: a daemon restart 90s after the enqueue), no handover cache was\n * ever written, but the marker already said \"done\" \u2014 so the session\n * compacted with the mechanical scrape only, and would never have retried,\n * ever, for that session. A threshold is now marked CONFIRMED only after a\n * handover cache file actually appears; an enqueue with no cache to show for\n * it within PENDING_TIMEOUT_MS is treated as failed and retried.\n *\n * Never throws. A daemon that isn't running, a stale reading, an unknown\n * fill \u2014 every one of those is a reason to do nothing this check and try\n * again next time, not a reason to interrupt the hook that called this.\n */\n\nimport { existsSync, readFileSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\nimport {\n getContextFill,\n contextFillThresholds,\n crossedThresholds,\n isImmediate,\n transcriptModelFamily,\n type ContextFillReading,\n type ThresholdName,\n} from \"../hooks/ts/lib/context-fill.js\";\nimport { isWorkerSession } from \"../hooks/ts/lib/worker-session.js\";\nimport { readContextHandoverCache, type ContextHandoverCache } from \"../hooks/ts/lib/context-handover-cache.js\";\nimport { PaiClient } from \"../daemon/ipc-client.js\";\n\n/** The enqueue only needs to hand the item to the daemon's queue file \u2014 the\n * actual LLM call happens later, asynchronously, in the worker loop. A\n * short bound keeps a rare (at most twice per session) daemon hiccup from\n * making the calling hook wait anywhere near as long as a normal IPC call\n * is allowed to. */\nconst ENQUEUE_TIMEOUT_MS = 2_000;\n\n/**\n * How long to wait for a handover cache to appear after an enqueue before\n * treating it as failed and retrying. Generous over the worker's own sonnet\n * timeout (120s, see session-summary-worker.ts's CLAUDE_TIMEOUT_MS) plus\n * queue latency, so a slow-but-working run isn't retried out from under\n * itself \u2014 and short enough that a genuinely dead attempt (a daemon restart\n * mid-spawn, exactly what happened in the live failure this fixes) is\n * retried well before the next compaction, not \"never\".\n */\nconst PENDING_TIMEOUT_MS = 5 * 60 * 1000;\n\n/** Both thresholds confirmed \u2014 nothing left to check for the rest of the session. */\nconst ALL_THRESHOLDS: ThresholdName[] = [\"warmup\", \"refresh\"];\n\n// ---------------------------------------------------------------------------\n// Persisted state \u2014 outcome-based, not intent-based\n// ---------------------------------------------------------------------------\n\n/**\n * `confirmed`: thresholds whose handover was actually verified written.\n * `pending`: an enqueue attempt awaiting outcome confirmation \u2014 cleared\n * either when a fresher cache appears (success) or when PENDING_TIMEOUT_MS\n * elapses with nothing to show for it (treated as failed; retried).\n *\n * A file in the OLD shape (`{fired: [...]}`, no `confirmed` key \u2014 exactly\n * what the live failure left behind) parses with `confirmed` defaulting to\n * `[]` and `pending` to `null`: it is treated as \"nothing confirmed yet\",\n * which self-heals the stuck session that bug left behind rather than\n * requiring a manual cleanup step.\n */\nexport interface HandoverTriggerState {\n confirmed: ThresholdName[];\n pending: { thresholds: ThresholdName[]; enqueuedAt: string } | null;\n}\n\nexport function triggerStatePath(sessionId: string): string {\n return join(tmpdir(), `pai-context-handover-fired-${sessionId}.json`);\n}\n\nexport function loadTriggerState(sessionId: string): HandoverTriggerState {\n const path = triggerStatePath(sessionId);\n if (!existsSync(path)) return { confirmed: [], pending: null };\n try {\n const raw = JSON.parse(readFileSync(path, \"utf-8\")) as Partial<HandoverTriggerState>;\n return {\n confirmed: Array.isArray(raw.confirmed) ? (raw.confirmed as ThresholdName[]) : [],\n pending: raw.pending && Array.isArray(raw.pending.thresholds) && typeof raw.pending.enqueuedAt === \"string\"\n ? raw.pending\n : null,\n };\n } catch {\n return { confirmed: [], pending: null };\n }\n}\n\nfunction saveTriggerState(sessionId: string, state: HandoverTriggerState): void {\n try {\n writeFileSync(triggerStatePath(sessionId), JSON.stringify(state), \"utf-8\");\n } catch {\n // Best-effort \u2014 a missing write only costs a possible re-attempt later.\n }\n}\n\n/**\n * Reset a session's fired/confirmed marker after a compaction, so the next\n * fill cycle gets its own handover instead of being judged against\n * thresholds this session already used up. Deliberately touches ONLY the\n * marker file (`triggerStatePath`) \u2014 never the handover cache\n * (`context-handover-cache.ts`), which is what actually gets injected at\n * the compaction boundary and must survive until that injection reads it.\n *\n * WHY THIS EXISTS (do not \"simplify\" it away): without this reset,\n * `confirmed` was permanent for the lifetime of a session id \u2014 nothing\n * anywhere ever cleared it. A session observed live on 2026-09-17\n * compacted once, both thresholds got confirmed, and every check for the\n * rest of that session's life then hit the `confirmed` short-circuit at\n * the top of `checkAndEnqueueContextHandover` and returned immediately \u2014\n * even after a SECOND compaction, ~15 hours and a full context refill\n * later, whose injected \"handover\" was still the first one, generated\n * long before, and which said in its own text that nothing had changed\n * since it. A long-lived session compacts more than once; each compaction\n * starts a new fill cycle and deserves its own handover attempt.\n *\n * Call this exactly once per compaction, from the post-compact SessionStart\n * hook that already runs at that moment for other cleanup \u2014 see\n * post-compact-inject.ts.\n *\n * If a `pending` enqueue was outstanding at reset time, it is simply\n * dropped along with the rest of the marker \u2014 the in-flight daemon job\n * itself is NOT cancelled and will still write its cache entry when it\n * finishes (harmless: cache files are read by generation time, not by\n * whether a marker was watching for them). Because the marker is gone,\n * the next check has no pending entry to resolve, so it does not confirm\n * against that stray cache \u2014 it simply runs a fresh crossing check. So\n * this cannot double-enqueue: the only thing that ever calls `enqueue`\n * again is a genuinely fresh threshold crossing after the reset, of which\n * there is exactly one code path.\n *\n * Never throws: a session with no marker file (fresh session, or one\n * already reset) is a plain no-op, not an error.\n */\nexport function resetHandoverTriggerState(sessionId: string): void {\n const path = triggerStatePath(sessionId);\n try {\n if (existsSync(path)) unlinkSync(path);\n } catch {\n // Best-effort \u2014 see saveTriggerState above. Worst case on failure: the\n // next compaction re-serves one stale handover, exactly the bug this\n // function fixes, not a corrupted or crashed session.\n }\n}\n\n// ---------------------------------------------------------------------------\n// Enqueue\n// ---------------------------------------------------------------------------\n\nexport interface HandoverTriggerInput {\n sessionId: string;\n cwd: string;\n transcriptPath?: string;\n}\n\nasync function enqueueContextHandover(payload: {\n sessionId: string;\n cwd: string;\n transcriptPath?: string;\n threshold: ThresholdName;\n urgent: boolean;\n}): Promise<void> {\n const client = new PaiClient();\n await client.call(\n \"work_queue_enqueue\",\n {\n type: \"context-handover\",\n // Below session-end (2), above session-summary (4) \u2014 this is rarer\n // than session-summary (at most twice per session) and time-sensitive\n // (it exists specifically to beat the compaction it is racing).\n priority: 3,\n payload: {\n sessionId: payload.sessionId,\n cwd: payload.cwd,\n transcriptPath: payload.transcriptPath,\n threshold: payload.threshold,\n urgent: payload.urgent,\n },\n },\n ENQUEUE_TIMEOUT_MS\n );\n}\n\n// ---------------------------------------------------------------------------\n// Dependency injection \u2014 for testing without a real daemon or filesystem\n// ---------------------------------------------------------------------------\n\nexport interface HandoverTriggerDeps {\n getReading: (input: HandoverTriggerInput) => ContextFillReading;\n loadState: (sessionId: string) => HandoverTriggerState;\n saveState: (sessionId: string, state: HandoverTriggerState) => void;\n readCache: (sessionId: string) => ContextHandoverCache | null;\n enqueue: typeof enqueueContextHandover;\n now: () => number;\n}\n\nconst defaultDeps: HandoverTriggerDeps = {\n getReading: (input) =>\n getContextFill({ sessionId: input.sessionId, transcriptPath: input.transcriptPath }),\n loadState: loadTriggerState,\n saveState: saveTriggerState,\n readCache: readContextHandoverCache,\n enqueue: enqueueContextHandover,\n now: () => Date.now(),\n};\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\nexport interface HandoverTriggerResult {\n /** Newly enqueued this check \u2014 an attempt, not yet a confirmed outcome. */\n attempted: ThresholdName[];\n /** Newly CONFIRMED this check \u2014 a handover cache was actually verified. */\n confirmed: ThresholdName[];\n}\n\nconst NOTHING: HandoverTriggerResult = { attempted: [], confirmed: [] };\n\n/**\n * Check this session's current context fill against the derived thresholds,\n * confirm or retry any in-flight attempt, and enqueue a context-handover job\n * for any newly-crossed threshold. Never marks a threshold done on the\n * strength of the enqueue call alone \u2014 only a verified cache file does that.\n *\n * A single check that crosses both warmup and refresh in one jump (a\n * session first observed already close to compaction) enqueues ONE job,\n * tagged with the more urgent of the two, and \u2014 once confirmed \u2014 marks both\n * done together, since one model-written handover already covers what\n * either alone would have.\n */\nexport async function checkAndEnqueueContextHandover(\n input: HandoverTriggerInput,\n deps: HandoverTriggerDeps = defaultDeps\n): Promise<HandoverTriggerResult> {\n // A disposable worker session gets no handover: nobody resumes it, and its\n // model may not even be the platform's (a different window, a different\n // provider's bill). Two signals, either suffices: the launcher's env mark,\n // or a transcript whose last assistant turn came from a non-Claude model.\n if (isWorkerSession()) {\n console.error(`[context-handover-trigger] session ${input.sessionId}: worker session (PAI_WORKER=1) \u2014 no handover.`);\n return NOTHING;\n }\n if (input.transcriptPath && transcriptModelFamily(input.transcriptPath) === \"foreign\") {\n console.error(`[context-handover-trigger] session ${input.sessionId}: transcript written by a non-Claude model \u2014 no handover.`);\n return NOTHING;\n }\n\n let state = deps.loadState(input.sessionId);\n if (ALL_THRESHOLDS.every((t) => state.confirmed.includes(t))) return NOTHING;\n\n // Thresholds confirmed DURING this check (from the pending-resolution step\n // below) \u2014 carried through every return path so a confirm that falls\n // through to a fresh crossing-check doesn't get silently dropped.\n let confirmedThisCheck: ThresholdName[] = [];\n\n // -------------------------------------------------------------------\n // Resolve any in-flight attempt first \u2014 confirm, keep waiting, or\n // time it out and clear it for retry. This is the outcome check: a\n // handover cache generated AFTER the enqueue is proof the worker ran\n // and actually wrote something, not just that the enqueue call itself\n // succeeded.\n // -------------------------------------------------------------------\n if (state.pending) {\n const cache = deps.readCache(input.sessionId);\n const cacheIsFromThisAttempt =\n cache !== null && Date.parse(cache.generatedAt) > Date.parse(state.pending.enqueuedAt);\n\n if (cacheIsFromThisAttempt) {\n const newlyConfirmed = state.pending.thresholds.filter((t) => !state.confirmed.includes(t));\n state = { confirmed: [...state.confirmed, ...newlyConfirmed], pending: null };\n deps.saveState(input.sessionId, state);\n confirmedThisCheck = newlyConfirmed;\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: confirmed [${newlyConfirmed.join(\", \")}] ` +\n `\u2014 handover cache verified (generated ${cache!.generatedAt}).`\n );\n if (ALL_THRESHOLDS.every((t) => state.confirmed.includes(t))) {\n return { attempted: [], confirmed: confirmedThisCheck };\n }\n // Fall through \u2014 a session that jumped straight to refresh-confirmed\n // territory may already have crossed further; keep checking below.\n } else if (deps.now() - Date.parse(state.pending.enqueuedAt) < PENDING_TIMEOUT_MS) {\n // Still within the window \u2014 no news is not yet failure.\n return NOTHING;\n } else {\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: pending [${state.pending.thresholds.join(\", \")}] ` +\n `timed out after ${PENDING_TIMEOUT_MS}ms with no handover cache written \u2014 ` +\n `treating as FAILED and retrying (daemon worker likely died or was restarted mid-run).`\n );\n state = { ...state, pending: null };\n deps.saveState(input.sessionId, state);\n }\n }\n\n const reading = deps.getReading(input);\n if (reading.status !== \"ok\" || reading.usedTokens === null) {\n return { attempted: [], confirmed: confirmedThisCheck };\n }\n\n const thresholds = contextFillThresholds(reading, process.env, { cwd: input.cwd });\n if (!thresholds.windowConfirmed) {\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: window size not confirmed \u2014 ` +\n `using the assumed default (${reading.windowSize} tokens) rather than a reading Claude Code reported.`\n );\n }\n\n const newlyCrossed = crossedThresholds(reading.usedTokens, thresholds, state.confirmed);\n if (newlyCrossed.length === 0) return { attempted: [], confirmed: confirmedThisCheck };\n\n const urgent = isImmediate(reading.usedTokens, thresholds);\n const mostUrgent = newlyCrossed[newlyCrossed.length - 1];\n\n try {\n await deps.enqueue({\n sessionId: input.sessionId,\n cwd: input.cwd,\n transcriptPath: input.transcriptPath,\n threshold: mostUrgent,\n urgent,\n });\n } catch (err) {\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: enqueue FAILED \u2014 will retry next check: ${err}`\n );\n // Nothing persisted \u2014 next check sees no pending and tries again.\n return { attempted: [], confirmed: confirmedThisCheck };\n }\n\n state = { ...state, pending: { thresholds: newlyCrossed, enqueuedAt: new Date(deps.now()).toISOString() } };\n deps.saveState(input.sessionId, state);\n console.error(\n `[context-handover-trigger] session ${input.sessionId}: enqueued [${newlyCrossed.join(\", \")}] ` +\n `at ${reading.usedTokens} tokens (trigger=${thresholds.effectiveTriggerTokens}, ` +\n `triggerSource=${thresholds.triggerSource}, autocompactPct=${thresholds.autocompactPct}, urgent=${urgent}) ` +\n `\u2014 awaiting outcome confirmation before marking done.`\n );\n\n return { attempted: newlyCrossed, confirmed: confirmedThisCheck };\n}\n", "/**\n * context-fill.ts \u2014 \"how full is this session's context window, right now?\"\n *\n * No hook payload carries this number directly (verified: PreCompact,\n * UserPromptSubmit and PostToolUse stdin never include `context_window`).\n * Two sources can reconstruct it, in order of preference:\n *\n * 1. STATUSLINE STATE FILE \u2014 statusline-command.sh receives the exact\n * figure from Claude Code (`.context_window.used_percentage` /\n * `.context_window.context_window_size`) on every render and persists\n * it to `${TMPDIR}/pai-context-<session_id>.json`. This is authoritative\n * but depends on the status line having rendered recently \u2014 a session\n * whose terminal isn't drawing a status line (headless, backgrounded)\n * leaves this file missing or stale.\n *\n * 2. TRANSCRIPT USAGE \u2014 every `message.usage` entry in the session's own\n * .jsonl transcript already reports the token accounting for that one\n * API call: `input_tokens + cache_read_input_tokens +\n * cache_creation_input_tokens` on the MOST RECENT such entry *is* the\n * current context fill, because each request resends the full context.\n * This is why it must be the last entry's fields summed, never a sum\n * across entries \u2014 summing across entries is a running token-spend\n * total, not a fill reading (see calculateSessionTokens, a different\n * metric answering a different question).\n *\n * When neither source is available, the answer is UNKNOWN, not zero. A zero\n * reads as \"plenty of room\" and would silently suppress every downstream\n * decision that depends on this number (the compaction handover chief among\n * them) for the entire session.\n */\n\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { homedir, tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\n/** Fallback window size when nothing on hand reports one. Matches the\n * default statusline-command.sh already falls back to. */\nexport const DEFAULT_CONTEXT_WINDOW = 200_000;\n\n/** How many trailing lines of the transcript to scan for a usage entry.\n * A single turn is rarely more than a handful of JSONL lines (assistant\n * text + tool_use/tool_result pairs), so 40 comfortably covers one turn\n * even a busy one, without reading the whole file on every check. */\nconst TRANSCRIPT_TAIL_LINES = 40;\n\n/** A statusline reading older than this is treated as not there at all \u2014\n * a stale number is worse than none, because it looks confident. */\nconst STATUSLINE_STALE_MS = 5 * 60 * 1000; // 5 minutes\n\nexport type ContextFillSource = \"statusline\" | \"transcript\" | \"unknown\";\n\nexport interface ContextFillReading {\n status: \"ok\" | \"unknown\";\n /** Tokens currently occupying the context window, or null when unknown. */\n usedTokens: number | null;\n windowSize: number;\n /** usedTokens / windowSize, or null when unknown. Not clamped to [0,1] \u2014\n * callers that need a clamped display value do that themselves so the\n * raw reading (which can legitimately exceed 1 for a moment) is never\n * silently rewritten here. */\n fraction: number | null;\n source: ContextFillSource;\n}\n\nfunction unknownReading(windowSize: number): ContextFillReading {\n return { status: \"unknown\", usedTokens: null, windowSize, fraction: null, source: \"unknown\" };\n}\n\n// ---------------------------------------------------------------------------\n// Source 1 \u2014 statusline state file\n// ---------------------------------------------------------------------------\n\nexport function statuslineStateFilePath(sessionId: string): string {\n return join(tmpdir(), `pai-context-${sessionId}.json`);\n}\n\ninterface StatuslineState {\n used_percentage?: number;\n context_window_size?: number;\n session_id?: string;\n timestamp?: number;\n}\n\n/**\n * Read the state file statusline-command.sh writes on every render.\n * Returns null when the file is missing, unparsable, or older than\n * STATUSLINE_STALE_MS \u2014 all three mean \"not a source right now\".\n */\nexport function readStatuslineFill(sessionId: string, now = Date.now()): ContextFillReading | null {\n if (!sessionId) return null;\n const path = statuslineStateFilePath(sessionId);\n if (!existsSync(path)) return null;\n\n let raw: StatuslineState;\n try {\n raw = JSON.parse(readFileSync(path, \"utf-8\"));\n } catch {\n return null;\n }\n\n if (typeof raw.timestamp !== \"number\" || now - raw.timestamp > STATUSLINE_STALE_MS) {\n return null; // stale \u2014 fall through to the transcript source\n }\n if (typeof raw.used_percentage !== \"number\" || typeof raw.context_window_size !== \"number\") {\n return null;\n }\n\n const windowSize = raw.context_window_size;\n const usedTokens = Math.round((raw.used_percentage / 100) * windowSize);\n return {\n status: \"ok\",\n usedTokens,\n windowSize,\n fraction: raw.used_percentage / 100,\n source: \"statusline\",\n };\n}\n\n// ---------------------------------------------------------------------------\n// Source 2 \u2014 transcript usage (fallback)\n// ---------------------------------------------------------------------------\n\ninterface UsageEntry {\n input_tokens?: number;\n cache_read_input_tokens?: number;\n cache_creation_input_tokens?: number;\n}\n\n/**\n * Sum the three context-carrying fields of ONE usage object \u2014 never across\n * usage objects. A single `message.usage` already reports what that one API\n * call sent as context; adding another entry's numbers to it produces a\n * cumulative spend figure, not a fill reading, and can exceed the window\n * size many times over on a long session (the exact defect this helper\n * exists to not repeat \u2014 see the PreCompact header fix in the same change).\n */\nfunction usageTotal(usage: UsageEntry): number {\n return (\n (usage.input_tokens || 0) +\n (usage.cache_read_input_tokens || 0) +\n (usage.cache_creation_input_tokens || 0)\n );\n}\n\n/**\n * Derive context fill from the tail of a .jsonl transcript: the most recent\n * `message.usage` entry, read from the end backwards so a trailing line with\n * no usage field (a plain text turn, a tool_result) doesn't hide one just\n * before it.\n */\nexport function contextFillFromTranscript(\n transcriptPath: string,\n windowSize = DEFAULT_CONTEXT_WINDOW\n): ContextFillReading {\n if (!transcriptPath || !existsSync(transcriptPath)) return unknownReading(windowSize);\n\n let raw: string;\n try {\n raw = readFileSync(transcriptPath, \"utf-8\");\n } catch {\n return unknownReading(windowSize);\n }\n\n const lines = raw.trim().split(\"\\n\").filter((l) => l.trim());\n const tail = lines.slice(-TRANSCRIPT_TAIL_LINES);\n\n for (let i = tail.length - 1; i >= 0; i--) {\n let entry: { message?: { usage?: UsageEntry } };\n try {\n entry = JSON.parse(tail[i]);\n } catch {\n continue;\n }\n const usage = entry?.message?.usage;\n if (usage && typeof usage === \"object\") {\n const usedTokens = usageTotal(usage);\n return {\n status: \"ok\",\n usedTokens,\n windowSize,\n fraction: usedTokens / windowSize,\n source: \"transcript\",\n };\n }\n }\n\n return unknownReading(windowSize);\n}\n\n// ---------------------------------------------------------------------------\n// Precedence \u2014 statusline (fresh) > transcript > unknown\n// ---------------------------------------------------------------------------\n\nexport function getContextFill(\n input: { sessionId?: string; transcriptPath?: string; windowSize?: number },\n now = Date.now()\n): ContextFillReading {\n const windowSize = input.windowSize ?? DEFAULT_CONTEXT_WINDOW;\n\n if (input.sessionId) {\n const fromStatusline = readStatuslineFill(input.sessionId, now);\n if (fromStatusline) return fromStatusline;\n }\n\n if (input.transcriptPath) {\n return contextFillFromTranscript(input.transcriptPath, windowSize);\n }\n\n return unknownReading(windowSize);\n}\n\n// ---------------------------------------------------------------------------\n// Display helper \u2014 clamp-and-flag rather than ever print an absurd number\n// ---------------------------------------------------------------------------\n\nexport interface FillDisplay {\n /** e.g. \"63k\" or \"unknown\". Never a number larger than the window. */\n text: string;\n /** True when the raw reading exceeded the window size and had to be\n * clamped \u2014 that reading was a bug, not a fill, and callers may want to\n * log it even though the displayed text is already safe. */\n flagged: boolean;\n}\n\nexport function formatContextFill(reading: ContextFillReading): FillDisplay {\n if (reading.status !== \"ok\" || reading.usedTokens === null) {\n return { text: \"unknown\", flagged: false };\n }\n\n let used = reading.usedTokens;\n let flagged = false;\n if (used > reading.windowSize) {\n flagged = true;\n used = reading.windowSize;\n }\n\n const text = used > 1000 ? `${Math.round(used / 1000)}k` : String(used);\n return { text: flagged ? `${text}+ (clamped \u2014 reading exceeded window)` : text, flagged };\n}\n\n// ---------------------------------------------------------------------------\n// Threshold-triggered handover \u2014 WHEN to warm up / refresh the model-written\n// summary ahead of a compaction.\n// ---------------------------------------------------------------------------\n//\n// THE CONFIGURED VALUE DOES NOT PREDICT THE TRIGGER. CLAUDE_AUTOCOMPACT_\n// PCT_OVERRIDE was 80 throughout, on this machine, across BOTH of the\n// following regimes \u2014 the same configured number, two different truths:\n//\n// 2026-08-16 .. 2026-09-10 preTokens ~ 993,096 \u2013 1,002,500 (~100% of a 1M window)\n// 2026-09-12 .. 2026-09-15 preTokens ~ 782,981 \u2013 791,995 (~78-79% of a 1M window)\n//\n// A threshold derived only from the configured override was wrong for the\n// second regime and would be wrong again the next time it moves \u2014 the\n// config is not ground truth, it is what Claude Code claims it will honor.\n//\n// GROUND TRUTH IS ON DISK ALREADY: every real compaction writes a\n// `compact_boundary` system event to the transcript with\n// `compactMetadata.preTokens` \u2014 the platform's own count of context tokens\n// immediately before IT compacted. `measureCompactionTrigger` scans a\n// project's own transcripts for these and takes the MINIMUM of the most\n// recent three (minimum, not mean: warm-up must fire before the earliest\n// plausible boundary, not the average one). A project that has compacted\n// before learns its own trigger and needs no code change when the regime\n// moves again \u2014 the same derivation produced 998,508 pre-09-12 and ~784k\n// after, from one unchanged formula.\n//\n// The configured chain \u2014 env override, then a default \u2014 is the fallback,\n// used ONLY when a project has no compaction history yet:\n//\n// effectiveTrigger = measured ?? (windowSize * (override ?? 80) / 100)\n//\n// DEFAULT IS 80, NOT 100, WHEN NOTHING IS KNOWN. 100 was considered \u2014 it\n// matches the FIRST regime above \u2014 and rejected: the costs are asymmetric.\n// Warming up too early wastes one summary; cheap, invisible. Warming up too\n// late produces exactly the degraded successor session this feature exists\n// to prevent. 80 is the conservative default until a project has its own\n// measured history to correct it.\n//\n// Margins below the effective trigger are absolute tokens, sized against the\n// largest single-turn context jump measured: 62,383 tokens (517,952 \u2192\n// 580,335, about 27 seconds). warmup sits 100k below the trigger \u2014\n// comfortably more than that jump, so a session cannot leap clean over\n// warmup straight into a compaction in one turn; refresh at 40k below\n// catches a session that sat above warmup for a while; fireNow at 15k below\n// is \"no time left for a clean crossing\" and fires immediately rather than\n// waiting for one.\nexport const DEFAULT_AUTOCOMPACT_PCT = 80;\n\n/** How many of the most recent compact_boundary events to consider, and to\n * take the minimum of. */\nconst MEASURED_TRIGGER_SAMPLE_SIZE = 3;\n\nexport const THRESHOLD_MARGIN_TOKENS = {\n warmup: 100_000,\n refresh: 40_000,\n immediate: 15_000,\n} as const;\n\n// ---------------------------------------------------------------------------\n// Measured trigger \u2014 scan a project's own transcript history for the\n// platform's own compact_boundary events, ground truth over configuration.\n// ---------------------------------------------------------------------------\n//\n// Deliberately re-implements the tiny bit of path encoding it needs (below)\n// rather than importing project-utils/paths.ts's encodePath: that module\n// chain ends in pai-paths.ts, which calls process.exit(1) if PAI_DIR does\n// not resolve to an existing directory. context-fill.ts is specifically the\n// module a hook falls back on when its environment is unreliable \u2014 pulling\n// in a dependency that can kill the process on import would defeat that.\n// Claude Code's transcript directory is a fixed, home-relative convention\n// (~/.claude/projects/<encoded-cwd>/), not something PAI_DIR governs, so\n// resolving it directly here is also just the more correct dependency, not\n// only the safer one.\n\n/** Real default \u2014 overridable per-call so tests never touch the user's\n * actual ~/.claude/projects/ (a live directory this very session writes to). */\nexport const CLAUDE_PROJECTS_DIR = join(homedir(), \".claude\", \"projects\");\n\nfunction encodeProjectPath(cwd: string): string {\n return cwd.replace(/[/\\s.-]/g, \"-\");\n}\n\nexport interface CompactBoundarySample {\n preTokens: number;\n timestampMs: number;\n /** ISO string, kept alongside timestampMs so callers can print it. */\n timestamp: string;\n /** The event's own uuid when present \u2014 the dedup key. Claude Code's\n * `sessions/` archive directory mirrors the live project transcript, so\n * the SAME compact_boundary event can legitimately appear in two files;\n * without deduping by identity, \"most recent three\" can silently become\n * three copies of one event, which is a stale reading wearing a\n * plausible-looking sample size. */\n uuid?: string;\n}\n\n/** Every `.jsonl` transcript belonging to a project \u2014 top-level (the live\n * file) and `sessions/` (Claude Code's archive, which mirrors it). No file\n * is excluded and no ordering is applied here: ordering by EVENT\n * timestamp, not by file mtime, is the whole point (see\n * readCompactBoundarySamples) \u2014 a file's mtime does not reliably track\n * which events inside it are recent, and pre-filtering by mtime is exactly\n * what caused this function to return a stale, pre-regime-change trigger\n * on real data. */\nfunction listProjectTranscripts(cwd: string, projectsDir: string): string[] {\n const projectDir = join(projectsDir, encodeProjectPath(cwd));\n if (!existsSync(projectDir)) return [];\n\n const paths: string[] = [];\n const collect = (dir: string): void => {\n if (!existsSync(dir)) return;\n let entries: string[];\n try {\n entries = readdirSync(dir);\n } catch {\n return;\n }\n for (const entry of entries) {\n if (entry.endsWith(\".jsonl\")) paths.push(join(dir, entry));\n }\n };\n collect(projectDir);\n collect(join(projectDir, \"sessions\"));\n return paths;\n}\n\n/** Placeholder model id the platform writes on synthetic assistant turns. */\nconst SYNTHETIC_MODEL = \"<synthetic>\";\n\n/**\n * Which family of model wrote a transcript. \"claude\" is the platform's own;\n * \"foreign\" is any other provider routed through the same CLI (a different\n * context window, so its compactions say nothing about ours); \"unknown\" is\n * no usable model field at all.\n */\nexport type TranscriptModelFamily = \"claude\" | \"foreign\" | \"unknown\";\n\nexport function modelFamily(model: string | null | undefined): TranscriptModelFamily {\n if (typeof model !== \"string\" || model === \"\" || model === SYNTHETIC_MODEL) return \"unknown\";\n return model.startsWith(\"claude-\") ? \"claude\" : \"foreign\";\n}\n\n/**\n * The family of the LAST assistant model in a transcript \u2014 scanned from the\n * end so a long transcript costs one read and a few lines of parsing.\n * Unreadable or model-less transcripts are \"unknown\", never \"foreign\":\n * the callers that skip work on \"foreign\" must not skip it on doubt.\n */\nexport function transcriptModelFamily(path: string): TranscriptModelFamily {\n let raw: string;\n try {\n raw = readFileSync(path, \"utf-8\");\n } catch {\n return \"unknown\";\n }\n const lines = raw.split(\"\\n\");\n for (let i = lines.length - 1; i >= 0; i--) {\n const line = lines[i];\n if (!line.includes('\"model\"')) continue;\n try {\n const entry = JSON.parse(line) as { type?: string; message?: { model?: unknown } };\n if (entry.type !== \"assistant\") continue;\n const model = entry.message?.model;\n if (typeof model === \"string\" && model !== SYNTHETIC_MODEL) return modelFamily(model);\n } catch {\n continue;\n }\n }\n return \"unknown\";\n}\n\n/**\n * Every DISTINCT compact_boundary sample found across ALL of a project's\n * transcripts (live + archived), newest first by the event's OWN timestamp\n * \u2014 never by which file it came from or that file's mtime. Deduplicated by\n * the event's uuid (falling back to a timestamp+preTokens key for the rare\n * line with no uuid) so an event mirrored into `sessions/` is counted once.\n */\nfunction readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBoundarySample[] {\n const byKey = new Map<string, CompactBoundarySample>();\n\n for (const path of listProjectTranscripts(cwd, projectsDir)) {\n let raw: string;\n try {\n raw = readFileSync(path, \"utf-8\");\n } catch {\n continue;\n }\n\n // The model governing each sample: the most recent assistant\n // `message.model` seen in this file before the compact_boundary. A\n // transcript written by a non-Claude model (a headless worker on another\n // provider, with a different context window) compacts at a different\n // size and must not shape THIS project's trigger \u2014 two such workers\n // compacting at ~151k pulled a real project's trigger from ~784k to\n // ~151k. Samples with no model seen yet are kept: older transcripts\n // may lack the field.\n let lastModel: string | null = null;\n let foreignDiscards = 0;\n\n for (const line of raw.split(\"\\n\")) {\n if (!line.trim()) continue;\n let entry: {\n type?: string;\n subtype?: string;\n timestamp?: string;\n uuid?: string;\n compactMetadata?: { preTokens?: number };\n message?: { model?: unknown };\n };\n try {\n entry = JSON.parse(line);\n } catch {\n continue;\n }\n if (entry.type === \"assistant\") {\n const model = entry.message?.model;\n if (typeof model === \"string\" && model !== SYNTHETIC_MODEL) lastModel = model;\n continue;\n }\n if (entry.type !== \"system\" || entry.subtype !== \"compact_boundary\") continue;\n const preTokens = entry.compactMetadata?.preTokens;\n if (typeof preTokens !== \"number\" || !Number.isFinite(preTokens)) continue;\n const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;\n if (!Number.isFinite(timestampMs)) continue;\n if (modelFamily(lastModel) === \"foreign\") {\n foreignDiscards++;\n continue;\n }\n\n const key = entry.uuid ?? `${timestampMs}:${preTokens}`;\n if (!byKey.has(key)) {\n byKey.set(key, { preTokens, timestampMs, timestamp: entry.timestamp!, uuid: entry.uuid });\n }\n }\n\n if (foreignDiscards > 0) {\n console.error(\n `[context-fill] ignored ${foreignDiscards} compaction sample(s) from a ` +\n `non-Claude transcript (model=${lastModel}): ${path}`\n );\n }\n }\n\n return [...byKey.values()].sort((a, b) => b.timestampMs - a.timestampMs);\n}\n\n/**\n * The most recent MEASURED_TRIGGER_SAMPLE_SIZE distinct compact_boundary\n * events for a project, newest first \u2014 exposed on its own (not just the\n * derived minimum) so the number `measureCompactionTrigger` returns is\n * checkable: print these and the timestamps prove which three events\n * produced it, rather than asking for trust.\n */\nexport function selectedCompactionSamples(\n cwd: string,\n projectsDir: string = CLAUDE_PROJECTS_DIR\n): CompactBoundarySample[] {\n if (!cwd) return [];\n return readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);\n}\n\n/**\n * The measured compaction trigger for a project, or null when it has no\n * compaction history yet (a brand-new project, or one whose transcripts\n * this process cannot read). Minimum of the most recent\n * MEASURED_TRIGGER_SAMPLE_SIZE DISTINCT compact_boundary events, ordered by\n * the events' own timestamps across every transcript the project has\n * (live and archived) \u2014 see the module comment above for why minimum, not\n * mean, and readCompactBoundarySamples for why \"distinct\" and \"own\n * timestamp\" both matter (a file-mtime-ordered, non-deduplicated version of\n * this returned a stale pre-regime-change trigger on real project data).\n */\nexport function measureCompactionTrigger(\n cwd: string,\n projectsDir: string = CLAUDE_PROJECTS_DIR\n): number | null {\n const samples = selectedCompactionSamples(cwd, projectsDir);\n if (samples.length === 0) return null;\n const trigger = Math.min(...samples.map((s) => s.preTokens));\n console.error(\n `[context-fill] measured trigger for ${cwd}: ${trigger} (minimum of ` +\n samples.map((s) => `${s.preTokens}@${s.timestamp}`).join(\", \") + \")\"\n );\n return trigger;\n}\n\n/**\n * Read CLAUDE_AUTOCOMPACT_PCT_OVERRIDE from the environment. Absent \u2192\n * DEFAULT_AUTOCOMPACT_PCT. Present but not a finite number in (0, 100] \u2192\n * also DEFAULT_AUTOCOMPACT_PCT, logged, so a typo in the override degrades\n * to the documented default instead of silently producing nonsense\n * thresholds (0, negative, or a fraction so large no session ever reaches\n * it).\n */\nexport function resolveAutocompactPct(env: NodeJS.ProcessEnv = process.env): number {\n const raw = env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;\n if (raw === undefined || raw === \"\") return DEFAULT_AUTOCOMPACT_PCT;\n\n const parsed = Number(raw);\n if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) {\n console.error(\n `[context-fill] CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=\"${raw}\" is not a usable percentage \u2014 ` +\n `falling back to the default ${DEFAULT_AUTOCOMPACT_PCT}.`\n );\n return DEFAULT_AUTOCOMPACT_PCT;\n }\n return parsed;\n}\n\n/**\n * The two thresholds that actually fire a handover job. \"immediate\" is not a\n * third job \u2014 see `isImmediate` below \u2014 it only changes how urgently a\n * crossing of these two is treated.\n */\nexport type ThresholdName = \"warmup\" | \"refresh\";\n\n/** Which basis actually produced effectiveTriggerTokens \u2014 reported so the\n * number is checkable rather than trusted. */\nexport type TriggerSource = \"measured\" | \"configured\" | \"measured-clamped\";\n\nexport interface ContextFillThresholds {\n warmupTokens: number;\n refreshTokens: number;\n immediateTokens: number;\n /** The derived compaction trigger these were measured back from \u2014\n * min(measured, configured) when a measured value exists. */\n effectiveTriggerTokens: number;\n /** The raw measured value from this project's own compact_boundary\n * history, before any clamping \u2014 null when the project has no history.\n * Kept alongside effectiveTriggerTokens so a clamp is visible rather\n * than silent: a caller can see both what was measured and what was\n * actually used. */\n measuredTriggerTokens: number | null;\n /** The raw configured-chain value (env override or default, as a\n * fraction of the window) \u2014 always computed and reported, even when it\n * wasn't what ended up being used. */\n configuredTriggerTokens: number;\n /** The autocompact percentage from the configured chain. */\n autocompactPct: number;\n /** \"measured\": a measured value existed and was <= configured, so it was\n * used directly \u2014 the better estimate, since it reflects reality the\n * configured percentage cannot know.\n * \"measured-clamped\": a measured value existed but was HIGHER than\n * configured \u2014 it reflects a regime that may no longer apply (this\n * project's last compaction predates a since-changed trigger), so the\n * lower, safer configured value was used instead.\n * \"configured\": no measured value exists yet (no compaction history for\n * this project) \u2014 the configured chain is all there is. */\n triggerSource: TriggerSource;\n /** True when these were computed against a window size Claude Code itself\n * reported (the statusline source). False when the window is only an\n * assumed default (the transcript-fallback source never learns the real\n * window size) \u2014 callers should log this: a threshold silently computed\n * against the wrong basis is the same class of fault as printing a token\n * count larger than the window. */\n windowConfirmed: boolean;\n}\n\nexport interface ContextFillThresholdOpts {\n /** The session's project directory, used to look up its own compaction\n * history for the measured trigger. Omit when unknown \u2014 falls back to\n * the configured chain, same as a project with no history yet. */\n cwd?: string;\n /** Override the measured-trigger lookup instead of scanning transcripts \u2014\n * primarily for tests. `null` forces the configured fallback even when\n * `cwd` is given; `undefined` (the default) does the real lookup. */\n measuredTrigger?: number | null;\n}\n\n/**\n * Derive warmup/refresh/immediate thresholds from a fill reading.\n *\n * effectiveTrigger = min(measured, configuredChainValue) when a measured\n * value exists \u2014 NOT the measured value outright. A project whose newest\n * compaction predates a regime change measures a stale-HIGH trigger: a real\n * case on this machine measured 998,267 for a project whose ACTUAL current\n * boundary (from a different project's fresher history, cross-checked\n * independently) is ~784,000 \u2014 using 998,267 directly would compute a\n * warm-up of 898,267, above the real boundary, so the handover would never\n * fire there. The measurement is honest; it is just old.\n *\n * The minimum is correct in both directions: the measured value is the\n * better estimate when it is LOWER than configured (it reflects reality the\n * configured percentage cannot know \u2014 see the module comment above, the\n * 100%-to-78% regime change this project itself lived through); it is\n * unsafe when it is HIGHER (it reflects a regime that no longer applies).\n * Taking the minimum costs nothing in the safe direction \u2014 one wasted\n * summary if the project's regime actually did move up \u2014 and prevents the\n * unsafe direction, where a stale-high measurement suppresses the handover\n * past the real boundary. That asymmetry is the same one the 80-not-100\n * default was chosen for.\n *\n * Margins below effectiveTrigger are clamped at 0 (and logged) for a window\n * small enough that a margin would otherwise go negative \u2014 a pathological\n * input should degrade to \"fire immediately\", never to a threshold below\n * zero.\n */\nexport function contextFillThresholds(\n reading: ContextFillReading,\n env: NodeJS.ProcessEnv = process.env,\n opts: ContextFillThresholdOpts = {}\n): ContextFillThresholds {\n const windowConfirmed = reading.source === \"statusline\";\n const autocompactPct = resolveAutocompactPct(env);\n const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));\n\n const measuredTriggerTokens = opts.measuredTrigger !== undefined\n ? opts.measuredTrigger\n : opts.cwd\n ? measureCompactionTrigger(opts.cwd)\n : null;\n\n let effectiveTriggerTokens: number;\n let triggerSource: TriggerSource;\n\n if (measuredTriggerTokens === null) {\n effectiveTriggerTokens = configuredTriggerTokens;\n triggerSource = \"configured\";\n console.error(\n `[context-fill] trigger source: CONFIGURED \u2014 no compaction history for this project yet. ` +\n `measured=none, configured=${configuredTriggerTokens} (${autocompactPct}% of a ${reading.windowSize}-token ` +\n `window) -> using ${effectiveTriggerTokens}.`\n );\n } else if (measuredTriggerTokens <= configuredTriggerTokens) {\n effectiveTriggerTokens = measuredTriggerTokens;\n triggerSource = \"measured\";\n console.error(\n `[context-fill] trigger source: MEASURED \u2014 measured=${measuredTriggerTokens} ` +\n `(minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events), ` +\n `configured=${configuredTriggerTokens} -> using ${effectiveTriggerTokens} (measured, \u2264 configured).`\n );\n } else {\n effectiveTriggerTokens = configuredTriggerTokens;\n triggerSource = \"measured-clamped\";\n console.error(\n `[context-fill] trigger source: MEASURED-CLAMPED \u2014 measured=${measuredTriggerTokens} is HIGHER than ` +\n `configured=${configuredTriggerTokens} (a stale regime this project's history predates) -> ` +\n `using ${effectiveTriggerTokens} (configured, the safer bound).`\n );\n }\n\n const clamp = (name: string, value: number): number => {\n if (value >= 0) return value;\n console.error(\n `[context-fill] ${name} threshold went negative (${value}) for a ` +\n `${reading.windowSize}-token window \u2014 clamping to 0.`\n );\n return 0;\n };\n\n return {\n warmupTokens: clamp(\"warmup\", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.warmup),\n refreshTokens: clamp(\"refresh\", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.refresh),\n immediateTokens: clamp(\"immediate\", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.immediate),\n effectiveTriggerTokens,\n measuredTriggerTokens,\n configuredTriggerTokens,\n autocompactPct,\n triggerSource,\n windowConfirmed,\n };\n}\n\n/**\n * Which named thresholds `usedTokens` has newly crossed, given the ones that\n * have already fired this session. Ascending order (warmup before refresh).\n *\n * A single check can return more than one name \u2014 e.g. a session first\n * observed at 99% of its window crosses warmup and refresh in the same tick,\n * because there was no earlier clean crossing to catch it at. That is the\n * \"fire immediately\" case: the caller enqueues one handover and marks every\n * newly-crossed name fired, rather than waiting for a crossing that already\n * happened.\n */\nexport function crossedThresholds(\n usedTokens: number,\n thresholds: ContextFillThresholds,\n alreadyFired: ThresholdName[]\n): ThresholdName[] {\n const fired = new Set(alreadyFired);\n const ordered: Array<[ThresholdName, number]> = [\n [\"warmup\", thresholds.warmupTokens],\n [\"refresh\", thresholds.refreshTokens],\n ];\n return ordered.filter(([name, tokens]) => !fired.has(name) && usedTokens >= tokens).map(([name]) => name);\n}\n\n/**\n * True once a session is at or above the \"no time left for a clean crossing\"\n * floor (0.985 of the window). Purely informational for callers \u2014 it never\n * gates whether `crossedThresholds` fires (a poll-based check already fires\n * any unfired threshold the moment `usedTokens` reaches it, on whatever tick\n * observes it) \u2014 but it distinguishes \"this crossed on schedule\" from \"this\n * was first observed already almost out of room\", which is worth a different\n * log line and, for a caller that queues work, a higher priority.\n */\nexport function isImmediate(usedTokens: number, thresholds: ContextFillThresholds): boolean {\n return usedTokens >= thresholds.immediateTokens;\n}\n", "/**\n * runtime-paths.ts \u2014 the shared files PAI talks to the running daemon through.\n *\n * Sockets, logs and pidfiles live at fixed absolute paths under /tmp, and every\n * one of them is shared with a *live* daemon. That makes them the same hazard\n * as any other piece of real user state a test can reach: writing one from a\n * test does not fail, it corrupts something already running, silently.\n *\n * The home guard in test/setup-home-guard.ts does not cover them. It works by\n * redirecting HOME, so it protects paths *derived* from the home directory and\n * nothing else \u2014 a hardcoded \"/tmp/pai.sock\" walks straight past it. Verified\n * on 2026-08-01 by snapshotting all five and running the suite: nothing writes\n * to them today. But \"no test does this yet\" is not a property, and the next\n * daemon test to be written would clobber the live socket with nothing to stop\n * it.\n *\n * So each path is read from an environment variable with the historical /tmp\n * value as its default. Behaviour is unchanged for every real invocation; the\n * test setup overrides the variables and the whole class becomes unreachable\n * rather than merely absent, the same way the home guard works.\n *\n * Read at call time, not at module load: the test setup file runs before any\n * module is imported, but resolving these eagerly would still bake in whatever\n * the environment held at import and make the override order matter.\n */\n\n/** IPC socket for the PAI daemon. */\nexport function paiSocketPath(): string {\n return process.env.PAI_SOCKET_PATH ?? \"/tmp/pai.sock\";\n}\n\n/** IPC socket for the AIBroker daemon \u2014 read by PAI, owned by AIBroker. */\nexport function aibrokerSocketPath(): string {\n return process.env.PAI_AIBROKER_SOCKET_PATH ?? \"/tmp/aibroker.sock\";\n}\n\n/** Where the daemon's stdout and stderr are collected. */\nexport function daemonLogPath(): string {\n return process.env.PAI_DAEMON_LOG_PATH ?? \"/tmp/pai-daemon.log\";\n}\n\n/** Pidfile for the running daemon. */\nexport function daemonPidPath(): string {\n return process.env.PAI_DAEMON_PID_PATH ?? \"/tmp/pai-daemon.pid\";\n}\n\n/** Where the launchd scheduler tick writes its output. */\nexport function schedulerLogPath(): string {\n return process.env.PAI_SCHEDULER_LOG_PATH ?? \"/tmp/pai-scheduler.log\";\n}\n\n/**\n * Every runtime path, for the test guard to redirect in one place.\n *\n * Listed here rather than in the guard so that adding a path and forgetting to\n * protect it is not possible: the guard iterates this.\n */\nexport const RUNTIME_PATH_ENV_VARS = [\n \"PAI_SOCKET_PATH\",\n \"PAI_AIBROKER_SOCKET_PATH\",\n \"PAI_DAEMON_LOG_PATH\",\n \"PAI_DAEMON_PID_PATH\",\n \"PAI_SCHEDULER_LOG_PATH\",\n] as const;\n", "/**\n * ipc-client.ts \u2014 IPC client for the PAI Daemon MCP shim\n *\n * PaiClient connects to the Unix Domain Socket served by daemon.ts\n * and forwards tool calls to the daemon. Uses a fresh socket connection per\n * call (connect \u2192 write JSON + newline \u2192 read response line \u2192 parse \u2192 destroy).\n * This keeps the client stateless and avoids connection management complexity.\n *\n * Adapted from the Coogle ipc-client pattern (which was adapted from Whazaa).\n */\n\nimport { connect, Socket } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport type {\n NotificationConfig,\n NotificationMode,\n NotificationEvent,\n SendResult,\n} from \"../notifications/types.js\";\nimport type { TopicCheckParams, TopicCheckResult } from \"../topics/detector.js\";\nimport type { AutoRouteResult } from \"../session/auto-route.js\";\nimport { paiSocketPath } from \"../runtime-paths.js\";\n\n// ---------------------------------------------------------------------------\n// Protocol types\n// ---------------------------------------------------------------------------\n\n/** Default socket path */\nexport const IPC_SOCKET_PATH = paiSocketPath();\n\n/** Timeout for IPC calls (60 seconds) */\nconst IPC_TIMEOUT_MS = 60_000;\n\ninterface IpcRequest {\n id: string;\n method: string;\n params: Record<string, unknown>;\n}\n\ninterface IpcResponse {\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Client\n// ---------------------------------------------------------------------------\n\n/**\n * Thin IPC proxy that forwards tool calls to pai-daemon over a Unix\n * Domain Socket. Each call opens a fresh connection, sends one NDJSON request,\n * reads the response, and closes. Stateless and simple.\n */\nexport class PaiClient {\n private readonly socketPath: string;\n\n constructor(socketPath?: string) {\n this.socketPath = socketPath ?? IPC_SOCKET_PATH;\n }\n\n /**\n * Call a PAI tool by name with the given params.\n * Returns the tool result or throws on error.\n *\n * `timeoutMs` overrides the default 60s wait \u2014 for a caller on a hook's\n * critical path (e.g. the threshold-triggered handover enqueue in\n * `cli/commands/session/autosave.ts`) where the actual work happens later,\n * asynchronously, in the daemon's worker loop, and only the cheap\n * enqueue handshake itself should ever be waited on.\n */\n async call(method: string, params: Record<string, unknown>, timeoutMs?: number): Promise<unknown> {\n return this.send(method, params, timeoutMs);\n }\n\n /**\n * Check daemon status.\n */\n async status(): Promise<Record<string, unknown>> {\n const result = await this.send(\"status\", {});\n return result as Record<string, unknown>;\n }\n\n /**\n * Trigger an immediate index run.\n */\n async triggerIndex(): Promise<void> {\n await this.send(\"index_now\", {});\n }\n\n // -------------------------------------------------------------------------\n // Notification methods\n // -------------------------------------------------------------------------\n\n /**\n * Get the current notification config from the daemon.\n */\n async getNotificationConfig(): Promise<{\n config: NotificationConfig;\n activeChannels: string[];\n }> {\n const result = await this.send(\"notification_get_config\", {});\n return result as { config: NotificationConfig; activeChannels: string[] };\n }\n\n /**\n * Patch the notification config on the daemon (and persist to disk).\n */\n async setNotificationConfig(patch: {\n mode?: NotificationMode;\n channels?: Partial<NotificationConfig[\"channels\"]>;\n routing?: Partial<NotificationConfig[\"routing\"]>;\n }): Promise<{ config: NotificationConfig }> {\n const result = await this.send(\"notification_set_config\", patch as Record<string, unknown>);\n return result as { config: NotificationConfig };\n }\n\n /**\n * Send a notification via the daemon (routes to configured channels).\n */\n async sendNotification(payload: {\n event: NotificationEvent;\n message: string;\n title?: string;\n }): Promise<SendResult> {\n const result = await this.send(\"notification_send\", payload as Record<string, unknown>);\n return result as SendResult;\n }\n\n // -------------------------------------------------------------------------\n // Topic detection methods\n // -------------------------------------------------------------------------\n\n /**\n * Check whether the provided context text has drifted to a different project\n * than the session's current routing.\n */\n async topicCheck(params: TopicCheckParams): Promise<TopicCheckResult> {\n const result = await this.send(\"topic_check\", params as unknown as Record<string, unknown>);\n return result as TopicCheckResult;\n }\n\n // -------------------------------------------------------------------------\n // Session routing methods\n // -------------------------------------------------------------------------\n\n /**\n * Automatically detect which project a session belongs to.\n * Tries path match, PAI.md marker walk, then topic detection (if context given).\n */\n async sessionAutoRoute(params: {\n cwd?: string;\n context?: string;\n }): Promise<AutoRouteResult | null> {\n // session_auto_route returns a ToolResult (content array). Extract the text\n // and parse JSON from it.\n const result = await this.send(\"session_auto_route\", params as Record<string, unknown>);\n const toolResult = result as { content?: Array<{ text: string }>; isError?: boolean };\n if (toolResult.isError) return null;\n const text = toolResult.content?.[0]?.text ?? \"\";\n // Text is either JSON (on match) or a human-readable \"no match\" message\n try {\n return JSON.parse(text) as AutoRouteResult;\n } catch {\n return null;\n }\n }\n\n // -------------------------------------------------------------------------\n // Internal transport\n // -------------------------------------------------------------------------\n\n /**\n * Send a single IPC request and wait for the response.\n * Opens a new socket connection per call \u2014 simple and reliable.\n */\n private send(\n method: string,\n params: Record<string, unknown>,\n timeoutMs: number = IPC_TIMEOUT_MS\n ): Promise<unknown> {\n const socketPath = this.socketPath;\n\n return new Promise((resolve, reject) => {\n let socket: Socket | null = null;\n let done = false;\n let buffer = \"\";\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n function finish(error: Error | null, value?: unknown): void {\n if (done) return;\n done = true;\n if (timer !== null) {\n clearTimeout(timer);\n timer = null;\n }\n try {\n socket?.destroy();\n } catch {\n // ignore\n }\n if (error) {\n reject(error);\n } else {\n resolve(value);\n }\n }\n\n socket = connect(socketPath, () => {\n const request: IpcRequest = {\n id: randomUUID(),\n method,\n params,\n };\n socket!.write(JSON.stringify(request) + \"\\n\");\n });\n\n socket.on(\"data\", (chunk: Buffer) => {\n buffer += chunk.toString();\n const nl = buffer.indexOf(\"\\n\");\n if (nl === -1) return;\n\n const line = buffer.slice(0, nl);\n buffer = buffer.slice(nl + 1);\n\n let response: IpcResponse;\n try {\n response = JSON.parse(line) as IpcResponse;\n } catch {\n finish(new Error(`IPC parse error: ${line}`));\n return;\n }\n\n if (!response.ok) {\n finish(new Error(response.error ?? \"IPC call failed\"));\n } else {\n finish(null, response.result);\n }\n });\n\n socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n finish(\n new Error(\n \"PAI daemon not running. Start it with: pai daemon serve\"\n )\n );\n } else {\n finish(e);\n }\n });\n\n socket.on(\"end\", () => {\n if (!done) {\n finish(new Error(\"IPC connection closed before response\"));\n }\n });\n\n timer = setTimeout(() => {\n finish(new Error(`IPC call timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n });\n }\n}\n"],
|
|
5
|
+
"mappings": ";;;AAoBO,SAAS,gBAAgB,MAAyB,QAAQ,KAAc;AAC7E,SAAO,IAAI,eAAe;AAC5B;;;ACHA,SAAS,gBAAAA,eAAc,cAAAC,aAAY,cAAAC,mBAAkB;AACrD,SAAS,QAAAC,aAAY;AACrB,SAAS,UAAAC,eAAc;;;ACKvB,SAAS,YAAY,cAAc,YAAY,qBAAqB;AACpE,SAAS,UAAAC,eAAc;AACvB,SAAS,QAAAC,aAAY;;;ACIrB,SAAS,SAAS,cAAc;AAChC,SAAS,YAAY;AAcrB,IAAM,sBAAsB,IAAI,KAAK;AA8Q9B,IAAM,sBAAsB,KAAK,QAAQ,GAAG,WAAW,UAAU;;;AClSjE,SAAS,gBAAwB;AACtC,SAAO,QAAQ,IAAI,mBAAmB;AACxC;;;ACDO,IAAM,kBAAkB,cAAc;;;AH+B7C,IAAM,qBAAqB,IAAI,KAAK;AA0B7B,SAAS,iBAAiB,WAA2B;AAC1D,SAAOC,MAAKC,QAAO,GAAG,8BAA8B,SAAS,OAAO;AACtE;AAgEO,SAAS,0BAA0B,WAAyB;AACjE,QAAM,OAAO,iBAAiB,SAAS;AACvC,MAAI;AACF,QAAI,WAAW,IAAI,EAAG,YAAW,IAAI;AAAA,EACvC,QAAQ;AAAA,EAIR;AACF;;;ADhIA,eAAe,OAAO;AACpB,MAAI,gBAAgB,EAAG;AACvB,MAAI,YAA8B;AAElC,MAAI;AACF,UAAM,UAAU,IAAI,YAAY;AAChC,QAAI,QAAQ;AACZ,UAAM,iBAAiB,IAAI,QAAc,CAAC,YAAY;AAAE,iBAAW,SAAS,GAAG;AAAA,IAAG,CAAC;AACnF,UAAM,eAAe,YAAY;AAC/B,uBAAiB,SAAS,QAAQ,OAAO;AACvC,iBAAS,QAAQ,OAAO,OAAO,EAAE,QAAQ,KAAK,CAAC;AAAA,MACjD;AAAA,IACF,GAAG;AACH,UAAM,QAAQ,KAAK,CAAC,aAAa,cAAc,CAAC;AAChD,QAAI,MAAM,KAAK,GAAG;AAChB,kBAAY,KAAK,MAAM,KAAK;AAAA,IAC9B;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,MAAI,CAAC,WAAW,YAAY;AAC1B,YAAQ,MAAM,6CAA6C;AAC3D,YAAQ,KAAK,CAAC;AAAA,EAChB;AAUA,4BAA0B,UAAU,UAAU;AAG9C,QAAM,YAAYC,MAAKC,QAAO,GAAG,qBAAqB,UAAU,UAAU,MAAM;AAEhF,MAAI,CAACC,YAAW,SAAS,GAAG;AAC1B,YAAQ,MAAM,+CAA+C,SAAS,EAAE;AACxE,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,MAAI;AACF,UAAM,QAAQC,cAAa,WAAW,OAAO,EAAE,KAAK;AAEpD,QAAI,MAAM,SAAS,GAAG;AAEpB,cAAQ,IAAI,KAAK;AACjB,cAAQ,MAAM,iCAAiC,MAAM,MAAM,yBAAyB;AAAA,IACtF;AAGA,IAAAC,YAAW,SAAS;AACpB,YAAQ,MAAM,mCAAmC,SAAS,EAAE;AAAA,EAC9D,SAAS,KAAK;AACZ,YAAQ,MAAM,kDAAkD,GAAG,EAAE;AAAA,EACvE;AAEA,UAAQ,KAAK,CAAC;AAChB;AAEA,KAAK,EAAE,MAAM,MAAM;AACjB,UAAQ,KAAK,CAAC;AAChB,CAAC;",
|
|
6
6
|
"names": ["readFileSync", "existsSync", "unlinkSync", "join", "tmpdir", "tmpdir", "join", "join", "tmpdir", "join", "tmpdir", "existsSync", "readFileSync", "unlinkSync"]
|
|
7
7
|
}
|