@tekmidian/pai 0.65.2 → 0.66.1
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/{chain-CT3SI-3z.mjs → chain-CZfKIEq9.mjs} +4 -4
- package/dist/{chain-CT3SI-3z.mjs.map → chain-CZfKIEq9.mjs.map} +1 -1
- package/dist/cli/index.mjs +9 -9
- package/dist/cli/program.mjs +9 -9
- package/dist/{context-handover-cache-CRSFdkNi.mjs → context-handover-cache-pkHzmL3c.mjs} +3 -3
- package/dist/{context-handover-cache-CRSFdkNi.mjs.map → context-handover-cache-pkHzmL3c.mjs.map} +1 -1
- package/dist/daemon/index.mjs +7 -7
- package/dist/{daemon-SaPnmoyg.mjs → daemon-DXlaVCkw.mjs} +9 -9
- package/dist/{daemon-SaPnmoyg.mjs.map → daemon-DXlaVCkw.mjs.map} +1 -1
- package/dist/{daemon-DoTVK9z6.mjs → daemon-DyORmxZu.mjs} +7 -7
- package/dist/daemon-mcp/index.mjs +8 -6
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{fallback-vHjFZP0Y.mjs → fallback-CIiq2Raw.mjs} +18 -8
- package/dist/fallback-CIiq2Raw.mjs.map +1 -0
- package/dist/hooks/block-sleep-poll.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +16 -0
- package/dist/hooks/load-project-context.mjs.map +2 -2
- package/dist/hooks/route-agents-to-worker.mjs +16 -0
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/security-validator.mjs +16 -0
- package/dist/hooks/security-validator.mjs.map +2 -2
- package/dist/hooks/worker-guard.mjs +15 -0
- package/dist/hooks/worker-guard.mjs.map +3 -3
- package/dist/hooks/worker-proxy.mjs +16 -0
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +17 -0
- package/dist/hooks/worker-status-line.mjs.map +2 -2
- package/dist/hooks/worker-supervision.mjs +16 -0
- package/dist/hooks/worker-supervision.mjs.map +2 -2
- package/dist/{main-resolver-VSM5ytnA.mjs → main-resolver-BKz_OuEh.mjs} +2 -2
- package/dist/{main-resolver-CKNGux1B.mjs → main-resolver-DPPwHNtn.mjs} +2 -2
- package/dist/{main-resolver-CKNGux1B.mjs.map → main-resolver-DPPwHNtn.mjs.map} +1 -1
- package/dist/{planner-Dzs9-8AS.mjs → planner-Dm5Acyo1.mjs} +5 -5
- package/dist/{planner-Dzs9-8AS.mjs.map → planner-Dm5Acyo1.mjs.map} +1 -1
- package/dist/{program-DK0mRRlf.mjs → program-JJ0TwSHI.mjs} +59 -16
- package/dist/program-JJ0TwSHI.mjs.map +1 -0
- package/dist/{run-DMaCpvMW.mjs → run-BCpbxqA5.mjs} +490 -26
- package/dist/run-BCpbxqA5.mjs.map +1 -0
- package/dist/{run-env-BHWnXqle.mjs → run-env-DRcp7A8K.mjs} +16 -2
- package/dist/run-env-DRcp7A8K.mjs.map +1 -0
- package/dist/{server-DlL1QI2k.mjs → server-DgmAHyFK.mjs} +2 -2
- package/dist/{server-DlL1QI2k.mjs.map → server-DgmAHyFK.mjs.map} +1 -1
- package/dist/{session-keepalive-DVZ9-7qg.mjs → session-keepalive-DxjTHGcK.mjs} +4 -4
- package/dist/{session-keepalive-DVZ9-7qg.mjs.map → session-keepalive-DxjTHGcK.mjs.map} +1 -1
- package/dist/{work-queue-worker-DZiEMvBQ.mjs → work-queue-worker-4tLa5gpQ.mjs} +2 -2
- package/dist/{work-queue-worker-DZiEMvBQ.mjs.map → work-queue-worker-4tLa5gpQ.mjs.map} +1 -1
- package/dist/{work-queue-worker-D7GHuj2I.mjs → work-queue-worker-rFZvTO3p.mjs} +4 -4
- package/docs/commands/README.md +1 -0
- package/docs/commands/worker.md +30 -0
- package/package.json +1 -1
- package/src/hooks/ts/lib/worker-guard.test.ts +12 -0
- package/src/hooks/ts/lib/worker-guard.ts +13 -0
- package/dist/fallback-vHjFZP0Y.mjs.map +0 -1
- package/dist/program-DK0mRRlf.mjs.map +0 -1
- package/dist/run-DMaCpvMW.mjs.map +0 -1
- package/dist/run-env-BHWnXqle.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-keepalive-DVZ9-7qg.mjs","names":["fetchLiveSessionsDefault","sendToSessionDefault"],"sources":["../src/audit/session-usage.ts","../src/daemon/session-keepalive.ts"],"sourcesContent":["/**\n * session-usage.ts — parse the usage numbers out of a Claude Code session\n * (or subagent, or pai-worker event-mirror) JSONL transcript.\n *\n * All three log shapes share the same assistant-message envelope\n * ({ type: \"assistant\", message: { id, model, usage } }), so one parser\n * covers `pai audit tokens session` and the per-log readings feeding\n * `pai audit tokens spawn`.\n *\n * Streaming writes one JSONL line per content block of the same logical\n * turn, repeating message.id and usage each time — summing every line would\n * multiply usage by the block count. Keeping only the first line seen per\n * message.id is what the reference script (and this parser) count instead.\n */\n\nimport { createReadStream, existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { readFileSync } from \"node:fs\";\n\nexport interface UsageTotals {\n cache_read_input_tokens: number;\n cache_creation_input_tokens: number;\n input_tokens: number;\n output_tokens: number;\n}\n\nexport interface CacheCreationSplit {\n ephemeral5m: number;\n ephemeral1h: number;\n}\n\nexport interface CompactionEvent {\n trigger: string;\n preTokens: number;\n turnIndex: number;\n}\n\nexport interface ModelSwitch {\n turnIndex: number;\n from: string;\n to: string;\n cacheRead: number;\n cacheCreation: number;\n}\n\nexport interface FallbackEvent {\n turnIndex: number;\n from: string;\n to: string;\n category: string;\n scope: string;\n}\n\nexport interface SessionUsageReport {\n path: string;\n sizeBytes: number;\n turns: number;\n totals: UsageTotals;\n /** Per-model assistant-turn counts. */\n models: Record<string, number>;\n /** cache_read + cache_creation + input on the first / last assistant turn seen. */\n firstTurnContext: number | null;\n lastTurnContext: number | null;\n cacheCreationSplit: CacheCreationSplit;\n /** Average / max of the per-turn context value across all turns; null if no turns. */\n avgContext: number | null;\n maxContext: number | null;\n /** Turns whose per-turn context value exceeds the report's threshold. */\n turnsAboveThreshold: number;\n /** Turns whose cache_creation_input_tokens exceeds 20000. */\n cacheRebuildTurns: number;\n /** Real human-authored user prompts (excludes tool-result-only \"user\" lines). */\n userPrompts: number;\n /**\n * Sum over assistant turns of the real user prompts seen before that turn;\n * multiplied by per-prompt hook tokens it gives the tokens UserPromptSubmit\n * output occupied across the whole session.\n */\n promptExposure: number;\n compactions: CompactionEvent[];\n /** ISO timestamp of the first assistant turn. */\n firstTurnAt: string | null;\n /** Model of the last folded turn; drives switch detection. */\n lastModel: string | null;\n modelSwitches: ModelSwitch[];\n fallbacks: FallbackEvent[];\n /** Timestamp (ms) of the last folded assistant turn; drives idle-gap detection. */\n lastTurnAtMs: number | null;\n /** Gaps between consecutive assistant turns exceeding 60 minutes — evidence\n * for whether the session ever went idle long enough to risk its cache TTL\n * (see sessions.cacheKeepalive, src/daemon/config.ts). */\n idleGapsOver60min: number;\n /** Real user prompts whose text is exactly the configured keepalive word\n * (set only when parseSessionUsage is called with one); null otherwise. */\n keepaliveBeats: number | null;\n}\n\nconst USAGE_KEYS: (keyof UsageTotals)[] = [\n \"cache_read_input_tokens\",\n \"cache_creation_input_tokens\",\n \"input_tokens\",\n \"output_tokens\",\n];\n\nfunction emptyTotals(): UsageTotals {\n return { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, input_tokens: 0, output_tokens: 0 };\n}\n\nexport interface AssistantLine {\n type?: string;\n uuid?: string;\n subtype?: string;\n /** Claude Code marks skill expansions and injected system reminders isMeta; they fire no UserPromptSubmit hook. */\n isMeta?: boolean;\n /** Present on newer logs: { kind: \"human\" } for a typed prompt. */\n origin?: { kind?: string };\n compactMetadata?: { trigger?: string; preTokens?: number };\n timestamp?: string;\n originalModel?: string;\n fallbackModel?: string;\n apiRefusalCategory?: string;\n scope?: string;\n message?: {\n id?: string;\n model?: string;\n usage?: Record<string, unknown> & {\n cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };\n };\n content?: string | Array<{ type?: string }>;\n stop_reason?: string | null;\n };\n}\n\n/**\n * `type:\"system\"` `subtype:\"compact_boundary\"` lines mark a compaction.\n * turnIndex is the count of assistant turns already folded when it fired,\n * so it lines up with the turn numbering the text/JSON report prints.\n */\nexport function isCompactBoundary(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"compact_boundary\";\n}\n\nexport function parseCompactionEvent(line: AssistantLine, turnIndex: number): CompactionEvent | null {\n const meta = line.compactMetadata;\n if (!meta || typeof meta.preTokens !== \"number\") return null;\n return { trigger: meta.trigger ?? \"unknown\", preTokens: meta.preTokens, turnIndex };\n}\n\n/**\n * `type:\"system\"` `subtype:\"model_refusal_fallback\"` lines mark an\n * automatic model switch fired by an API safety refusal (e.g. \"cyber\"\n * category), not a user or config choice — worth flagging separately since\n * it can rebuild the whole prompt cache mid-session.\n */\nexport function isModelFallback(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"model_refusal_fallback\";\n}\n\nexport function parseFallbackEvent(line: AssistantLine, turnIndex: number): FallbackEvent {\n return {\n turnIndex,\n from: line.originalModel ?? \"unknown\",\n to: line.fallbackModel ?? \"unknown\",\n category: line.apiRefusalCategory ?? \"unknown\",\n scope: line.scope ?? \"unknown\",\n };\n}\n\n/**\n * Fold one already-parsed JSONL line into a report being accumulated.\n * Exported separately so both the streaming file reader below and tests\n * (which build fixtures as arrays of objects, not files) share one path.\n * Returns the turn's context value when a new turn was counted, else null\n * (non-assistant line, no usage, or a duplicate message.id already seen).\n */\nexport function foldAssistantLine(\n report: Pick<\n SessionUsageReport,\n | \"turns\"\n | \"totals\"\n | \"models\"\n | \"firstTurnContext\"\n | \"lastTurnContext\"\n | \"cacheCreationSplit\"\n | \"maxContext\"\n | \"turnsAboveThreshold\"\n | \"cacheRebuildTurns\"\n | \"firstTurnAt\"\n | \"lastModel\"\n | \"modelSwitches\"\n | \"lastTurnAtMs\"\n | \"idleGapsOver60min\"\n >,\n seenIds: Set<string>,\n line: AssistantLine,\n threshold: number\n): number | null {\n if (line.type !== \"assistant\") return null;\n const message = line.message;\n const usage = message?.usage;\n if (!usage) return null;\n const id = message?.id ?? line.uuid;\n if (id) {\n if (seenIds.has(id)) return null;\n seenIds.add(id);\n }\n report.turns++;\n for (const key of USAGE_KEYS) {\n const v = usage[key];\n if (typeof v === \"number\") report.totals[key] += v;\n }\n const context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n if (report.firstTurnContext === null) {\n report.firstTurnContext = context;\n report.firstTurnAt = line.timestamp ?? null;\n }\n const atMs = line.timestamp ? Date.parse(line.timestamp) : NaN;\n if (!Number.isNaN(atMs)) {\n if (report.lastTurnAtMs !== null && atMs - report.lastTurnAtMs > 60 * 60 * 1000) {\n report.idleGapsOver60min++;\n }\n report.lastTurnAtMs = atMs;\n }\n report.lastTurnContext = context;\n report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);\n if (context > threshold) report.turnsAboveThreshold++;\n if ((Number(usage.cache_creation_input_tokens) || 0) > 20000) report.cacheRebuildTurns++;\n const model = message?.model ?? \"unknown\";\n report.models[model] = (report.models[model] ?? 0) + 1;\n if (report.lastModel !== null && report.lastModel !== model) {\n report.modelSwitches.push({\n turnIndex: report.turns,\n from: report.lastModel,\n to: model,\n cacheRead: Number(usage.cache_read_input_tokens) || 0,\n cacheCreation: Number(usage.cache_creation_input_tokens) || 0,\n });\n }\n report.lastModel = model;\n const split = usage.cache_creation;\n if (split) {\n report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;\n report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;\n }\n return context;\n}\n\n/**\n * True for a real human-authored `type:\"user\"` prompt line: content is a\n * plain string, or a content array with no `tool_result` block. Claude Code\n * encodes tool results as `type:\"user\"` messages whose content array is\n * entirely (or partly) tool_result blocks — those must not count as prompts.\n */\nexport function isRealUserPrompt(line: AssistantLine): boolean {\n if (line.type !== \"user\") return false;\n if (line.isMeta) return false;\n if (line.origin?.kind && line.origin.kind !== \"human\") return false;\n const content = line.message?.content;\n if (typeof content === \"string\") return true;\n if (Array.isArray(content)) return !content.some((block) => block?.type === \"tool_result\");\n return false;\n}\n\n/** Plain text of a real user prompt: the string content, or its first text block. */\nexport function extractUserPromptText(line: AssistantLine): string {\n const content = line.message?.content;\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n const block = content.find((b) => b?.type === \"text\") as { text?: string } | undefined;\n return block?.text ?? \"\";\n }\n return \"\";\n}\n\n/**\n * Parse a session/subagent/worker-event JSONL file into a usage report.\n * `keepaliveWord`, when given, counts real user prompts whose text exactly\n * matches it (trimmed) — the sessions.cacheKeepalive beat prompt — into\n * `keepaliveBeats`; omitted, `keepaliveBeats` stays null.\n */\nexport async function parseSessionUsage(\n path: string,\n threshold = 200_000,\n keepaliveWord?: string\n): Promise<SessionUsageReport> {\n const report: SessionUsageReport = {\n path,\n sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,\n turns: 0,\n totals: emptyTotals(),\n models: {},\n firstTurnContext: null,\n lastTurnContext: null,\n cacheCreationSplit: { ephemeral5m: 0, ephemeral1h: 0 },\n avgContext: null,\n maxContext: null,\n turnsAboveThreshold: 0,\n cacheRebuildTurns: 0,\n userPrompts: 0,\n promptExposure: 0,\n compactions: [],\n firstTurnAt: null,\n lastModel: null,\n modelSwitches: [],\n fallbacks: [],\n lastTurnAtMs: null,\n idleGapsOver60min: 0,\n keepaliveBeats: keepaliveWord ? 0 : null,\n };\n const seenIds = new Set<string>();\n let contextSum = 0;\n\n const rl = createInterface({ input: createReadStream(path, \"utf8\"), crlfDelay: Infinity });\n for await (const raw of rl) {\n if (!raw.trim()) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (obj.type === \"user\") {\n if (isRealUserPrompt(obj)) {\n report.userPrompts++;\n if (keepaliveWord && extractUserPromptText(obj).trim() === keepaliveWord) {\n report.keepaliveBeats = (report.keepaliveBeats ?? 0) + 1;\n }\n }\n continue;\n }\n if (isCompactBoundary(obj)) {\n const event = parseCompactionEvent(obj, report.turns);\n if (event) report.compactions.push(event);\n continue;\n }\n if (isModelFallback(obj)) {\n report.fallbacks.push(parseFallbackEvent(obj, report.turns));\n continue;\n }\n const context = foldAssistantLine(report, seenIds, obj, threshold);\n if (context !== null) {\n contextSum += context;\n report.promptExposure += report.userPrompts;\n }\n }\n report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;\n return report;\n}\n\nexport function totalUsageTokens(totals: UsageTotals): number {\n return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;\n}\n","/**\n * session-keepalive.ts — idle-triggered prompt-cache keepalive beat for live\n * interactive Claude Code sessions (`sessions.cacheKeepalive`, see config.ts\n * and docs/cache-keepalive.md, \"Interactive sessions\").\n *\n * One beat = one trivial prompt typed into a session through AIBroker's\n * send_to_session (src/cli/lib/aibroker-client.ts), sent with `noReply: true`\n * so the target sees only the bare word typed into its input line and\n * nothing is queued into its mailbox as a peer message demanding a reply.\n * A cache READ refreshes the provider's ephemeral prompt-cache TTL at\n * roughly 0.1x the cost of the 2x rewrite a cold cache forces on the next\n * real prompt.\n *\n * Distinct from workers/keepalive.ts, which beats a *worker provider's*\n * cache on a fixed timer regardless of activity: this only beats a session\n * that has actually been idle long enough to be at risk, and only inside a\n * configured working-hours window, so it never fires while the user is\n * present at the keyboard or overnight when nobody will read the reply\n * before the cache would have expired anyway.\n */\n\nimport { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport {\n fetchLiveSessions as fetchLiveSessionsDefault,\n sendToSession as sendToSessionDefault,\n type AiBrokerSessionMeta,\n} from \"../cli/lib/aibroker-client.js\";\nimport { writeJsonAtomic } from \"../config/json-store.js\";\nimport { paiHomePath } from \"../config/pai-home.js\";\nimport { appendLedger } from \"../workers/ledger.js\";\nimport { readWorkersSection } from \"../workers/config.js\";\nimport { workersLogDir } from \"../workers/paths.js\";\nimport { worktreesDir } from \"../workers/worktree.js\";\nimport { itermUuid, sessionMapPath, type SessionMapEntry } from \"../workers/scope.js\";\nimport { isRealUserPrompt, extractUserPromptText, type AssistantLine } from \"../audit/session-usage.js\";\nimport type { SessionsCacheKeepaliveConfig } from \"./config.js\";\n\n// ---------------------------------------------------------------------------\n// State file — per-session beat counters, rebuildable (see json-store.ts on\n// when NOT to use readJsonStrict: a damaged file here just resets counters,\n// never blocks the feature).\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveEntry {\n /** Beats sent since the last real (non-keepalive) user prompt. */\n beats: number;\n /** Identity of the last real user prompt observed, so a fresh one can be\n * told apart from the keepalive's own echo landing back in the transcript. */\n lastRealPromptKey: string | null;\n /** ISO stamp of the last beat sent. */\n lastBeatAt: string | null;\n}\n\nexport type SessionKeepaliveState = Record<string, SessionKeepaliveEntry>;\n\nexport function sessionKeepaliveStatePath(): string {\n return paiHomePath(\"session-keepalive.json\");\n}\n\nfunction emptyEntry(): SessionKeepaliveEntry {\n return { beats: 0, lastRealPromptKey: null, lastBeatAt: null };\n}\n\nexport function loadSessionKeepaliveState(path: string = sessionKeepaliveStatePath()): SessionKeepaliveState {\n if (!existsSync(path)) return {};\n try {\n return JSON.parse(readFileSync(path, \"utf8\")) as SessionKeepaliveState;\n } catch {\n return {};\n }\n}\n\nexport function saveSessionKeepaliveState(\n state: SessionKeepaliveState,\n path: string = sessionKeepaliveStatePath()\n): void {\n writeJsonAtomic(path, state, { backup: false, label: path });\n}\n\n// ---------------------------------------------------------------------------\n// Active-hours window\n// ---------------------------------------------------------------------------\n\n/** Parse \"HH:MM-HH:MM\" into minutes-since-midnight. Throws on a malformed\n * spec — an explicit config error beats a window that is silently always\n * on or always off. */\nexport function parseActiveHours(spec: string): { startMin: number; endMin: number } {\n const m = spec.match(/^(\\d{1,2}):(\\d{2})-(\\d{1,2}):(\\d{2})$/);\n if (!m) {\n throw new Error(`sessions.cacheKeepalive.activeHours: invalid \"${spec}\" (want \"HH:MM-HH:MM\")`);\n }\n return {\n startMin: Number(m[1]) * 60 + Number(m[2]),\n endMin: Number(m[3]) * 60 + Number(m[4]),\n };\n}\n\n/**\n * Whether `now` (local time) sits inside the window. A window that wraps\n * midnight (e.g. \"22:00-06:00\") is honoured by inverting the test instead of\n * requiring startMin < endMin.\n */\nexport function isWithinActiveHours(now: Date, spec: string): boolean {\n const { startMin, endMin } = parseActiveHours(spec);\n const nowMin = now.getHours() * 60 + now.getMinutes();\n if (startMin <= endMin) return nowMin >= startMin && nowMin < endMin;\n return nowMin >= startMin || nowMin < endMin;\n}\n\n// ---------------------------------------------------------------------------\n// Transcript lookup\n// ---------------------------------------------------------------------------\n\n/**\n * Full path to a live session's transcript under ~/.claude/projects, or null\n * when none is found — the case for a session too new to have written a file\n * yet. A worker running in a worktree DOES write a transcript here (Claude\n * Code encodes its worktree cwd as the project dir name), so workers are not\n * excluded \"for free\" — see isWorkerSession.\n */\nexport function findSessionTranscript(\n sessionId: string,\n projectsDir: string = join(homedir(), \".claude\", \"projects\")\n): string | null {\n let projectDirs: string[];\n try {\n projectDirs = readdirSync(projectsDir);\n } catch {\n return null;\n }\n for (const projectDir of projectDirs) {\n const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`);\n if (existsSync(candidate)) return candidate;\n }\n return null;\n}\n\n/**\n * AIBroker's `fetchLiveSessions()` identifies a session by its iTerm2 pane id\n * (e.g. an AIBroker/iTerm UUID), not the Claude session id `<uuid>.jsonl`\n * transcripts are named after — the two are unrelated identifiers. The\n * status line bridges them on every refresh via `claude-session-map.json`\n * (see `recordSessionMapEntry` in ../workers/scope.ts): this picks, among the\n * map entries whose `term` pane UUID matches, the most recently written one.\n * Returns null when the pane has no (fresh enough) mapped Claude session.\n */\nexport function resolveClaudeSessionIdFromMap(paneId: string, logDir: string): string | null {\n const path = sessionMapPath(logDir);\n if (!paneId || !existsSync(path)) return null;\n let map: Record<string, SessionMapEntry>;\n try {\n map = JSON.parse(readFileSync(path, \"utf8\")) as Record<string, SessionMapEntry>;\n } catch {\n return null;\n }\n let best: SessionMapEntry | null = null;\n for (const entry of Object.values(map)) {\n if (!entry.term || itermUuid(entry.term) !== paneId) continue;\n if (!best || entry.ts > best.ts) best = entry;\n }\n return best?.session ?? null;\n}\n\n/** Claude Code's project-dir encoding of a cwd: every \"/\" becomes \"-\". */\nfunction encodeProjectDirName(cwd: string): string {\n return cwd.replace(/\\//g, \"-\");\n}\n\n/**\n * Is this live \"claude\"-kind session actually a worker pane rather than an\n * interactive one? `claude -p` workers running in a worktree write their\n * transcript under ~/.claude/projects too (encoded cwd = the worktree dir),\n * so a missing transcript is NOT how workers get excluded — this predicate\n * is. Either signal is enough:\n * (a) the transcript's project-dir name is the encoded form of a path\n * under <logDir>/worktrees (every worker worktree lives there), or\n * (b) the broker's session name/paiName names a path under the workers\n * log dir or one of its worktrees (best-effort: AIBroker does not\n * guarantee this, but honors it when present).\n */\nexport function isWorkerSession(\n meta: AiBrokerSessionMeta,\n transcriptPath: string | null,\n logDir: string\n): boolean {\n const worktreesPrefix = encodeProjectDirName(worktreesDir(logDir));\n if (transcriptPath) {\n const projectDirName = transcriptPath.split(\"/\").slice(0, -1).pop() ?? \"\";\n if (projectDirName.startsWith(worktreesPrefix)) return true;\n }\n const rawPrefix = worktreesDir(logDir);\n for (const field of [meta.name, meta.paiName]) {\n if (field && (field.includes(rawPrefix) || field.includes(logDir))) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Tail snapshot: last turn's context, mid-turn flag, last real prompt\n// ---------------------------------------------------------------------------\n\nexport interface TranscriptSnapshot {\n /** cache_read + cache_creation + input on the most recent assistant turn seen. */\n context: number | null;\n /** True when the last line in the file shows the session still working: an\n * assistant turn with no usage yet (streaming) or a stop_reason other than\n * end_turn/stop_sequence (e.g. mid tool-call), or a real user prompt with\n * no reply behind it yet. A beat sent now would land mid-generation. */\n midTurn: boolean;\n /** The last real (human-authored) user prompt seen, if any. */\n lastRealPrompt: { key: string; text: string } | null;\n}\n\nfunction isMidTurn(line: AssistantLine | null): boolean {\n if (!line) return false;\n if (line.type === \"user\" && isRealUserPrompt(line)) return true;\n if (line.type === \"assistant\") {\n const stopReason = line.message?.stop_reason;\n if (stopReason === undefined || stopReason === null) return true;\n return stopReason !== \"end_turn\" && stopReason !== \"stop_sequence\";\n }\n return false;\n}\n\nexport function readTranscriptSnapshot(path: string): TranscriptSnapshot {\n let lines: string[];\n try {\n lines = readFileSync(path, \"utf8\").split(\"\\n\");\n } catch {\n return { context: null, midTurn: false, lastRealPrompt: null };\n }\n\n let lastLine: AssistantLine | null = null;\n let context: number | null = null;\n let lastRealPrompt: { key: string; text: string } | null = null;\n\n for (let i = lines.length - 1; i >= 0; i--) {\n const raw = lines[i].trim();\n if (!raw) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (lastLine === null) lastLine = obj;\n\n if (context === null && obj.type === \"assistant\" && obj.message?.usage) {\n const usage = obj.message.usage;\n context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n }\n\n if (lastRealPrompt === null && isRealUserPrompt(obj)) {\n const key = obj.uuid ?? obj.timestamp ?? String(i);\n lastRealPrompt = { key, text: extractUserPromptText(obj).trim() };\n }\n\n if (context !== null && lastRealPrompt !== null) break;\n }\n\n return { context, midTurn: isMidTurn(lastLine), lastRealPrompt };\n}\n\n// ---------------------------------------------------------------------------\n// Ledger — same file WORKER-KEEPALIVE lines go to (workers.logDir/ledger.log)\n// ---------------------------------------------------------------------------\n\n/** Ledger event tag; `pai daemon keepalive` and any log-tailer key off this. */\nexport const SESSION_KEEPALIVE_EVENT = \"SESSION-KEEPALIVE\";\n\nexport function sessionKeepaliveLedgerPath(): string {\n const { workers } = readWorkersSection();\n return join(workersLogDir(workers), \"ledger.log\");\n}\n\n/** The counts/last lines `pai daemon keepalive` prints, scoped to this event. */\nexport function sessionKeepaliveLedgerSummary(\n path: string = sessionKeepaliveLedgerPath(),\n lastN = 10\n): { sent: number; skipped: number; lastLines: string[] } {\n if (!existsSync(path)) return { sent: 0, skipped: 0, lastLines: [] };\n const lines = readFileSync(path, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.includes(SESSION_KEEPALIVE_EVENT));\n const sent = lines.filter((l) => / result=sent(\\s|$)/.test(l)).length;\n return { sent, skipped: lines.length - sent, lastLines: lines.slice(-lastN) };\n}\n\n// ---------------------------------------------------------------------------\n// The tick\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveDeps {\n now: () => Date;\n fetchLiveSessions: () => Promise<AiBrokerSessionMeta[]>;\n /** AIBroker pane id (fetchLiveSessions' `sessionId`) -> Claude transcript\n * session id, via the status line's claude-session-map.json bridge. */\n resolveClaudeSessionId: (paneId: string) => string | null;\n findTranscript: (sessionId: string) => string | null;\n mtimeMs: (path: string) => number | null;\n readSnapshot: (path: string) => TranscriptSnapshot;\n sendBeat: (sessionId: string, text: string) => Promise<{ ok: boolean; error?: string }>;\n loadState: () => SessionKeepaliveState;\n saveState: (s: SessionKeepaliveState) => void;\n ledger: (kv: Record<string, string | number | null | undefined>) => void;\n}\n\nexport interface SessionKeepaliveResult {\n sessionId: string;\n result: \"sent\" | string; // \"skipped:<reason>\"\n}\n\nfunction defaultDeps(): SessionKeepaliveDeps {\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n return {\n now: () => new Date(),\n fetchLiveSessions: fetchLiveSessionsDefault,\n resolveClaudeSessionId: (paneId) => resolveClaudeSessionIdFromMap(paneId, logDir),\n findTranscript: (id) => findSessionTranscript(id),\n mtimeMs: (p) => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return null;\n }\n },\n readSnapshot: readTranscriptSnapshot,\n sendBeat: (id, text) => sendToSessionDefault(id, text, undefined, { noReply: true }),\n loadState: () => loadSessionKeepaliveState(),\n saveState: (s) => saveSessionKeepaliveState(s),\n ledger: (kv) => appendLedger(sessionKeepaliveLedgerPath(), SESSION_KEEPALIVE_EVENT, kv),\n };\n}\n\n/**\n * One tick over every live interactive session: beat the ones that qualify,\n * skip (with a reason) the ones that don't. Returns one result per live\n * \"claude\"-kind session for tests to assert against; writes the ledger line\n * and (when any counter changed) the state file as side effects.\n */\nexport async function runSessionKeepaliveTick(\n config: SessionsCacheKeepaliveConfig,\n overrides: Partial<SessionKeepaliveDeps> = {}\n): Promise<SessionKeepaliveResult[]> {\n if (!config.enabled) return [];\n\n const d: SessionKeepaliveDeps = { ...defaultDeps(), ...overrides };\n const sessions = await d.fetchLiveSessions();\n const state = d.loadState();\n const now = d.now();\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n const results: SessionKeepaliveResult[] = [];\n let anyChanged = false;\n\n for (const s of sessions) {\n if (s.kind !== \"claude\") continue;\n const paneId = s.sessionId;\n\n const claudeId = d.resolveClaudeSessionId(paneId);\n if (!claudeId) {\n d.ledger({ session: null, pane: paneId, result: \"skipped:unmapped\" });\n results.push({ sessionId: paneId, result: \"skipped:unmapped\" });\n continue;\n }\n const sessionId = claudeId;\n const entry = { ...(state[sessionId] ?? emptyEntry()) };\n let changed = false;\n\n const transcript = d.findTranscript(sessionId);\n\n if (isWorkerSession(s, transcript, logDir)) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:worker\" });\n results.push({ sessionId, result: \"skipped:worker\" });\n continue;\n }\n\n if (!transcript) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:no-transcript\" });\n results.push({ sessionId, result: \"skipped:no-transcript\" });\n continue;\n }\n\n const snapshot = d.readSnapshot(transcript);\n\n // A fresh real prompt (not the keepalive's own echo) resets the beat\n // count — the idle stretch it capped is over.\n if (snapshot.lastRealPrompt && snapshot.lastRealPrompt.key !== entry.lastRealPromptKey) {\n entry.lastRealPromptKey = snapshot.lastRealPrompt.key;\n if (snapshot.lastRealPrompt.text !== config.prompt) entry.beats = 0;\n changed = true;\n }\n\n const mtime = d.mtimeMs(transcript);\n const idleMin = mtime === null ? null : (now.getTime() - mtime) / 60_000;\n\n const skip: string | null =\n mtime === null || idleMin === null\n ? \"no-mtime\"\n : idleMin < config.idleMinutes\n ? `idle:${idleMin.toFixed(1)}min`\n : !isWithinActiveHours(now, config.activeHours)\n ? \"hours\"\n : snapshot.context === null || snapshot.context < config.minContextTokens\n ? \"context\"\n : snapshot.midTurn\n ? \"mid-turn\"\n : entry.beats >= config.maxBeats\n ? \"max-beats\"\n : null;\n\n const ledgerBase = {\n session: sessionId,\n pane: paneId,\n idle_min: idleMin === null ? null : idleMin.toFixed(1),\n context: snapshot.context,\n };\n\n if (skip) {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: `skipped:${skip}` });\n results.push({ sessionId, result: `skipped:${skip}` });\n } else {\n const sent = await d.sendBeat(paneId, config.prompt);\n if (sent.ok) {\n entry.beats += 1;\n entry.lastBeatAt = now.toISOString();\n changed = true;\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"sent\" });\n results.push({ sessionId, result: \"sent\" });\n } else {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"skipped:send-failed\" });\n results.push({ sessionId, result: \"skipped:send-failed\" });\n }\n }\n\n if (changed) {\n state[sessionId] = entry;\n anyChanged = true;\n }\n }\n\n if (anyChanged) d.saveState(state);\n return results;\n}\n\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"session-keepalive-DxjTHGcK.mjs","names":["fetchLiveSessionsDefault","sendToSessionDefault"],"sources":["../src/audit/session-usage.ts","../src/daemon/session-keepalive.ts"],"sourcesContent":["/**\n * session-usage.ts — parse the usage numbers out of a Claude Code session\n * (or subagent, or pai-worker event-mirror) JSONL transcript.\n *\n * All three log shapes share the same assistant-message envelope\n * ({ type: \"assistant\", message: { id, model, usage } }), so one parser\n * covers `pai audit tokens session` and the per-log readings feeding\n * `pai audit tokens spawn`.\n *\n * Streaming writes one JSONL line per content block of the same logical\n * turn, repeating message.id and usage each time — summing every line would\n * multiply usage by the block count. Keeping only the first line seen per\n * message.id is what the reference script (and this parser) count instead.\n */\n\nimport { createReadStream, existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { readFileSync } from \"node:fs\";\n\nexport interface UsageTotals {\n cache_read_input_tokens: number;\n cache_creation_input_tokens: number;\n input_tokens: number;\n output_tokens: number;\n}\n\nexport interface CacheCreationSplit {\n ephemeral5m: number;\n ephemeral1h: number;\n}\n\nexport interface CompactionEvent {\n trigger: string;\n preTokens: number;\n turnIndex: number;\n}\n\nexport interface ModelSwitch {\n turnIndex: number;\n from: string;\n to: string;\n cacheRead: number;\n cacheCreation: number;\n}\n\nexport interface FallbackEvent {\n turnIndex: number;\n from: string;\n to: string;\n category: string;\n scope: string;\n}\n\nexport interface SessionUsageReport {\n path: string;\n sizeBytes: number;\n turns: number;\n totals: UsageTotals;\n /** Per-model assistant-turn counts. */\n models: Record<string, number>;\n /** cache_read + cache_creation + input on the first / last assistant turn seen. */\n firstTurnContext: number | null;\n lastTurnContext: number | null;\n cacheCreationSplit: CacheCreationSplit;\n /** Average / max of the per-turn context value across all turns; null if no turns. */\n avgContext: number | null;\n maxContext: number | null;\n /** Turns whose per-turn context value exceeds the report's threshold. */\n turnsAboveThreshold: number;\n /** Turns whose cache_creation_input_tokens exceeds 20000. */\n cacheRebuildTurns: number;\n /** Real human-authored user prompts (excludes tool-result-only \"user\" lines). */\n userPrompts: number;\n /**\n * Sum over assistant turns of the real user prompts seen before that turn;\n * multiplied by per-prompt hook tokens it gives the tokens UserPromptSubmit\n * output occupied across the whole session.\n */\n promptExposure: number;\n compactions: CompactionEvent[];\n /** ISO timestamp of the first assistant turn. */\n firstTurnAt: string | null;\n /** Model of the last folded turn; drives switch detection. */\n lastModel: string | null;\n modelSwitches: ModelSwitch[];\n fallbacks: FallbackEvent[];\n /** Timestamp (ms) of the last folded assistant turn; drives idle-gap detection. */\n lastTurnAtMs: number | null;\n /** Gaps between consecutive assistant turns exceeding 60 minutes — evidence\n * for whether the session ever went idle long enough to risk its cache TTL\n * (see sessions.cacheKeepalive, src/daemon/config.ts). */\n idleGapsOver60min: number;\n /** Real user prompts whose text is exactly the configured keepalive word\n * (set only when parseSessionUsage is called with one); null otherwise. */\n keepaliveBeats: number | null;\n}\n\nconst USAGE_KEYS: (keyof UsageTotals)[] = [\n \"cache_read_input_tokens\",\n \"cache_creation_input_tokens\",\n \"input_tokens\",\n \"output_tokens\",\n];\n\nfunction emptyTotals(): UsageTotals {\n return { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, input_tokens: 0, output_tokens: 0 };\n}\n\nexport interface AssistantLine {\n type?: string;\n uuid?: string;\n subtype?: string;\n /** Claude Code marks skill expansions and injected system reminders isMeta; they fire no UserPromptSubmit hook. */\n isMeta?: boolean;\n /** Present on newer logs: { kind: \"human\" } for a typed prompt. */\n origin?: { kind?: string };\n compactMetadata?: { trigger?: string; preTokens?: number };\n timestamp?: string;\n originalModel?: string;\n fallbackModel?: string;\n apiRefusalCategory?: string;\n scope?: string;\n message?: {\n id?: string;\n model?: string;\n usage?: Record<string, unknown> & {\n cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };\n };\n content?: string | Array<{ type?: string }>;\n stop_reason?: string | null;\n };\n}\n\n/**\n * `type:\"system\"` `subtype:\"compact_boundary\"` lines mark a compaction.\n * turnIndex is the count of assistant turns already folded when it fired,\n * so it lines up with the turn numbering the text/JSON report prints.\n */\nexport function isCompactBoundary(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"compact_boundary\";\n}\n\nexport function parseCompactionEvent(line: AssistantLine, turnIndex: number): CompactionEvent | null {\n const meta = line.compactMetadata;\n if (!meta || typeof meta.preTokens !== \"number\") return null;\n return { trigger: meta.trigger ?? \"unknown\", preTokens: meta.preTokens, turnIndex };\n}\n\n/**\n * `type:\"system\"` `subtype:\"model_refusal_fallback\"` lines mark an\n * automatic model switch fired by an API safety refusal (e.g. \"cyber\"\n * category), not a user or config choice — worth flagging separately since\n * it can rebuild the whole prompt cache mid-session.\n */\nexport function isModelFallback(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"model_refusal_fallback\";\n}\n\nexport function parseFallbackEvent(line: AssistantLine, turnIndex: number): FallbackEvent {\n return {\n turnIndex,\n from: line.originalModel ?? \"unknown\",\n to: line.fallbackModel ?? \"unknown\",\n category: line.apiRefusalCategory ?? \"unknown\",\n scope: line.scope ?? \"unknown\",\n };\n}\n\n/**\n * Fold one already-parsed JSONL line into a report being accumulated.\n * Exported separately so both the streaming file reader below and tests\n * (which build fixtures as arrays of objects, not files) share one path.\n * Returns the turn's context value when a new turn was counted, else null\n * (non-assistant line, no usage, or a duplicate message.id already seen).\n */\nexport function foldAssistantLine(\n report: Pick<\n SessionUsageReport,\n | \"turns\"\n | \"totals\"\n | \"models\"\n | \"firstTurnContext\"\n | \"lastTurnContext\"\n | \"cacheCreationSplit\"\n | \"maxContext\"\n | \"turnsAboveThreshold\"\n | \"cacheRebuildTurns\"\n | \"firstTurnAt\"\n | \"lastModel\"\n | \"modelSwitches\"\n | \"lastTurnAtMs\"\n | \"idleGapsOver60min\"\n >,\n seenIds: Set<string>,\n line: AssistantLine,\n threshold: number\n): number | null {\n if (line.type !== \"assistant\") return null;\n const message = line.message;\n const usage = message?.usage;\n if (!usage) return null;\n const id = message?.id ?? line.uuid;\n if (id) {\n if (seenIds.has(id)) return null;\n seenIds.add(id);\n }\n report.turns++;\n for (const key of USAGE_KEYS) {\n const v = usage[key];\n if (typeof v === \"number\") report.totals[key] += v;\n }\n const context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n if (report.firstTurnContext === null) {\n report.firstTurnContext = context;\n report.firstTurnAt = line.timestamp ?? null;\n }\n const atMs = line.timestamp ? Date.parse(line.timestamp) : NaN;\n if (!Number.isNaN(atMs)) {\n if (report.lastTurnAtMs !== null && atMs - report.lastTurnAtMs > 60 * 60 * 1000) {\n report.idleGapsOver60min++;\n }\n report.lastTurnAtMs = atMs;\n }\n report.lastTurnContext = context;\n report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);\n if (context > threshold) report.turnsAboveThreshold++;\n if ((Number(usage.cache_creation_input_tokens) || 0) > 20000) report.cacheRebuildTurns++;\n const model = message?.model ?? \"unknown\";\n report.models[model] = (report.models[model] ?? 0) + 1;\n if (report.lastModel !== null && report.lastModel !== model) {\n report.modelSwitches.push({\n turnIndex: report.turns,\n from: report.lastModel,\n to: model,\n cacheRead: Number(usage.cache_read_input_tokens) || 0,\n cacheCreation: Number(usage.cache_creation_input_tokens) || 0,\n });\n }\n report.lastModel = model;\n const split = usage.cache_creation;\n if (split) {\n report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;\n report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;\n }\n return context;\n}\n\n/**\n * True for a real human-authored `type:\"user\"` prompt line: content is a\n * plain string, or a content array with no `tool_result` block. Claude Code\n * encodes tool results as `type:\"user\"` messages whose content array is\n * entirely (or partly) tool_result blocks — those must not count as prompts.\n */\nexport function isRealUserPrompt(line: AssistantLine): boolean {\n if (line.type !== \"user\") return false;\n if (line.isMeta) return false;\n if (line.origin?.kind && line.origin.kind !== \"human\") return false;\n const content = line.message?.content;\n if (typeof content === \"string\") return true;\n if (Array.isArray(content)) return !content.some((block) => block?.type === \"tool_result\");\n return false;\n}\n\n/** Plain text of a real user prompt: the string content, or its first text block. */\nexport function extractUserPromptText(line: AssistantLine): string {\n const content = line.message?.content;\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n const block = content.find((b) => b?.type === \"text\") as { text?: string } | undefined;\n return block?.text ?? \"\";\n }\n return \"\";\n}\n\n/**\n * Parse a session/subagent/worker-event JSONL file into a usage report.\n * `keepaliveWord`, when given, counts real user prompts whose text exactly\n * matches it (trimmed) — the sessions.cacheKeepalive beat prompt — into\n * `keepaliveBeats`; omitted, `keepaliveBeats` stays null.\n */\nexport async function parseSessionUsage(\n path: string,\n threshold = 200_000,\n keepaliveWord?: string\n): Promise<SessionUsageReport> {\n const report: SessionUsageReport = {\n path,\n sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,\n turns: 0,\n totals: emptyTotals(),\n models: {},\n firstTurnContext: null,\n lastTurnContext: null,\n cacheCreationSplit: { ephemeral5m: 0, ephemeral1h: 0 },\n avgContext: null,\n maxContext: null,\n turnsAboveThreshold: 0,\n cacheRebuildTurns: 0,\n userPrompts: 0,\n promptExposure: 0,\n compactions: [],\n firstTurnAt: null,\n lastModel: null,\n modelSwitches: [],\n fallbacks: [],\n lastTurnAtMs: null,\n idleGapsOver60min: 0,\n keepaliveBeats: keepaliveWord ? 0 : null,\n };\n const seenIds = new Set<string>();\n let contextSum = 0;\n\n const rl = createInterface({ input: createReadStream(path, \"utf8\"), crlfDelay: Infinity });\n for await (const raw of rl) {\n if (!raw.trim()) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (obj.type === \"user\") {\n if (isRealUserPrompt(obj)) {\n report.userPrompts++;\n if (keepaliveWord && extractUserPromptText(obj).trim() === keepaliveWord) {\n report.keepaliveBeats = (report.keepaliveBeats ?? 0) + 1;\n }\n }\n continue;\n }\n if (isCompactBoundary(obj)) {\n const event = parseCompactionEvent(obj, report.turns);\n if (event) report.compactions.push(event);\n continue;\n }\n if (isModelFallback(obj)) {\n report.fallbacks.push(parseFallbackEvent(obj, report.turns));\n continue;\n }\n const context = foldAssistantLine(report, seenIds, obj, threshold);\n if (context !== null) {\n contextSum += context;\n report.promptExposure += report.userPrompts;\n }\n }\n report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;\n return report;\n}\n\nexport function totalUsageTokens(totals: UsageTotals): number {\n return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;\n}\n","/**\n * session-keepalive.ts — idle-triggered prompt-cache keepalive beat for live\n * interactive Claude Code sessions (`sessions.cacheKeepalive`, see config.ts\n * and docs/cache-keepalive.md, \"Interactive sessions\").\n *\n * One beat = one trivial prompt typed into a session through AIBroker's\n * send_to_session (src/cli/lib/aibroker-client.ts), sent with `noReply: true`\n * so the target sees only the bare word typed into its input line and\n * nothing is queued into its mailbox as a peer message demanding a reply.\n * A cache READ refreshes the provider's ephemeral prompt-cache TTL at\n * roughly 0.1x the cost of the 2x rewrite a cold cache forces on the next\n * real prompt.\n *\n * Distinct from workers/keepalive.ts, which beats a *worker provider's*\n * cache on a fixed timer regardless of activity: this only beats a session\n * that has actually been idle long enough to be at risk, and only inside a\n * configured working-hours window, so it never fires while the user is\n * present at the keyboard or overnight when nobody will read the reply\n * before the cache would have expired anyway.\n */\n\nimport { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport {\n fetchLiveSessions as fetchLiveSessionsDefault,\n sendToSession as sendToSessionDefault,\n type AiBrokerSessionMeta,\n} from \"../cli/lib/aibroker-client.js\";\nimport { writeJsonAtomic } from \"../config/json-store.js\";\nimport { paiHomePath } from \"../config/pai-home.js\";\nimport { appendLedger } from \"../workers/ledger.js\";\nimport { readWorkersSection } from \"../workers/config.js\";\nimport { workersLogDir } from \"../workers/paths.js\";\nimport { worktreesDir } from \"../workers/worktree.js\";\nimport { itermUuid, sessionMapPath, type SessionMapEntry } from \"../workers/scope.js\";\nimport { isRealUserPrompt, extractUserPromptText, type AssistantLine } from \"../audit/session-usage.js\";\nimport type { SessionsCacheKeepaliveConfig } from \"./config.js\";\n\n// ---------------------------------------------------------------------------\n// State file — per-session beat counters, rebuildable (see json-store.ts on\n// when NOT to use readJsonStrict: a damaged file here just resets counters,\n// never blocks the feature).\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveEntry {\n /** Beats sent since the last real (non-keepalive) user prompt. */\n beats: number;\n /** Identity of the last real user prompt observed, so a fresh one can be\n * told apart from the keepalive's own echo landing back in the transcript. */\n lastRealPromptKey: string | null;\n /** ISO stamp of the last beat sent. */\n lastBeatAt: string | null;\n}\n\nexport type SessionKeepaliveState = Record<string, SessionKeepaliveEntry>;\n\nexport function sessionKeepaliveStatePath(): string {\n return paiHomePath(\"session-keepalive.json\");\n}\n\nfunction emptyEntry(): SessionKeepaliveEntry {\n return { beats: 0, lastRealPromptKey: null, lastBeatAt: null };\n}\n\nexport function loadSessionKeepaliveState(path: string = sessionKeepaliveStatePath()): SessionKeepaliveState {\n if (!existsSync(path)) return {};\n try {\n return JSON.parse(readFileSync(path, \"utf8\")) as SessionKeepaliveState;\n } catch {\n return {};\n }\n}\n\nexport function saveSessionKeepaliveState(\n state: SessionKeepaliveState,\n path: string = sessionKeepaliveStatePath()\n): void {\n writeJsonAtomic(path, state, { backup: false, label: path });\n}\n\n// ---------------------------------------------------------------------------\n// Active-hours window\n// ---------------------------------------------------------------------------\n\n/** Parse \"HH:MM-HH:MM\" into minutes-since-midnight. Throws on a malformed\n * spec — an explicit config error beats a window that is silently always\n * on or always off. */\nexport function parseActiveHours(spec: string): { startMin: number; endMin: number } {\n const m = spec.match(/^(\\d{1,2}):(\\d{2})-(\\d{1,2}):(\\d{2})$/);\n if (!m) {\n throw new Error(`sessions.cacheKeepalive.activeHours: invalid \"${spec}\" (want \"HH:MM-HH:MM\")`);\n }\n return {\n startMin: Number(m[1]) * 60 + Number(m[2]),\n endMin: Number(m[3]) * 60 + Number(m[4]),\n };\n}\n\n/**\n * Whether `now` (local time) sits inside the window. A window that wraps\n * midnight (e.g. \"22:00-06:00\") is honoured by inverting the test instead of\n * requiring startMin < endMin.\n */\nexport function isWithinActiveHours(now: Date, spec: string): boolean {\n const { startMin, endMin } = parseActiveHours(spec);\n const nowMin = now.getHours() * 60 + now.getMinutes();\n if (startMin <= endMin) return nowMin >= startMin && nowMin < endMin;\n return nowMin >= startMin || nowMin < endMin;\n}\n\n// ---------------------------------------------------------------------------\n// Transcript lookup\n// ---------------------------------------------------------------------------\n\n/**\n * Full path to a live session's transcript under ~/.claude/projects, or null\n * when none is found — the case for a session too new to have written a file\n * yet. A worker running in a worktree DOES write a transcript here (Claude\n * Code encodes its worktree cwd as the project dir name), so workers are not\n * excluded \"for free\" — see isWorkerSession.\n */\nexport function findSessionTranscript(\n sessionId: string,\n projectsDir: string = join(homedir(), \".claude\", \"projects\")\n): string | null {\n let projectDirs: string[];\n try {\n projectDirs = readdirSync(projectsDir);\n } catch {\n return null;\n }\n for (const projectDir of projectDirs) {\n const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`);\n if (existsSync(candidate)) return candidate;\n }\n return null;\n}\n\n/**\n * AIBroker's `fetchLiveSessions()` identifies a session by its iTerm2 pane id\n * (e.g. an AIBroker/iTerm UUID), not the Claude session id `<uuid>.jsonl`\n * transcripts are named after — the two are unrelated identifiers. The\n * status line bridges them on every refresh via `claude-session-map.json`\n * (see `recordSessionMapEntry` in ../workers/scope.ts): this picks, among the\n * map entries whose `term` pane UUID matches, the most recently written one.\n * Returns null when the pane has no (fresh enough) mapped Claude session.\n */\nexport function resolveClaudeSessionIdFromMap(paneId: string, logDir: string): string | null {\n const path = sessionMapPath(logDir);\n if (!paneId || !existsSync(path)) return null;\n let map: Record<string, SessionMapEntry>;\n try {\n map = JSON.parse(readFileSync(path, \"utf8\")) as Record<string, SessionMapEntry>;\n } catch {\n return null;\n }\n let best: SessionMapEntry | null = null;\n for (const entry of Object.values(map)) {\n if (!entry.term || itermUuid(entry.term) !== paneId) continue;\n if (!best || entry.ts > best.ts) best = entry;\n }\n return best?.session ?? null;\n}\n\n/** Claude Code's project-dir encoding of a cwd: every \"/\" becomes \"-\". */\nfunction encodeProjectDirName(cwd: string): string {\n return cwd.replace(/\\//g, \"-\");\n}\n\n/**\n * Is this live \"claude\"-kind session actually a worker pane rather than an\n * interactive one? `claude -p` workers running in a worktree write their\n * transcript under ~/.claude/projects too (encoded cwd = the worktree dir),\n * so a missing transcript is NOT how workers get excluded — this predicate\n * is. Either signal is enough:\n * (a) the transcript's project-dir name is the encoded form of a path\n * under <logDir>/worktrees (every worker worktree lives there), or\n * (b) the broker's session name/paiName names a path under the workers\n * log dir or one of its worktrees (best-effort: AIBroker does not\n * guarantee this, but honors it when present).\n */\nexport function isWorkerSession(\n meta: AiBrokerSessionMeta,\n transcriptPath: string | null,\n logDir: string\n): boolean {\n const worktreesPrefix = encodeProjectDirName(worktreesDir(logDir));\n if (transcriptPath) {\n const projectDirName = transcriptPath.split(\"/\").slice(0, -1).pop() ?? \"\";\n if (projectDirName.startsWith(worktreesPrefix)) return true;\n }\n const rawPrefix = worktreesDir(logDir);\n for (const field of [meta.name, meta.paiName]) {\n if (field && (field.includes(rawPrefix) || field.includes(logDir))) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Tail snapshot: last turn's context, mid-turn flag, last real prompt\n// ---------------------------------------------------------------------------\n\nexport interface TranscriptSnapshot {\n /** cache_read + cache_creation + input on the most recent assistant turn seen. */\n context: number | null;\n /** True when the last line in the file shows the session still working: an\n * assistant turn with no usage yet (streaming) or a stop_reason other than\n * end_turn/stop_sequence (e.g. mid tool-call), or a real user prompt with\n * no reply behind it yet. A beat sent now would land mid-generation. */\n midTurn: boolean;\n /** The last real (human-authored) user prompt seen, if any. */\n lastRealPrompt: { key: string; text: string } | null;\n}\n\nfunction isMidTurn(line: AssistantLine | null): boolean {\n if (!line) return false;\n if (line.type === \"user\" && isRealUserPrompt(line)) return true;\n if (line.type === \"assistant\") {\n const stopReason = line.message?.stop_reason;\n if (stopReason === undefined || stopReason === null) return true;\n return stopReason !== \"end_turn\" && stopReason !== \"stop_sequence\";\n }\n return false;\n}\n\nexport function readTranscriptSnapshot(path: string): TranscriptSnapshot {\n let lines: string[];\n try {\n lines = readFileSync(path, \"utf8\").split(\"\\n\");\n } catch {\n return { context: null, midTurn: false, lastRealPrompt: null };\n }\n\n let lastLine: AssistantLine | null = null;\n let context: number | null = null;\n let lastRealPrompt: { key: string; text: string } | null = null;\n\n for (let i = lines.length - 1; i >= 0; i--) {\n const raw = lines[i].trim();\n if (!raw) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (lastLine === null) lastLine = obj;\n\n if (context === null && obj.type === \"assistant\" && obj.message?.usage) {\n const usage = obj.message.usage;\n context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n }\n\n if (lastRealPrompt === null && isRealUserPrompt(obj)) {\n const key = obj.uuid ?? obj.timestamp ?? String(i);\n lastRealPrompt = { key, text: extractUserPromptText(obj).trim() };\n }\n\n if (context !== null && lastRealPrompt !== null) break;\n }\n\n return { context, midTurn: isMidTurn(lastLine), lastRealPrompt };\n}\n\n// ---------------------------------------------------------------------------\n// Ledger — same file WORKER-KEEPALIVE lines go to (workers.logDir/ledger.log)\n// ---------------------------------------------------------------------------\n\n/** Ledger event tag; `pai daemon keepalive` and any log-tailer key off this. */\nexport const SESSION_KEEPALIVE_EVENT = \"SESSION-KEEPALIVE\";\n\nexport function sessionKeepaliveLedgerPath(): string {\n const { workers } = readWorkersSection();\n return join(workersLogDir(workers), \"ledger.log\");\n}\n\n/** The counts/last lines `pai daemon keepalive` prints, scoped to this event. */\nexport function sessionKeepaliveLedgerSummary(\n path: string = sessionKeepaliveLedgerPath(),\n lastN = 10\n): { sent: number; skipped: number; lastLines: string[] } {\n if (!existsSync(path)) return { sent: 0, skipped: 0, lastLines: [] };\n const lines = readFileSync(path, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.includes(SESSION_KEEPALIVE_EVENT));\n const sent = lines.filter((l) => / result=sent(\\s|$)/.test(l)).length;\n return { sent, skipped: lines.length - sent, lastLines: lines.slice(-lastN) };\n}\n\n// ---------------------------------------------------------------------------\n// The tick\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveDeps {\n now: () => Date;\n fetchLiveSessions: () => Promise<AiBrokerSessionMeta[]>;\n /** AIBroker pane id (fetchLiveSessions' `sessionId`) -> Claude transcript\n * session id, via the status line's claude-session-map.json bridge. */\n resolveClaudeSessionId: (paneId: string) => string | null;\n findTranscript: (sessionId: string) => string | null;\n mtimeMs: (path: string) => number | null;\n readSnapshot: (path: string) => TranscriptSnapshot;\n sendBeat: (sessionId: string, text: string) => Promise<{ ok: boolean; error?: string }>;\n loadState: () => SessionKeepaliveState;\n saveState: (s: SessionKeepaliveState) => void;\n ledger: (kv: Record<string, string | number | null | undefined>) => void;\n}\n\nexport interface SessionKeepaliveResult {\n sessionId: string;\n result: \"sent\" | string; // \"skipped:<reason>\"\n}\n\nfunction defaultDeps(): SessionKeepaliveDeps {\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n return {\n now: () => new Date(),\n fetchLiveSessions: fetchLiveSessionsDefault,\n resolveClaudeSessionId: (paneId) => resolveClaudeSessionIdFromMap(paneId, logDir),\n findTranscript: (id) => findSessionTranscript(id),\n mtimeMs: (p) => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return null;\n }\n },\n readSnapshot: readTranscriptSnapshot,\n sendBeat: (id, text) => sendToSessionDefault(id, text, undefined, { noReply: true }),\n loadState: () => loadSessionKeepaliveState(),\n saveState: (s) => saveSessionKeepaliveState(s),\n ledger: (kv) => appendLedger(sessionKeepaliveLedgerPath(), SESSION_KEEPALIVE_EVENT, kv),\n };\n}\n\n/**\n * One tick over every live interactive session: beat the ones that qualify,\n * skip (with a reason) the ones that don't. Returns one result per live\n * \"claude\"-kind session for tests to assert against; writes the ledger line\n * and (when any counter changed) the state file as side effects.\n */\nexport async function runSessionKeepaliveTick(\n config: SessionsCacheKeepaliveConfig,\n overrides: Partial<SessionKeepaliveDeps> = {}\n): Promise<SessionKeepaliveResult[]> {\n if (!config.enabled) return [];\n\n const d: SessionKeepaliveDeps = { ...defaultDeps(), ...overrides };\n const sessions = await d.fetchLiveSessions();\n const state = d.loadState();\n const now = d.now();\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n const results: SessionKeepaliveResult[] = [];\n let anyChanged = false;\n\n for (const s of sessions) {\n if (s.kind !== \"claude\") continue;\n const paneId = s.sessionId;\n\n const claudeId = d.resolveClaudeSessionId(paneId);\n if (!claudeId) {\n d.ledger({ session: null, pane: paneId, result: \"skipped:unmapped\" });\n results.push({ sessionId: paneId, result: \"skipped:unmapped\" });\n continue;\n }\n const sessionId = claudeId;\n const entry = { ...(state[sessionId] ?? emptyEntry()) };\n let changed = false;\n\n const transcript = d.findTranscript(sessionId);\n\n if (isWorkerSession(s, transcript, logDir)) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:worker\" });\n results.push({ sessionId, result: \"skipped:worker\" });\n continue;\n }\n\n if (!transcript) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:no-transcript\" });\n results.push({ sessionId, result: \"skipped:no-transcript\" });\n continue;\n }\n\n const snapshot = d.readSnapshot(transcript);\n\n // A fresh real prompt (not the keepalive's own echo) resets the beat\n // count — the idle stretch it capped is over.\n if (snapshot.lastRealPrompt && snapshot.lastRealPrompt.key !== entry.lastRealPromptKey) {\n entry.lastRealPromptKey = snapshot.lastRealPrompt.key;\n if (snapshot.lastRealPrompt.text !== config.prompt) entry.beats = 0;\n changed = true;\n }\n\n const mtime = d.mtimeMs(transcript);\n const idleMin = mtime === null ? null : (now.getTime() - mtime) / 60_000;\n\n const skip: string | null =\n mtime === null || idleMin === null\n ? \"no-mtime\"\n : idleMin < config.idleMinutes\n ? `idle:${idleMin.toFixed(1)}min`\n : !isWithinActiveHours(now, config.activeHours)\n ? \"hours\"\n : snapshot.context === null || snapshot.context < config.minContextTokens\n ? \"context\"\n : snapshot.midTurn\n ? \"mid-turn\"\n : entry.beats >= config.maxBeats\n ? \"max-beats\"\n : null;\n\n const ledgerBase = {\n session: sessionId,\n pane: paneId,\n idle_min: idleMin === null ? null : idleMin.toFixed(1),\n context: snapshot.context,\n };\n\n if (skip) {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: `skipped:${skip}` });\n results.push({ sessionId, result: `skipped:${skip}` });\n } else {\n const sent = await d.sendBeat(paneId, config.prompt);\n if (sent.ok) {\n entry.beats += 1;\n entry.lastBeatAt = now.toISOString();\n changed = true;\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"sent\" });\n results.push({ sessionId, result: \"sent\" });\n } else {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"skipped:send-failed\" });\n results.push({ sessionId, result: \"skipped:send-failed\" });\n }\n }\n\n if (changed) {\n state[sessionId] = entry;\n anyChanged = true;\n }\n }\n\n if (anyChanged) d.saveState(state);\n return results;\n}\n\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as archiveSessionFilesToSessionsDir, D as appendCheckpoint, E as addWorkToSessionNote, F as performScan, O as finalizeSessionNote, _ as spawnSummarizer, a as enqueue, b as updateTodoContinue, f as contentToText$2, h as handleSessionSummary, i as dequeue, j as findNotesDir, k as getCurrentNotePath, l as markCompleted, m as getGitContext, n as writeContextHandoverCache, p as findLatestJsonl, r as cleanup, s as hasPendingOrProcessingOfType, t as readContextHandoverCache, u as markFailed, y as sessionIdFromTranscript } from "./context-handover-cache-
|
|
1
|
+
import { A as archiveSessionFilesToSessionsDir, D as appendCheckpoint, E as addWorkToSessionNote, F as performScan, O as finalizeSessionNote, _ as spawnSummarizer, a as enqueue, b as updateTodoContinue, f as contentToText$2, h as handleSessionSummary, i as dequeue, j as findNotesDir, k as getCurrentNotePath, l as markCompleted, m as getGitContext, n as writeContextHandoverCache, p as findLatestJsonl, r as cleanup, s as hasPendingOrProcessingOfType, t as readContextHandoverCache, u as markFailed, y as sessionIdFromTranscript } from "./context-handover-cache-pkHzmL3c.mjs";
|
|
2
2
|
import { d as registryBackend, k as storageBackend } from "./state-HyjqTihC.mjs";
|
|
3
3
|
import { t as detectTopicShift } from "./detector-BRTtAWrM.mjs";
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -564,4 +564,4 @@ function extractWorkFromTranscript(lines) {
|
|
|
564
564
|
|
|
565
565
|
//#endregion
|
|
566
566
|
export { stopWorker as i, notifyNewWork as n, startWorker as r, enqueueRegistryScan as t };
|
|
567
|
-
//# sourceMappingURL=work-queue-worker-
|
|
567
|
+
//# sourceMappingURL=work-queue-worker-4tLa5gpQ.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"work-queue-worker-DZiEMvBQ.mjs","names":["contentToText","contentToText"],"sources":["../src/daemon/templates/context-handover-prompt.ts","../src/daemon/context-handover-worker.ts","../src/daemon/topic-detect-worker.ts","../src/daemon/work-queue-worker.ts"],"sourcesContent":["/**\n * context-handover-prompt.ts — prompt template for the threshold-triggered\n * pre-compaction handover.\n *\n * This is NOT the session-note summary (session-summary-prompt.ts already\n * does that well, and Claude Code's own native compaction summary is also\n * genuinely detailed on file paths and issue numbers). This exists for the\n * one thing neither of those reliably carries: the reasoning behind what\n * happened, which compresses to bullets or vanishes outright once a native\n * compaction fires.\n *\n * FRAMING (lifted from a working example of this exact mechanism, read\n * before writing this prompt): the handover is a DIFF against everything\n * already durable, not a summary of the session. The successor can read\n * git, the tracker, and the notes — so this must not repeat what is\n * recoverable there. What cannot be recovered is what only ever existed in\n * the conversation: reasoning, rejected alternatives, negative results and\n * what they cost, traps discovered but invisible in the repo, and specific\n * values/ids/paths that would otherwise have to be dug up again.\n *\n * Length is not a target. A short session earns a short handover; every\n * line in a long one must be something the successor could not have\n * obtained anywhere else.\n */\n\nexport interface HandoverPromptParams {\n /** Interleaved user + assistant turns, oldest first, each prefixed with\n * who said it — this prompt needs the assistant's reasoning, not just\n * what the user asked for. */\n turns: string[];\n /** Git log output for the session period, if any. */\n gitLog: string;\n cwd: string;\n /** The previous cached handover's summary text, if this session already\n * produced one (the warmup handover, when this call is the refresh).\n * Passed through so the new handover can carry it forward rather than\n * silently dropping everything it already captured — see the \"pointer\n * to the previous handover, kept rather than superseded\" structure. */\n previousHandover?: string;\n}\n\nexport function buildContextHandoverPrompt(params: HandoverPromptParams): string {\n const { turns, gitLog, cwd, previousHandover } = params;\n\n const turnsSection = turns.length > 0\n ? turns.join(\"\\n\\n\")\n : \"(No turns extracted)\";\n\n const gitSection = gitLog.trim() || \"(No git commits during this session)\";\n\n const previousSection = previousHandover\n ? `\\nA PREVIOUS HANDOVER already exists for this session (from an earlier, \\\nlower threshold). Carry it forward — start your response with a one-line \\\npointer to it (\"Carries forward from the previous handover, which stays for \\\nits detail.\") and then write only what has happened SINCE it that a compact \\\nwould also lose. Do not re-derive or repeat what it already covered.\\n\\n\\\nPREVIOUS HANDOVER:\\n${previousHandover}\\n`\n : \"\";\n\n return `This session is approaching a context-window compaction. Write the \\\nhandover that would let a successor continue WITHOUT re-reading this \\\nconversation and WITHOUT asking the user to repeat themselves.\n\nProject directory: ${cwd}\n${previousSection}\nSCOPING RULE — the single most important instruction here: this is a DIFF \\\nagainst everything already durable, not a summary of the session. Assume \\\nthe successor can and will read git history, the issue tracker, and any \\\nnotes files themselves. Do NOT restate anything recoverable there — no file \\\ninventories, no \"implemented X\" restatements, no chronology of commands run. \\\nWrite down ONLY what existed exclusively in this conversation and would \\\notherwise be gone: the reasoning, not the result.\n\nWrite ONLY the sections below that this session actually has content for — \\\nomit a section entirely rather than writing \"none\" or padding it:\n\nWHERE THIS SESSION ENDED — the concrete state right now: what is verified \\\nworking (tests green, tree clean, etc.) versus merely attempted, any branch \\\nor working-tree state that isn't obvious from git status alone (e.g. a \\\ndetached HEAD, an intentionally uncommitted change, a stash).\n\nWHAT CLOSED — each item paired with the evidence that closed it, not just \\\nthe claim that it did.\n\nWHAT IS FILED AND STILL OPEN — each with its next concrete step or who/what \\\nit is waiting on.\n\nDECISIONS & REASONING — choices actually made, each with why this way and \\\nnot another way.\n\nREJECTED ALTERNATIVES — approaches considered and set aside, and why. An \\\nalternative without a reason is not worth recording.\n\nNEGATIVE RESULTS — something investigated that turned out NOT to be the \\\nanswer, kept WITH the reasoning that ruled it out (e.g. \"X looked like the \\\ncause — it wasn't; the actual mechanism was Y\"). Without the reasoning \\\nattached, a successor re-investigates the same dead end.\n\nTRAPS — anything discovered that is invisible from the repo alone and would \\\notherwise bite a successor immediately (a required manual step, an ordering \\\nconstraint, a state that looks fine but isn't).\n\nCRITICAL VALUES — any specific id, path, URL, credential name, version \\\nnumber, or configuration value that appeared in conversation and whose loss \\\nwould force someone to go find it again.\n\nTHE FEW THINGS THAT MATTER MOST, IF NOTHING ELSE IS READ — at most 3 bullets. \\\nForces a priority judgement; do not pad this to fill space.\n\nFormat your response as markdown with those section headers (only the ones \\\nyou have content for), no other structure.\n\n---\n\nCONVERSATION:\n${turnsSection}\n\nGIT COMMITS:\n${gitSection}`;\n}\n","/**\n * context-handover-worker.ts — the threshold-triggered pre-compaction\n * handover.\n *\n * `session-summary-worker.ts` already spawns a model to write session notes,\n * but only from the PreCompact hook itself — which fires at the compaction\n * boundary, far too late to be injected INTO that same compaction's\n * post-compact context. This worker runs earlier, off a token-count\n * threshold (see ../hooks/ts/lib/context-fill.ts), so a model-written\n * handover already exists in a cache file by the time compaction happens.\n *\n * Deliberately narrow: this does not write a session note, does not touch\n * the session-summary cooldown file, and does not run KG extraction. It\n * writes one thing — a cache file the PreCompact hook can read — because its\n * only job is to exist before compaction does, not to duplicate what\n * session-summary-worker already does well.\n *\n * The prompt itself (see templates/context-handover-prompt.ts) treats the\n * handover as a DIFF against everything already durable — git, the tracker,\n * the notes — not a session summary, and a refresh run carries the previous\n * cached handover forward rather than overwriting it.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\n\nimport {\n findLatestJsonl,\n getGitContext,\n spawnSummarizer,\n contentToText,\n} from \"./session-summary-worker.js\";\nimport { buildContextHandoverPrompt } from \"./templates/context-handover-prompt.js\";\nimport {\n readContextHandoverCache,\n writeContextHandoverCache,\n type HandoverThreshold,\n} from \"../hooks/ts/lib/context-handover-cache.js\";\n\nexport type { HandoverThreshold } from \"../hooks/ts/lib/context-handover-cache.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ContextHandoverPayload {\n cwd: string;\n sessionId: string;\n transcriptPath?: string;\n threshold: HandoverThreshold;\n /** True when this fired via the \"no time for a clean crossing\" path\n * (see isImmediate in context-fill.ts) — logged, not otherwise acted on. */\n urgent?: boolean;\n}\n\n/** How much of the tail of the transcript to feed the summarizer. Generous —\n * this runs at most twice per session, not on every tool call. */\nconst MAX_TURN_CHARS = 150_000;\n\n/** Max turns to include (a hard floor even if MAX_TURN_CHARS is not hit). */\nconst MAX_TURNS = 120;\n\n// ---------------------------------------------------------------------------\n// Transcript → turns\n// ---------------------------------------------------------------------------\n\n/**\n * Interleaved \"User:\"/\"Assistant:\" turns, oldest-kept-from-the-end so a long\n * transcript is truncated to its most recent content rather than its\n * earliest. Unlike session-summary-worker's extractFromJsonl (which keeps\n * only user messages, because a session note doesn't need the assistant's\n * own reasoning) this keeps BOTH sides — the whole point of this prompt is\n * the assistant's reasoning, which lives in assistant turns.\n */\nfunction extractTurns(jsonlPath: string): string[] {\n let raw: string;\n try {\n raw = readFileSync(jsonlPath, \"utf-8\");\n } catch {\n return [];\n }\n\n if (raw.length > MAX_TURN_CHARS) {\n const truncPoint = raw.indexOf(\"\\n\", raw.length - MAX_TURN_CHARS);\n raw = truncPoint >= 0 ? raw.slice(truncPoint + 1) : raw.slice(-MAX_TURN_CHARS);\n }\n\n const turns: string[] = [];\n for (const line of raw.trim().split(\"\\n\")) {\n if (!line.trim()) continue;\n let entry: Record<string, unknown>;\n try {\n entry = JSON.parse(line);\n } catch {\n continue;\n }\n\n if (entry.type === \"user\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n const text = contentToText(msg?.content);\n if (text && text.length >= 3 && !text.startsWith(\"<system-reminder>\")) {\n turns.push(`User: ${text.slice(0, 2000)}`);\n }\n } else if (entry.type === \"assistant\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n const text = contentToText(msg?.content);\n if (text && text.length >= 3) {\n turns.push(`Assistant: ${text.slice(0, 4000)}`);\n }\n }\n }\n\n return turns.slice(-MAX_TURNS);\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a `context-handover` work item.\n *\n * THROWS on every failure path (missing input, no transcript, no turns, an\n * empty/failed summarizer run) rather than logging and returning silently.\n * That used to be a deliberate choice — avoid the work queue's own retry\n * piling up redundant LLM calls — but it made a real failure invisible: a\n * daemon restart mid-spawn (session 77084e72-...) left no cache, no error\n * anywhere the work queue's own stats could show, and a trigger-side marker\n * that (at the time) had already been set to \"done\". Throwing here makes\n * work-queue-worker.ts call markFailed(), which logs prominently AND is\n * visible via `work_queue_stats` / `pai daemon status` — not just a stderr\n * line that can scroll away. It is a second, faster (seconds-to-minutes\n * backoff) retry path alongside the primary one in context-handover-\n * trigger.ts (which retries on its own ~5-minute cadence based on whether a\n * cache actually appeared); the two are complementary, not redundant — a\n * failed cache write costs nothing to retry twice.\n */\nexport async function handleContextHandover(payload: ContextHandoverPayload): Promise<void> {\n const { cwd, sessionId, transcriptPath, threshold, urgent } = payload;\n\n if (!cwd || !sessionId) {\n throw new Error(\"[context-handover] payload missing cwd or sessionId\");\n }\n\n process.stderr.write(\n `[context-handover] Starting for ${cwd} (session=${sessionId}, threshold=${threshold}` +\n `${urgent ? \", urgent\" : \"\"}).\\n`\n );\n\n let jsonlPath: string | null = transcriptPath && existsSync(transcriptPath) ? transcriptPath : null;\n if (!jsonlPath) jsonlPath = findLatestJsonl(cwd);\n if (!jsonlPath) {\n throw new Error(`[context-handover] No transcript found for ${cwd} (session=${sessionId})`);\n }\n\n const turns = extractTurns(jsonlPath);\n if (turns.length === 0) {\n throw new Error(`[context-handover] No turns extracted from ${jsonlPath} (session=${sessionId})`);\n }\n\n const gitLog = await getGitContext(cwd);\n\n // Carry the previous handover forward (see buildContextHandoverPrompt's\n // scoping rule) rather than silently overwriting it — a refresh at the\n // second threshold should not lose what the warmup handover already\n // captured.\n const previous = readContextHandoverCache(sessionId);\n const prompt = buildContextHandoverPrompt({\n turns,\n gitLog,\n cwd,\n previousHandover: previous?.summary,\n });\n process.stderr.write(`[context-handover] Sending ${prompt.length} char prompt to sonnet...\\n`);\n\n const summary = await spawnSummarizer(prompt, \"sonnet\");\n if (!summary || !summary.trim()) {\n // No cache write on this path — an existing cache from an earlier\n // successful run is left untouched rather than clobbered with nothing.\n throw new Error(`[context-handover] sonnet produced no output (session=${sessionId})`);\n }\n\n writeContextHandoverCache({\n sessionId,\n cwd,\n threshold,\n generatedAt: new Date().toISOString(),\n model: \"sonnet\",\n summary: summary.trim(),\n });\n\n process.stderr.write(\n `[context-handover] Wrote cache for session ${sessionId} (${summary.length} chars).\\n`\n );\n}\n","/**\n * topic-detect-worker.ts — Topic shift detection for session note splitting\n *\n * Processes `topic-detect` work items by:\n * 1. Extracting recent user messages from the JSONL transcript\n * 2. Running the BM25-based topic shift detector against the PAI memory DB\n * 3. If a shift is detected, recording a topic boundary marker\n *\n * The actual note splitting is handled by session-summary-worker.ts when it\n * processes the next `session-summary` work item — it uses the TOPIC: line\n * from the summarizer to decide whether to create a new note.\n *\n * This worker provides an additional signal: project-level topic shift\n * (e.g., conversation moved from project A to project B). The session\n * summary worker handles intra-project topic shifts (e.g., from \"dark mode\"\n * to \"keyboard IPC\" within the same project).\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join, basename } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nimport { detectTopicShift } from \"../topics/detector.js\";\nimport { registryBackend, storageBackend } from \"./daemon/state.js\";\nimport {\n findNotesDir,\n getCurrentNotePath,\n appendCheckpoint,\n} from \"../hooks/ts/lib/project-utils/index.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicDetectPayload {\n /** Working directory of the session. */\n cwd: string;\n /** Recent conversation context (extracted user messages). */\n context?: string;\n /** The project slug the session is currently routed to. */\n currentProject?: string;\n /** Path to the JSONL transcript (optional — used to extract context if not provided). */\n transcriptPath?: string;\n /** Session ID (for logging). */\n sessionId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// JSONL context extraction (lightweight — just last few user messages)\n// ---------------------------------------------------------------------------\n\nconst MAX_CONTEXT_MESSAGES = 5;\nconst MAX_CONTEXT_CHARS = 2000;\n\n/**\n * Extract recent user messages from a JSONL transcript for topic detection.\n * Takes only the last few messages to represent the current topic.\n */\nfunction extractRecentContext(jsonlPath: string): string {\n try {\n const raw = readFileSync(jsonlPath, \"utf-8\");\n // Read from the end — last 50KB should be more than enough\n const tail = raw.length > 50_000 ? raw.slice(-50_000) : raw;\n const lines = tail.trim().split(\"\\n\");\n\n const messages: string[] = [];\n\n for (let i = lines.length - 1; i >= 0 && messages.length < MAX_CONTEXT_MESSAGES; i--) {\n const line = lines[i].trim();\n if (!line) continue;\n\n try {\n const entry = JSON.parse(line) as Record<string, unknown>;\n if (entry.type === \"user\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n if (msg?.content) {\n const text = contentToText(msg.content);\n if (text && text.length > 3) {\n messages.unshift(text.slice(0, 500));\n }\n }\n }\n } catch { /* skip invalid JSON */ }\n }\n\n return messages.join(\"\\n\\n\").slice(0, MAX_CONTEXT_CHARS);\n } catch {\n return \"\";\n }\n}\n\n/** Convert Claude content (string or content block array) to plain text. */\nfunction contentToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === \"string\") return c;\n const block = c as Record<string, unknown>;\n if (block?.text) return String(block.text);\n if (block?.content) return String(block.content);\n return \"\";\n })\n .join(\" \")\n .trim();\n }\n return \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Topic boundary file — signals to session-summary-worker\n// ---------------------------------------------------------------------------\n\nconst TOPIC_BOUNDARY_FILE = \"topic-boundary.json\";\n\ninterface TopicBoundary {\n timestamp: string;\n previousProject: string | null;\n suggestedProject: string | null;\n confidence: number;\n context: string;\n}\n\n/**\n * Write a topic boundary marker into the Notes directory.\n * The session-summary-worker checks for this file and uses it as an\n * additional signal that a new note should be created.\n */\nfunction writeTopicBoundary(\n cwd: string,\n boundary: TopicBoundary\n): void {\n try {\n const notesInfo = findNotesDir(cwd);\n const boundaryPath = join(notesInfo.path, TOPIC_BOUNDARY_FILE);\n writeFileSync(boundaryPath, JSON.stringify(boundary, null, 2), \"utf-8\");\n process.stderr.write(\n `[topic-detect] Wrote topic boundary marker: ${boundaryPath}\\n`\n );\n } catch (e) {\n process.stderr.write(`[topic-detect] Could not write boundary marker: ${e}\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a `topic-detect` work item.\n *\n * Called by work-queue-worker.ts. Throws on fatal errors so the work queue\n * retry logic handles them.\n */\nexport async function handleTopicDetect(payload: TopicDetectPayload): Promise<void> {\n const { cwd, currentProject, transcriptPath, sessionId } = payload;\n\n if (!cwd) {\n throw new Error(\"topic-detect payload missing cwd\");\n }\n\n process.stderr.write(\n `[topic-detect] Starting for ${cwd}` +\n `${currentProject ? ` (project=${currentProject})` : \"\"}` +\n `${sessionId ? ` (session=${sessionId})` : \"\"}\\n`\n );\n\n // Check that daemon state is available\n if (!registryBackend || !storageBackend) {\n process.stderr.write(\n \"[topic-detect] Registry backend or storage backend not available — skipping.\\n\"\n );\n return;\n }\n\n // Extract context from payload or transcript\n let context = payload.context || \"\";\n\n if (!context && transcriptPath && existsSync(transcriptPath)) {\n context = extractRecentContext(transcriptPath);\n }\n\n if (!context || context.trim().length < 10) {\n process.stderr.write(\n \"[topic-detect] Insufficient context for topic detection — skipping.\\n\"\n );\n return;\n }\n\n process.stderr.write(\n `[topic-detect] Context: ${context.length} chars, checking against memory...\\n`\n );\n\n // Run the BM25-based topic shift detector\n const result = await detectTopicShift(registryBackend, storageBackend, {\n context,\n currentProject,\n threshold: 0.6,\n candidates: 20,\n });\n\n process.stderr.write(\n `[topic-detect] Result: shifted=${result.shifted}, ` +\n `suggested=${result.suggestedProject}, confidence=${result.confidence.toFixed(2)}, ` +\n `chunks=${result.chunkCount}\\n`\n );\n\n if (result.topProjects.length > 0) {\n process.stderr.write(\n `[topic-detect] Top projects: ${result.topProjects.map(\n (p) => `${p.slug}(${(p.score * 100).toFixed(0)}%)`\n ).join(\", \")}\\n`\n );\n }\n\n if (result.shifted) {\n // Record the topic boundary\n writeTopicBoundary(cwd, {\n timestamp: new Date().toISOString(),\n previousProject: result.currentProject,\n suggestedProject: result.suggestedProject,\n confidence: result.confidence,\n context: context.slice(0, 200),\n });\n\n // Also append a checkpoint to the current session note\n try {\n const notesInfo = findNotesDir(cwd);\n const notePath = getCurrentNotePath(notesInfo.path);\n if (notePath) {\n appendCheckpoint(\n notePath,\n `Topic shift detected: conversation moved from **${result.currentProject}** ` +\n `to **${result.suggestedProject}** (confidence: ${(result.confidence * 100).toFixed(0)}%). ` +\n `A new session note will be created for the new topic.`\n );\n }\n } catch (e) {\n process.stderr.write(`[topic-detect] Could not append checkpoint: ${e}\\n`);\n }\n }\n\n process.stderr.write(\"[topic-detect] Done.\\n\");\n}\n","/**\n * work-queue-worker.ts — Daemon worker loop for the persistent work queue\n *\n * Runs every 5 seconds to drain the queue.\n * Handles 'session-end' work items by reading the transcript, extracting\n * work summaries, updating the session note, and updating TODO.md.\n * Handles 'session-summary' items by spawning Haiku for AI-powered note generation.\n * Handles 'context-handover' items by spawning Sonnet to write the\n * threshold-triggered pre-compaction reasoning handover (see\n * context-handover-worker.ts and hooks/ts/lib/context-fill.ts).\n * Handles 'registry-scan' items by running performScan() against the registry DB.\n *\n * Handles 'topic-detect' items by running BM25-based topic shift detection.\n * Other item types (note-update, todo-update) are stubs — they log and\n * complete immediately, ready for future expansion.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { basename, dirname } from \"node:path\";\n\nimport {\n dequeue,\n enqueue,\n markCompleted,\n markFailed,\n cleanup,\n getStats,\n hasPendingOrProcessingOfType,\n type WorkItem,\n} from \"./work-queue.js\";\n\nimport {\n handleSessionSummary,\n type SessionSummaryPayload,\n} from \"./session-summary-worker.js\";\n\nimport {\n handleContextHandover,\n type ContextHandoverPayload,\n} from \"./context-handover-worker.js\";\n\nimport {\n handleTopicDetect,\n type TopicDetectPayload,\n} from \"./topic-detect-worker.js\";\n\n// Registry scan — called from handleRegistryScan()\nimport { performScan } from \"../cli/commands/registry/scan.js\";\nimport { registryBackend } from \"./daemon/state.js\";\n\n// Hooks lib imports — resolving through the compiled JS path.\n// These are the same utilities used by stop-hook.ts.\nimport {\n findNotesDir,\n getCurrentNotePath,\n addWorkToSessionNote,\n finalizeSessionNote,\n updateTodoContinue, sessionIdFromTranscript,\n archiveSessionFilesToSessionsDir,\n type WorkItem as NoteWorkItem,\n} from \"../hooks/ts/lib/project-utils/index.js\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst WORKER_INTERVAL_MS = 5_000;\nconst HOUSEKEEPING_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes\n\n// ---------------------------------------------------------------------------\n// Timers (stored so shutdown can clear them)\n// ---------------------------------------------------------------------------\n\nlet workerTimer: ReturnType<typeof setInterval> | null = null;\nlet housekeepingTimer: ReturnType<typeof setInterval> | null = null;\nlet _immediateSignal = false;\n\n// ---------------------------------------------------------------------------\n// Worker loop\n// ---------------------------------------------------------------------------\n\n/** Start the background worker and housekeeping timers. */\nexport function startWorker(): void {\n process.stderr.write(\"[work-queue-worker] Starting worker loop.\\n\");\n\n workerTimer = setInterval(async () => {\n try {\n await processNextItem();\n } catch (e) {\n process.stderr.write(`[work-queue-worker] Uncaught error in worker loop: ${e}\\n`);\n }\n }, WORKER_INTERVAL_MS);\n\n housekeepingTimer = setInterval(() => {\n try {\n cleanup();\n } catch (e) {\n process.stderr.write(`[work-queue-worker] Housekeeping error: ${e}\\n`);\n }\n }, HOUSEKEEPING_INTERVAL_MS);\n\n process.stderr.write(\"[work-queue-worker] Worker started (interval=5s, housekeeping=10min).\\n\");\n}\n\n/** Stop the worker timers gracefully. */\nexport function stopWorker(): void {\n if (workerTimer !== null) {\n clearInterval(workerTimer);\n workerTimer = null;\n }\n if (housekeepingTimer !== null) {\n clearInterval(housekeepingTimer);\n housekeepingTimer = null;\n }\n process.stderr.write(\"[work-queue-worker] Worker stopped.\\n\");\n}\n\n/**\n * Signal that new work has been enqueued.\n * The worker will run on its next tick — we don't need to reset the timer\n * since 5 s is fast enough. The flag allows future optimisations.\n */\nexport function notifyNewWork(): void {\n _immediateSignal = true;\n}\n\n// ---------------------------------------------------------------------------\n// Item processor (sequential — one item per tick)\n// ---------------------------------------------------------------------------\n\nasync function processNextItem(): Promise<void> {\n const item = dequeue();\n if (!item) return;\n\n process.stderr.write(\n `[work-queue-worker] Processing ${item.type} (id=${item.id}, attempt=${item.attempts}).\\n`\n );\n\n try {\n switch (item.type) {\n case \"session-end\":\n await handleSessionEnd(item);\n break;\n\n case \"session-summary\":\n await handleSessionSummary(item.payload as unknown as SessionSummaryPayload);\n break;\n\n case \"context-handover\":\n await handleContextHandover(item.payload as unknown as ContextHandoverPayload);\n break;\n\n case \"topic-detect\":\n await handleTopicDetect(item.payload as unknown as TopicDetectPayload);\n break;\n\n case \"registry-scan\":\n await handleRegistryScan();\n break;\n\n case \"note-update\":\n case \"todo-update\":\n // Stubs — log and complete\n process.stderr.write(\n `[work-queue-worker] Item type '${item.type}' is not yet implemented — completing as no-op.\\n`\n );\n break;\n\n default:\n throw new Error(`Unknown work item type: ${(item as WorkItem).type}`);\n }\n\n markCompleted(item.id);\n process.stderr.write(`[work-queue-worker] Completed ${item.type} (id=${item.id}).\\n`);\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e);\n markFailed(item.id, msg);\n }\n}\n\n// ---------------------------------------------------------------------------\n// session-end handler\n// ---------------------------------------------------------------------------\n\n/**\n * Process a 'session-end' work item.\n *\n * Expected payload:\n * transcriptPath: string — absolute path to the .jsonl transcript\n * cwd: string — working directory of the session\n * message?: string — COMPLETED: line extracted by the hook (optional)\n */\nasync function handleSessionEnd(item: WorkItem): Promise<void> {\n const { transcriptPath, cwd, message: hookMessage } = item.payload as {\n transcriptPath: string;\n cwd: string;\n message?: string;\n };\n\n if (!transcriptPath) throw new Error(\"session-end payload missing transcriptPath\");\n if (!cwd) throw new Error(\"session-end payload missing cwd\");\n\n // Read transcript\n let transcript: string;\n try {\n transcript = readFileSync(transcriptPath, \"utf-8\");\n } catch (e) {\n throw new Error(`Could not read transcript at ${transcriptPath}: ${e}`);\n }\n\n const lines = transcript.trim().split(\"\\n\");\n\n // Extract work items from transcript\n const workItems = extractWorkFromTranscript(lines);\n\n // Determine completion message\n let message = hookMessage ?? \"\";\n if (!message) {\n const lastEntry = tryParseJson(lines[lines.length - 1]) as\n | { type?: string; message?: { content?: unknown } }\n | null;\n if (lastEntry?.type === \"assistant\" && lastEntry.message?.content) {\n const content = contentToText(lastEntry.message.content);\n const m = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (m) {\n message = m[1].trim().replace(/\\*+/g, \"\").replace(/\\[.*?\\]/g, \"\").trim();\n }\n }\n }\n\n // Find notes directory and current note\n const notesInfo = findNotesDir(cwd);\n const currentNotePath = getCurrentNotePath(notesInfo.path);\n\n if (currentNotePath) {\n // Add work items to session note\n if (workItems.length > 0) {\n addWorkToSessionNote(currentNotePath, workItems);\n process.stderr.write(\n `[work-queue-worker] Added ${workItems.length} work item(s) to note.\\n`\n );\n } else if (message) {\n addWorkToSessionNote(currentNotePath, [{ title: message, completed: true }]);\n process.stderr.write(\"[work-queue-worker] Added completion message to note.\\n\");\n }\n\n // Finalize the note\n const summary = message || \"Session completed.\";\n finalizeSessionNote(currentNotePath, summary);\n process.stderr.write(\n `[work-queue-worker] Finalized session note: ${basename(currentNotePath)}.\\n`\n );\n\n // Update TODO.md ## Continue section.\n //\n // A session that produced neither work items nor a completion message has\n // no handover to write. Claiming the slot anyway hands the next session a\n // block naming a session that did nothing — see the 2026-08-04 incident\n // recorded in AIBroker's Notes. applyContinue now refuses such a write on\n // its own; this skips building it in the first place, so the \"last session\"\n // line keeps pointing at the last session that actually did something.\n if (workItems.length === 0 && !message) {\n process.stderr.write(\n \"[work-queue-worker] Nothing to hand over — leaving TODO.md ## Continue intact.\\n\"\n );\n } else {\n try {\n const stateLines: string[] = [];\n stateLines.push(`Working directory: ${cwd}`);\n if (workItems.length > 0) {\n stateLines.push(\"\", \"Work completed:\");\n for (const wi of workItems.slice(0, 5)) {\n stateLines.push(`- ${wi.title}`);\n }\n }\n if (message) {\n stateLines.push(\"\", `Last completed: ${message}`);\n }\n updateTodoContinue(\n cwd,\n basename(currentNotePath),\n stateLines.join(\"\\n\"),\n \"session-end\",\n // Same reasoning as the stop hook: the transcript filename is the\n // session's real identity, and the note title is not.\n sessionIdFromTranscript(transcriptPath)\n );\n } catch (todoError) {\n // Non-fatal — log and continue\n process.stderr.write(\n `[work-queue-worker] Could not update TODO.md: ${todoError}\\n`\n );\n }\n }\n } else {\n process.stderr.write(\n \"[work-queue-worker] No current session note found — skipping note update.\\n\"\n );\n }\n\n // Move session .jsonl files to sessions/ subdirectory\n try {\n const transcriptDir = dirname(transcriptPath);\n const archivedCount = archiveSessionFilesToSessionsDir(transcriptDir);\n if (archivedCount > 0) {\n process.stderr.write(\n `[work-queue-worker] Archived ${archivedCount} session file(s) to sessions/.\\n`\n );\n }\n } catch (moveError) {\n // Non-fatal\n process.stderr.write(`[work-queue-worker] Could not move session files: ${moveError}\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// registry-scan handler\n// ---------------------------------------------------------------------------\n\n/**\n * Run performScan() against the registry DB.\n *\n * Debounce: if another registry-scan job is already pending in the queue,\n * we still run this one (dequeue already picked it), but enqueueRegistryScan()\n * checks before enqueuing so duplicates rarely reach here.\n */\nasync function handleRegistryScan(): Promise<void> {\n if (!registryBackend) {\n throw new Error(\"registry-scan: registryBackend not initialized yet\");\n }\n\n const t0 = Date.now();\n process.stderr.write(\"[work-queue-worker] Running registry scan...\\n\");\n\n const result = await performScan();\n const elapsed = Date.now() - t0;\n\n process.stderr.write(\n `[work-queue-worker] Registry scan complete: ` +\n `${result.projectsScanned} projects (${result.projectsNew} new, ${result.projectsUpdated} updated), ` +\n `${result.sessionsScanned} sessions (${result.sessionsNew} new) in ${elapsed}ms.\\n`\n );\n\n if (result.skipped.length > 0) {\n process.stderr.write(\n `[work-queue-worker] Registry scan: ${result.skipped.length} project(s) skipped (path not found).\\n`\n );\n }\n}\n\n/**\n * Enqueue a registry-scan work item — debounced.\n * If a registry-scan item is already pending or processing, skip enqueue.\n */\nexport function enqueueRegistryScan(): void {\n // Debounce: skip if already queued or processing\n if (hasPendingOrProcessingOfType(\"registry-scan\")) {\n process.stderr.write(\"[work-queue-worker] Registry scan already pending/processing — skipping duplicate enqueue.\\n\");\n return;\n }\n\n enqueue({\n type: \"registry-scan\",\n priority: 5, // lowest priority — runs after session-end, session-summary\n payload: {},\n });\n}\n\n// ---------------------------------------------------------------------------\n// Transcript parsing helpers (mirrors stop-hook.ts logic)\n// ---------------------------------------------------------------------------\n\nfunction tryParseJson(line: string): Record<string, unknown> | null {\n try {\n return JSON.parse(line) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction contentToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === \"string\") return c;\n const block = c as Record<string, unknown>;\n if (block?.text) return String(block.text);\n if (block?.content) return String(block.content);\n return \"\";\n })\n .join(\" \")\n .trim();\n }\n return \"\";\n}\n\nfunction extractWorkFromTranscript(lines: string[]): NoteWorkItem[] {\n const workItems: NoteWorkItem[] = [];\n const seenSummaries = new Set<string>();\n\n for (const line of lines) {\n const entry = tryParseJson(line);\n if (!entry || entry.type !== \"assistant\") continue;\n\n const msg = entry.message as Record<string, unknown> | undefined;\n if (!msg?.content) continue;\n\n const content = contentToText(msg.content);\n\n // SUMMARY: line (preferred)\n const summaryMatch = content.match(/SUMMARY:\\s*(.+?)(?:\\n|$)/i);\n if (summaryMatch) {\n const summary = summaryMatch[1].trim();\n if (summary && !seenSummaries.has(summary) && summary.length > 5) {\n seenSummaries.add(summary);\n\n const details: string[] = [];\n const actionsMatch = content.match(/ACTIONS:\\s*(.+?)(?=\\n[A-Z]+:|$)/is);\n if (actionsMatch) {\n const actionLines = actionsMatch[1]\n .split(\"\\n\")\n .map((l) =>\n l.replace(/^[-*•]\\s*/, \"\").replace(/^\\d+\\.\\s*/, \"\").trim()\n )\n .filter((l) => l.length > 3 && l.length < 100);\n details.push(...actionLines.slice(0, 3));\n }\n\n workItems.push({\n title: summary,\n details: details.length > 0 ? details : undefined,\n completed: true,\n });\n }\n }\n\n // COMPLETED: line (fallback)\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (completedMatch && workItems.length === 0) {\n const completed = completedMatch[1]\n .trim()\n .replace(/\\*+/g, \"\")\n .replace(/\\[.*?\\]/g, \"\")\n .trim();\n if (completed && !seenSummaries.has(completed) && completed.length > 5) {\n seenSummaries.add(completed);\n workItems.push({ title: completed, completed: true });\n }\n }\n }\n\n return workItems;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"work-queue-worker-4tLa5gpQ.mjs","names":["contentToText","contentToText"],"sources":["../src/daemon/templates/context-handover-prompt.ts","../src/daemon/context-handover-worker.ts","../src/daemon/topic-detect-worker.ts","../src/daemon/work-queue-worker.ts"],"sourcesContent":["/**\n * context-handover-prompt.ts — prompt template for the threshold-triggered\n * pre-compaction handover.\n *\n * This is NOT the session-note summary (session-summary-prompt.ts already\n * does that well, and Claude Code's own native compaction summary is also\n * genuinely detailed on file paths and issue numbers). This exists for the\n * one thing neither of those reliably carries: the reasoning behind what\n * happened, which compresses to bullets or vanishes outright once a native\n * compaction fires.\n *\n * FRAMING (lifted from a working example of this exact mechanism, read\n * before writing this prompt): the handover is a DIFF against everything\n * already durable, not a summary of the session. The successor can read\n * git, the tracker, and the notes — so this must not repeat what is\n * recoverable there. What cannot be recovered is what only ever existed in\n * the conversation: reasoning, rejected alternatives, negative results and\n * what they cost, traps discovered but invisible in the repo, and specific\n * values/ids/paths that would otherwise have to be dug up again.\n *\n * Length is not a target. A short session earns a short handover; every\n * line in a long one must be something the successor could not have\n * obtained anywhere else.\n */\n\nexport interface HandoverPromptParams {\n /** Interleaved user + assistant turns, oldest first, each prefixed with\n * who said it — this prompt needs the assistant's reasoning, not just\n * what the user asked for. */\n turns: string[];\n /** Git log output for the session period, if any. */\n gitLog: string;\n cwd: string;\n /** The previous cached handover's summary text, if this session already\n * produced one (the warmup handover, when this call is the refresh).\n * Passed through so the new handover can carry it forward rather than\n * silently dropping everything it already captured — see the \"pointer\n * to the previous handover, kept rather than superseded\" structure. */\n previousHandover?: string;\n}\n\nexport function buildContextHandoverPrompt(params: HandoverPromptParams): string {\n const { turns, gitLog, cwd, previousHandover } = params;\n\n const turnsSection = turns.length > 0\n ? turns.join(\"\\n\\n\")\n : \"(No turns extracted)\";\n\n const gitSection = gitLog.trim() || \"(No git commits during this session)\";\n\n const previousSection = previousHandover\n ? `\\nA PREVIOUS HANDOVER already exists for this session (from an earlier, \\\nlower threshold). Carry it forward — start your response with a one-line \\\npointer to it (\"Carries forward from the previous handover, which stays for \\\nits detail.\") and then write only what has happened SINCE it that a compact \\\nwould also lose. Do not re-derive or repeat what it already covered.\\n\\n\\\nPREVIOUS HANDOVER:\\n${previousHandover}\\n`\n : \"\";\n\n return `This session is approaching a context-window compaction. Write the \\\nhandover that would let a successor continue WITHOUT re-reading this \\\nconversation and WITHOUT asking the user to repeat themselves.\n\nProject directory: ${cwd}\n${previousSection}\nSCOPING RULE — the single most important instruction here: this is a DIFF \\\nagainst everything already durable, not a summary of the session. Assume \\\nthe successor can and will read git history, the issue tracker, and any \\\nnotes files themselves. Do NOT restate anything recoverable there — no file \\\ninventories, no \"implemented X\" restatements, no chronology of commands run. \\\nWrite down ONLY what existed exclusively in this conversation and would \\\notherwise be gone: the reasoning, not the result.\n\nWrite ONLY the sections below that this session actually has content for — \\\nomit a section entirely rather than writing \"none\" or padding it:\n\nWHERE THIS SESSION ENDED — the concrete state right now: what is verified \\\nworking (tests green, tree clean, etc.) versus merely attempted, any branch \\\nor working-tree state that isn't obvious from git status alone (e.g. a \\\ndetached HEAD, an intentionally uncommitted change, a stash).\n\nWHAT CLOSED — each item paired with the evidence that closed it, not just \\\nthe claim that it did.\n\nWHAT IS FILED AND STILL OPEN — each with its next concrete step or who/what \\\nit is waiting on.\n\nDECISIONS & REASONING — choices actually made, each with why this way and \\\nnot another way.\n\nREJECTED ALTERNATIVES — approaches considered and set aside, and why. An \\\nalternative without a reason is not worth recording.\n\nNEGATIVE RESULTS — something investigated that turned out NOT to be the \\\nanswer, kept WITH the reasoning that ruled it out (e.g. \"X looked like the \\\ncause — it wasn't; the actual mechanism was Y\"). Without the reasoning \\\nattached, a successor re-investigates the same dead end.\n\nTRAPS — anything discovered that is invisible from the repo alone and would \\\notherwise bite a successor immediately (a required manual step, an ordering \\\nconstraint, a state that looks fine but isn't).\n\nCRITICAL VALUES — any specific id, path, URL, credential name, version \\\nnumber, or configuration value that appeared in conversation and whose loss \\\nwould force someone to go find it again.\n\nTHE FEW THINGS THAT MATTER MOST, IF NOTHING ELSE IS READ — at most 3 bullets. \\\nForces a priority judgement; do not pad this to fill space.\n\nFormat your response as markdown with those section headers (only the ones \\\nyou have content for), no other structure.\n\n---\n\nCONVERSATION:\n${turnsSection}\n\nGIT COMMITS:\n${gitSection}`;\n}\n","/**\n * context-handover-worker.ts — the threshold-triggered pre-compaction\n * handover.\n *\n * `session-summary-worker.ts` already spawns a model to write session notes,\n * but only from the PreCompact hook itself — which fires at the compaction\n * boundary, far too late to be injected INTO that same compaction's\n * post-compact context. This worker runs earlier, off a token-count\n * threshold (see ../hooks/ts/lib/context-fill.ts), so a model-written\n * handover already exists in a cache file by the time compaction happens.\n *\n * Deliberately narrow: this does not write a session note, does not touch\n * the session-summary cooldown file, and does not run KG extraction. It\n * writes one thing — a cache file the PreCompact hook can read — because its\n * only job is to exist before compaction does, not to duplicate what\n * session-summary-worker already does well.\n *\n * The prompt itself (see templates/context-handover-prompt.ts) treats the\n * handover as a DIFF against everything already durable — git, the tracker,\n * the notes — not a session summary, and a refresh run carries the previous\n * cached handover forward rather than overwriting it.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\n\nimport {\n findLatestJsonl,\n getGitContext,\n spawnSummarizer,\n contentToText,\n} from \"./session-summary-worker.js\";\nimport { buildContextHandoverPrompt } from \"./templates/context-handover-prompt.js\";\nimport {\n readContextHandoverCache,\n writeContextHandoverCache,\n type HandoverThreshold,\n} from \"../hooks/ts/lib/context-handover-cache.js\";\n\nexport type { HandoverThreshold } from \"../hooks/ts/lib/context-handover-cache.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ContextHandoverPayload {\n cwd: string;\n sessionId: string;\n transcriptPath?: string;\n threshold: HandoverThreshold;\n /** True when this fired via the \"no time for a clean crossing\" path\n * (see isImmediate in context-fill.ts) — logged, not otherwise acted on. */\n urgent?: boolean;\n}\n\n/** How much of the tail of the transcript to feed the summarizer. Generous —\n * this runs at most twice per session, not on every tool call. */\nconst MAX_TURN_CHARS = 150_000;\n\n/** Max turns to include (a hard floor even if MAX_TURN_CHARS is not hit). */\nconst MAX_TURNS = 120;\n\n// ---------------------------------------------------------------------------\n// Transcript → turns\n// ---------------------------------------------------------------------------\n\n/**\n * Interleaved \"User:\"/\"Assistant:\" turns, oldest-kept-from-the-end so a long\n * transcript is truncated to its most recent content rather than its\n * earliest. Unlike session-summary-worker's extractFromJsonl (which keeps\n * only user messages, because a session note doesn't need the assistant's\n * own reasoning) this keeps BOTH sides — the whole point of this prompt is\n * the assistant's reasoning, which lives in assistant turns.\n */\nfunction extractTurns(jsonlPath: string): string[] {\n let raw: string;\n try {\n raw = readFileSync(jsonlPath, \"utf-8\");\n } catch {\n return [];\n }\n\n if (raw.length > MAX_TURN_CHARS) {\n const truncPoint = raw.indexOf(\"\\n\", raw.length - MAX_TURN_CHARS);\n raw = truncPoint >= 0 ? raw.slice(truncPoint + 1) : raw.slice(-MAX_TURN_CHARS);\n }\n\n const turns: string[] = [];\n for (const line of raw.trim().split(\"\\n\")) {\n if (!line.trim()) continue;\n let entry: Record<string, unknown>;\n try {\n entry = JSON.parse(line);\n } catch {\n continue;\n }\n\n if (entry.type === \"user\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n const text = contentToText(msg?.content);\n if (text && text.length >= 3 && !text.startsWith(\"<system-reminder>\")) {\n turns.push(`User: ${text.slice(0, 2000)}`);\n }\n } else if (entry.type === \"assistant\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n const text = contentToText(msg?.content);\n if (text && text.length >= 3) {\n turns.push(`Assistant: ${text.slice(0, 4000)}`);\n }\n }\n }\n\n return turns.slice(-MAX_TURNS);\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a `context-handover` work item.\n *\n * THROWS on every failure path (missing input, no transcript, no turns, an\n * empty/failed summarizer run) rather than logging and returning silently.\n * That used to be a deliberate choice — avoid the work queue's own retry\n * piling up redundant LLM calls — but it made a real failure invisible: a\n * daemon restart mid-spawn (session 77084e72-...) left no cache, no error\n * anywhere the work queue's own stats could show, and a trigger-side marker\n * that (at the time) had already been set to \"done\". Throwing here makes\n * work-queue-worker.ts call markFailed(), which logs prominently AND is\n * visible via `work_queue_stats` / `pai daemon status` — not just a stderr\n * line that can scroll away. It is a second, faster (seconds-to-minutes\n * backoff) retry path alongside the primary one in context-handover-\n * trigger.ts (which retries on its own ~5-minute cadence based on whether a\n * cache actually appeared); the two are complementary, not redundant — a\n * failed cache write costs nothing to retry twice.\n */\nexport async function handleContextHandover(payload: ContextHandoverPayload): Promise<void> {\n const { cwd, sessionId, transcriptPath, threshold, urgent } = payload;\n\n if (!cwd || !sessionId) {\n throw new Error(\"[context-handover] payload missing cwd or sessionId\");\n }\n\n process.stderr.write(\n `[context-handover] Starting for ${cwd} (session=${sessionId}, threshold=${threshold}` +\n `${urgent ? \", urgent\" : \"\"}).\\n`\n );\n\n let jsonlPath: string | null = transcriptPath && existsSync(transcriptPath) ? transcriptPath : null;\n if (!jsonlPath) jsonlPath = findLatestJsonl(cwd);\n if (!jsonlPath) {\n throw new Error(`[context-handover] No transcript found for ${cwd} (session=${sessionId})`);\n }\n\n const turns = extractTurns(jsonlPath);\n if (turns.length === 0) {\n throw new Error(`[context-handover] No turns extracted from ${jsonlPath} (session=${sessionId})`);\n }\n\n const gitLog = await getGitContext(cwd);\n\n // Carry the previous handover forward (see buildContextHandoverPrompt's\n // scoping rule) rather than silently overwriting it — a refresh at the\n // second threshold should not lose what the warmup handover already\n // captured.\n const previous = readContextHandoverCache(sessionId);\n const prompt = buildContextHandoverPrompt({\n turns,\n gitLog,\n cwd,\n previousHandover: previous?.summary,\n });\n process.stderr.write(`[context-handover] Sending ${prompt.length} char prompt to sonnet...\\n`);\n\n const summary = await spawnSummarizer(prompt, \"sonnet\");\n if (!summary || !summary.trim()) {\n // No cache write on this path — an existing cache from an earlier\n // successful run is left untouched rather than clobbered with nothing.\n throw new Error(`[context-handover] sonnet produced no output (session=${sessionId})`);\n }\n\n writeContextHandoverCache({\n sessionId,\n cwd,\n threshold,\n generatedAt: new Date().toISOString(),\n model: \"sonnet\",\n summary: summary.trim(),\n });\n\n process.stderr.write(\n `[context-handover] Wrote cache for session ${sessionId} (${summary.length} chars).\\n`\n );\n}\n","/**\n * topic-detect-worker.ts — Topic shift detection for session note splitting\n *\n * Processes `topic-detect` work items by:\n * 1. Extracting recent user messages from the JSONL transcript\n * 2. Running the BM25-based topic shift detector against the PAI memory DB\n * 3. If a shift is detected, recording a topic boundary marker\n *\n * The actual note splitting is handled by session-summary-worker.ts when it\n * processes the next `session-summary` work item — it uses the TOPIC: line\n * from the summarizer to decide whether to create a new note.\n *\n * This worker provides an additional signal: project-level topic shift\n * (e.g., conversation moved from project A to project B). The session\n * summary worker handles intra-project topic shifts (e.g., from \"dark mode\"\n * to \"keyboard IPC\" within the same project).\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join, basename } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nimport { detectTopicShift } from \"../topics/detector.js\";\nimport { registryBackend, storageBackend } from \"./daemon/state.js\";\nimport {\n findNotesDir,\n getCurrentNotePath,\n appendCheckpoint,\n} from \"../hooks/ts/lib/project-utils/index.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicDetectPayload {\n /** Working directory of the session. */\n cwd: string;\n /** Recent conversation context (extracted user messages). */\n context?: string;\n /** The project slug the session is currently routed to. */\n currentProject?: string;\n /** Path to the JSONL transcript (optional — used to extract context if not provided). */\n transcriptPath?: string;\n /** Session ID (for logging). */\n sessionId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// JSONL context extraction (lightweight — just last few user messages)\n// ---------------------------------------------------------------------------\n\nconst MAX_CONTEXT_MESSAGES = 5;\nconst MAX_CONTEXT_CHARS = 2000;\n\n/**\n * Extract recent user messages from a JSONL transcript for topic detection.\n * Takes only the last few messages to represent the current topic.\n */\nfunction extractRecentContext(jsonlPath: string): string {\n try {\n const raw = readFileSync(jsonlPath, \"utf-8\");\n // Read from the end — last 50KB should be more than enough\n const tail = raw.length > 50_000 ? raw.slice(-50_000) : raw;\n const lines = tail.trim().split(\"\\n\");\n\n const messages: string[] = [];\n\n for (let i = lines.length - 1; i >= 0 && messages.length < MAX_CONTEXT_MESSAGES; i--) {\n const line = lines[i].trim();\n if (!line) continue;\n\n try {\n const entry = JSON.parse(line) as Record<string, unknown>;\n if (entry.type === \"user\") {\n const msg = entry.message as Record<string, unknown> | undefined;\n if (msg?.content) {\n const text = contentToText(msg.content);\n if (text && text.length > 3) {\n messages.unshift(text.slice(0, 500));\n }\n }\n }\n } catch { /* skip invalid JSON */ }\n }\n\n return messages.join(\"\\n\\n\").slice(0, MAX_CONTEXT_CHARS);\n } catch {\n return \"\";\n }\n}\n\n/** Convert Claude content (string or content block array) to plain text. */\nfunction contentToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === \"string\") return c;\n const block = c as Record<string, unknown>;\n if (block?.text) return String(block.text);\n if (block?.content) return String(block.content);\n return \"\";\n })\n .join(\" \")\n .trim();\n }\n return \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Topic boundary file — signals to session-summary-worker\n// ---------------------------------------------------------------------------\n\nconst TOPIC_BOUNDARY_FILE = \"topic-boundary.json\";\n\ninterface TopicBoundary {\n timestamp: string;\n previousProject: string | null;\n suggestedProject: string | null;\n confidence: number;\n context: string;\n}\n\n/**\n * Write a topic boundary marker into the Notes directory.\n * The session-summary-worker checks for this file and uses it as an\n * additional signal that a new note should be created.\n */\nfunction writeTopicBoundary(\n cwd: string,\n boundary: TopicBoundary\n): void {\n try {\n const notesInfo = findNotesDir(cwd);\n const boundaryPath = join(notesInfo.path, TOPIC_BOUNDARY_FILE);\n writeFileSync(boundaryPath, JSON.stringify(boundary, null, 2), \"utf-8\");\n process.stderr.write(\n `[topic-detect] Wrote topic boundary marker: ${boundaryPath}\\n`\n );\n } catch (e) {\n process.stderr.write(`[topic-detect] Could not write boundary marker: ${e}\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Main entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Process a `topic-detect` work item.\n *\n * Called by work-queue-worker.ts. Throws on fatal errors so the work queue\n * retry logic handles them.\n */\nexport async function handleTopicDetect(payload: TopicDetectPayload): Promise<void> {\n const { cwd, currentProject, transcriptPath, sessionId } = payload;\n\n if (!cwd) {\n throw new Error(\"topic-detect payload missing cwd\");\n }\n\n process.stderr.write(\n `[topic-detect] Starting for ${cwd}` +\n `${currentProject ? ` (project=${currentProject})` : \"\"}` +\n `${sessionId ? ` (session=${sessionId})` : \"\"}\\n`\n );\n\n // Check that daemon state is available\n if (!registryBackend || !storageBackend) {\n process.stderr.write(\n \"[topic-detect] Registry backend or storage backend not available — skipping.\\n\"\n );\n return;\n }\n\n // Extract context from payload or transcript\n let context = payload.context || \"\";\n\n if (!context && transcriptPath && existsSync(transcriptPath)) {\n context = extractRecentContext(transcriptPath);\n }\n\n if (!context || context.trim().length < 10) {\n process.stderr.write(\n \"[topic-detect] Insufficient context for topic detection — skipping.\\n\"\n );\n return;\n }\n\n process.stderr.write(\n `[topic-detect] Context: ${context.length} chars, checking against memory...\\n`\n );\n\n // Run the BM25-based topic shift detector\n const result = await detectTopicShift(registryBackend, storageBackend, {\n context,\n currentProject,\n threshold: 0.6,\n candidates: 20,\n });\n\n process.stderr.write(\n `[topic-detect] Result: shifted=${result.shifted}, ` +\n `suggested=${result.suggestedProject}, confidence=${result.confidence.toFixed(2)}, ` +\n `chunks=${result.chunkCount}\\n`\n );\n\n if (result.topProjects.length > 0) {\n process.stderr.write(\n `[topic-detect] Top projects: ${result.topProjects.map(\n (p) => `${p.slug}(${(p.score * 100).toFixed(0)}%)`\n ).join(\", \")}\\n`\n );\n }\n\n if (result.shifted) {\n // Record the topic boundary\n writeTopicBoundary(cwd, {\n timestamp: new Date().toISOString(),\n previousProject: result.currentProject,\n suggestedProject: result.suggestedProject,\n confidence: result.confidence,\n context: context.slice(0, 200),\n });\n\n // Also append a checkpoint to the current session note\n try {\n const notesInfo = findNotesDir(cwd);\n const notePath = getCurrentNotePath(notesInfo.path);\n if (notePath) {\n appendCheckpoint(\n notePath,\n `Topic shift detected: conversation moved from **${result.currentProject}** ` +\n `to **${result.suggestedProject}** (confidence: ${(result.confidence * 100).toFixed(0)}%). ` +\n `A new session note will be created for the new topic.`\n );\n }\n } catch (e) {\n process.stderr.write(`[topic-detect] Could not append checkpoint: ${e}\\n`);\n }\n }\n\n process.stderr.write(\"[topic-detect] Done.\\n\");\n}\n","/**\n * work-queue-worker.ts — Daemon worker loop for the persistent work queue\n *\n * Runs every 5 seconds to drain the queue.\n * Handles 'session-end' work items by reading the transcript, extracting\n * work summaries, updating the session note, and updating TODO.md.\n * Handles 'session-summary' items by spawning Haiku for AI-powered note generation.\n * Handles 'context-handover' items by spawning Sonnet to write the\n * threshold-triggered pre-compaction reasoning handover (see\n * context-handover-worker.ts and hooks/ts/lib/context-fill.ts).\n * Handles 'registry-scan' items by running performScan() against the registry DB.\n *\n * Handles 'topic-detect' items by running BM25-based topic shift detection.\n * Other item types (note-update, todo-update) are stubs — they log and\n * complete immediately, ready for future expansion.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { basename, dirname } from \"node:path\";\n\nimport {\n dequeue,\n enqueue,\n markCompleted,\n markFailed,\n cleanup,\n getStats,\n hasPendingOrProcessingOfType,\n type WorkItem,\n} from \"./work-queue.js\";\n\nimport {\n handleSessionSummary,\n type SessionSummaryPayload,\n} from \"./session-summary-worker.js\";\n\nimport {\n handleContextHandover,\n type ContextHandoverPayload,\n} from \"./context-handover-worker.js\";\n\nimport {\n handleTopicDetect,\n type TopicDetectPayload,\n} from \"./topic-detect-worker.js\";\n\n// Registry scan — called from handleRegistryScan()\nimport { performScan } from \"../cli/commands/registry/scan.js\";\nimport { registryBackend } from \"./daemon/state.js\";\n\n// Hooks lib imports — resolving through the compiled JS path.\n// These are the same utilities used by stop-hook.ts.\nimport {\n findNotesDir,\n getCurrentNotePath,\n addWorkToSessionNote,\n finalizeSessionNote,\n updateTodoContinue, sessionIdFromTranscript,\n archiveSessionFilesToSessionsDir,\n type WorkItem as NoteWorkItem,\n} from \"../hooks/ts/lib/project-utils/index.js\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst WORKER_INTERVAL_MS = 5_000;\nconst HOUSEKEEPING_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes\n\n// ---------------------------------------------------------------------------\n// Timers (stored so shutdown can clear them)\n// ---------------------------------------------------------------------------\n\nlet workerTimer: ReturnType<typeof setInterval> | null = null;\nlet housekeepingTimer: ReturnType<typeof setInterval> | null = null;\nlet _immediateSignal = false;\n\n// ---------------------------------------------------------------------------\n// Worker loop\n// ---------------------------------------------------------------------------\n\n/** Start the background worker and housekeeping timers. */\nexport function startWorker(): void {\n process.stderr.write(\"[work-queue-worker] Starting worker loop.\\n\");\n\n workerTimer = setInterval(async () => {\n try {\n await processNextItem();\n } catch (e) {\n process.stderr.write(`[work-queue-worker] Uncaught error in worker loop: ${e}\\n`);\n }\n }, WORKER_INTERVAL_MS);\n\n housekeepingTimer = setInterval(() => {\n try {\n cleanup();\n } catch (e) {\n process.stderr.write(`[work-queue-worker] Housekeeping error: ${e}\\n`);\n }\n }, HOUSEKEEPING_INTERVAL_MS);\n\n process.stderr.write(\"[work-queue-worker] Worker started (interval=5s, housekeeping=10min).\\n\");\n}\n\n/** Stop the worker timers gracefully. */\nexport function stopWorker(): void {\n if (workerTimer !== null) {\n clearInterval(workerTimer);\n workerTimer = null;\n }\n if (housekeepingTimer !== null) {\n clearInterval(housekeepingTimer);\n housekeepingTimer = null;\n }\n process.stderr.write(\"[work-queue-worker] Worker stopped.\\n\");\n}\n\n/**\n * Signal that new work has been enqueued.\n * The worker will run on its next tick — we don't need to reset the timer\n * since 5 s is fast enough. The flag allows future optimisations.\n */\nexport function notifyNewWork(): void {\n _immediateSignal = true;\n}\n\n// ---------------------------------------------------------------------------\n// Item processor (sequential — one item per tick)\n// ---------------------------------------------------------------------------\n\nasync function processNextItem(): Promise<void> {\n const item = dequeue();\n if (!item) return;\n\n process.stderr.write(\n `[work-queue-worker] Processing ${item.type} (id=${item.id}, attempt=${item.attempts}).\\n`\n );\n\n try {\n switch (item.type) {\n case \"session-end\":\n await handleSessionEnd(item);\n break;\n\n case \"session-summary\":\n await handleSessionSummary(item.payload as unknown as SessionSummaryPayload);\n break;\n\n case \"context-handover\":\n await handleContextHandover(item.payload as unknown as ContextHandoverPayload);\n break;\n\n case \"topic-detect\":\n await handleTopicDetect(item.payload as unknown as TopicDetectPayload);\n break;\n\n case \"registry-scan\":\n await handleRegistryScan();\n break;\n\n case \"note-update\":\n case \"todo-update\":\n // Stubs — log and complete\n process.stderr.write(\n `[work-queue-worker] Item type '${item.type}' is not yet implemented — completing as no-op.\\n`\n );\n break;\n\n default:\n throw new Error(`Unknown work item type: ${(item as WorkItem).type}`);\n }\n\n markCompleted(item.id);\n process.stderr.write(`[work-queue-worker] Completed ${item.type} (id=${item.id}).\\n`);\n } catch (e) {\n const msg = e instanceof Error ? e.message : String(e);\n markFailed(item.id, msg);\n }\n}\n\n// ---------------------------------------------------------------------------\n// session-end handler\n// ---------------------------------------------------------------------------\n\n/**\n * Process a 'session-end' work item.\n *\n * Expected payload:\n * transcriptPath: string — absolute path to the .jsonl transcript\n * cwd: string — working directory of the session\n * message?: string — COMPLETED: line extracted by the hook (optional)\n */\nasync function handleSessionEnd(item: WorkItem): Promise<void> {\n const { transcriptPath, cwd, message: hookMessage } = item.payload as {\n transcriptPath: string;\n cwd: string;\n message?: string;\n };\n\n if (!transcriptPath) throw new Error(\"session-end payload missing transcriptPath\");\n if (!cwd) throw new Error(\"session-end payload missing cwd\");\n\n // Read transcript\n let transcript: string;\n try {\n transcript = readFileSync(transcriptPath, \"utf-8\");\n } catch (e) {\n throw new Error(`Could not read transcript at ${transcriptPath}: ${e}`);\n }\n\n const lines = transcript.trim().split(\"\\n\");\n\n // Extract work items from transcript\n const workItems = extractWorkFromTranscript(lines);\n\n // Determine completion message\n let message = hookMessage ?? \"\";\n if (!message) {\n const lastEntry = tryParseJson(lines[lines.length - 1]) as\n | { type?: string; message?: { content?: unknown } }\n | null;\n if (lastEntry?.type === \"assistant\" && lastEntry.message?.content) {\n const content = contentToText(lastEntry.message.content);\n const m = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (m) {\n message = m[1].trim().replace(/\\*+/g, \"\").replace(/\\[.*?\\]/g, \"\").trim();\n }\n }\n }\n\n // Find notes directory and current note\n const notesInfo = findNotesDir(cwd);\n const currentNotePath = getCurrentNotePath(notesInfo.path);\n\n if (currentNotePath) {\n // Add work items to session note\n if (workItems.length > 0) {\n addWorkToSessionNote(currentNotePath, workItems);\n process.stderr.write(\n `[work-queue-worker] Added ${workItems.length} work item(s) to note.\\n`\n );\n } else if (message) {\n addWorkToSessionNote(currentNotePath, [{ title: message, completed: true }]);\n process.stderr.write(\"[work-queue-worker] Added completion message to note.\\n\");\n }\n\n // Finalize the note\n const summary = message || \"Session completed.\";\n finalizeSessionNote(currentNotePath, summary);\n process.stderr.write(\n `[work-queue-worker] Finalized session note: ${basename(currentNotePath)}.\\n`\n );\n\n // Update TODO.md ## Continue section.\n //\n // A session that produced neither work items nor a completion message has\n // no handover to write. Claiming the slot anyway hands the next session a\n // block naming a session that did nothing — see the 2026-08-04 incident\n // recorded in AIBroker's Notes. applyContinue now refuses such a write on\n // its own; this skips building it in the first place, so the \"last session\"\n // line keeps pointing at the last session that actually did something.\n if (workItems.length === 0 && !message) {\n process.stderr.write(\n \"[work-queue-worker] Nothing to hand over — leaving TODO.md ## Continue intact.\\n\"\n );\n } else {\n try {\n const stateLines: string[] = [];\n stateLines.push(`Working directory: ${cwd}`);\n if (workItems.length > 0) {\n stateLines.push(\"\", \"Work completed:\");\n for (const wi of workItems.slice(0, 5)) {\n stateLines.push(`- ${wi.title}`);\n }\n }\n if (message) {\n stateLines.push(\"\", `Last completed: ${message}`);\n }\n updateTodoContinue(\n cwd,\n basename(currentNotePath),\n stateLines.join(\"\\n\"),\n \"session-end\",\n // Same reasoning as the stop hook: the transcript filename is the\n // session's real identity, and the note title is not.\n sessionIdFromTranscript(transcriptPath)\n );\n } catch (todoError) {\n // Non-fatal — log and continue\n process.stderr.write(\n `[work-queue-worker] Could not update TODO.md: ${todoError}\\n`\n );\n }\n }\n } else {\n process.stderr.write(\n \"[work-queue-worker] No current session note found — skipping note update.\\n\"\n );\n }\n\n // Move session .jsonl files to sessions/ subdirectory\n try {\n const transcriptDir = dirname(transcriptPath);\n const archivedCount = archiveSessionFilesToSessionsDir(transcriptDir);\n if (archivedCount > 0) {\n process.stderr.write(\n `[work-queue-worker] Archived ${archivedCount} session file(s) to sessions/.\\n`\n );\n }\n } catch (moveError) {\n // Non-fatal\n process.stderr.write(`[work-queue-worker] Could not move session files: ${moveError}\\n`);\n }\n}\n\n// ---------------------------------------------------------------------------\n// registry-scan handler\n// ---------------------------------------------------------------------------\n\n/**\n * Run performScan() against the registry DB.\n *\n * Debounce: if another registry-scan job is already pending in the queue,\n * we still run this one (dequeue already picked it), but enqueueRegistryScan()\n * checks before enqueuing so duplicates rarely reach here.\n */\nasync function handleRegistryScan(): Promise<void> {\n if (!registryBackend) {\n throw new Error(\"registry-scan: registryBackend not initialized yet\");\n }\n\n const t0 = Date.now();\n process.stderr.write(\"[work-queue-worker] Running registry scan...\\n\");\n\n const result = await performScan();\n const elapsed = Date.now() - t0;\n\n process.stderr.write(\n `[work-queue-worker] Registry scan complete: ` +\n `${result.projectsScanned} projects (${result.projectsNew} new, ${result.projectsUpdated} updated), ` +\n `${result.sessionsScanned} sessions (${result.sessionsNew} new) in ${elapsed}ms.\\n`\n );\n\n if (result.skipped.length > 0) {\n process.stderr.write(\n `[work-queue-worker] Registry scan: ${result.skipped.length} project(s) skipped (path not found).\\n`\n );\n }\n}\n\n/**\n * Enqueue a registry-scan work item — debounced.\n * If a registry-scan item is already pending or processing, skip enqueue.\n */\nexport function enqueueRegistryScan(): void {\n // Debounce: skip if already queued or processing\n if (hasPendingOrProcessingOfType(\"registry-scan\")) {\n process.stderr.write(\"[work-queue-worker] Registry scan already pending/processing — skipping duplicate enqueue.\\n\");\n return;\n }\n\n enqueue({\n type: \"registry-scan\",\n priority: 5, // lowest priority — runs after session-end, session-summary\n payload: {},\n });\n}\n\n// ---------------------------------------------------------------------------\n// Transcript parsing helpers (mirrors stop-hook.ts logic)\n// ---------------------------------------------------------------------------\n\nfunction tryParseJson(line: string): Record<string, unknown> | null {\n try {\n return JSON.parse(line) as Record<string, unknown>;\n } catch {\n return null;\n }\n}\n\nfunction contentToText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === \"string\") return c;\n const block = c as Record<string, unknown>;\n if (block?.text) return String(block.text);\n if (block?.content) return String(block.content);\n return \"\";\n })\n .join(\" \")\n .trim();\n }\n return \"\";\n}\n\nfunction extractWorkFromTranscript(lines: string[]): NoteWorkItem[] {\n const workItems: NoteWorkItem[] = [];\n const seenSummaries = new Set<string>();\n\n for (const line of lines) {\n const entry = tryParseJson(line);\n if (!entry || entry.type !== \"assistant\") continue;\n\n const msg = entry.message as Record<string, unknown> | undefined;\n if (!msg?.content) continue;\n\n const content = contentToText(msg.content);\n\n // SUMMARY: line (preferred)\n const summaryMatch = content.match(/SUMMARY:\\s*(.+?)(?:\\n|$)/i);\n if (summaryMatch) {\n const summary = summaryMatch[1].trim();\n if (summary && !seenSummaries.has(summary) && summary.length > 5) {\n seenSummaries.add(summary);\n\n const details: string[] = [];\n const actionsMatch = content.match(/ACTIONS:\\s*(.+?)(?=\\n[A-Z]+:|$)/is);\n if (actionsMatch) {\n const actionLines = actionsMatch[1]\n .split(\"\\n\")\n .map((l) =>\n l.replace(/^[-*•]\\s*/, \"\").replace(/^\\d+\\.\\s*/, \"\").trim()\n )\n .filter((l) => l.length > 3 && l.length < 100);\n details.push(...actionLines.slice(0, 3));\n }\n\n workItems.push({\n title: summary,\n details: details.length > 0 ? details : undefined,\n completed: true,\n });\n }\n }\n\n // COMPLETED: line (fallback)\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (completedMatch && workItems.length === 0) {\n const completed = completedMatch[1]\n .trim()\n .replace(/\\*+/g, \"\")\n .replace(/\\[.*?\\]/g, \"\")\n .trim();\n if (completed && !seenSummaries.has(completed) && completed.length > 5) {\n seenSummaries.add(completed);\n workItems.push({ title: completed, completed: true });\n }\n }\n }\n\n return workItems;\n}\n"],"mappings":"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"}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import "./utils-C9HsYDpO.mjs";
|
|
2
2
|
import "./migrate-BD7D8EEh.mjs";
|
|
3
3
|
import "./factory-CNpPQQ-5.mjs";
|
|
4
|
-
import "./context-handover-cache-
|
|
4
|
+
import "./context-handover-cache-pkHzmL3c.mjs";
|
|
5
5
|
import "./config-BbLFD7Uf.mjs";
|
|
6
|
-
import "./run-env-
|
|
7
|
-
import "./server-
|
|
6
|
+
import "./run-env-DRcp7A8K.mjs";
|
|
7
|
+
import "./server-DgmAHyFK.mjs";
|
|
8
8
|
import "./state-HyjqTihC.mjs";
|
|
9
|
-
import { i as stopWorker, n as notifyNewWork, r as startWorker, t as enqueueRegistryScan } from "./work-queue-worker-
|
|
9
|
+
import { i as stopWorker, n as notifyNewWork, r as startWorker, t as enqueueRegistryScan } from "./work-queue-worker-4tLa5gpQ.mjs";
|
|
10
10
|
|
|
11
11
|
export { enqueueRegistryScan };
|
package/docs/commands/README.md
CHANGED
|
@@ -270,6 +270,7 @@ Generated man pages for every `pai` command area. Read any page in the terminal
|
|
|
270
270
|
| `pai worker run [args...]` | Run one claude-code worker through the configured provider. |
|
|
271
271
|
| `pai worker say <id> <text>` | Send one message to a running worker (forwarded to its open stdin) |
|
|
272
272
|
| `pai worker status-line [term] [cwd] [session]` | One-line worker summary for a status bar (empty when none in scope). |
|
|
273
|
+
| `pai worker verify <id>` | Compare a worker's committed changes with the working tree (or --against <ref>); exit 0 when every file is identical, so discarding is safe. Also reads an archived worker (refs/pai-archive/<id>) |
|
|
273
274
|
| `pai worker wait <ids...>` | Poll workers until they finish; prints each result as one JSON line, exit 1 on failure or timeout |
|
|
274
275
|
| `pai worker watch` | ps refreshed every 2 seconds (plain `watch`, colors kept) |
|
|
275
276
|
| `pai zettel` | Zettelkasten intelligence: explore, surprise, converse, themes, health, suggest |
|
package/docs/commands/worker.md
CHANGED
|
@@ -30,6 +30,7 @@ pai worker <subcommand> [options]
|
|
|
30
30
|
| [`pai worker goal <id> <text>`](#pai-worker-goal-id-text) | Relabel a running worker (its ps / pane goal) without sending it a message |
|
|
31
31
|
| [`pai worker handoff <json>`](#pai-worker-handoff-json) | From inside a worker: append a handoff to the parent's inbox and (when it runs) say it to the parent. |
|
|
32
32
|
| [`pai worker merge <id>`](#pai-worker-merge-id) | Merge a worker's worktree branch (worker/<id>) into the original checkout, then remove the worktree and delete the branch |
|
|
33
|
+
| [`pai worker verify <id>`](#pai-worker-verify-id) | Compare a worker's committed changes with the working tree (or --against <ref>); exit 0 when every file is identical, so discarding is safe. Also reads an archived worker (refs/pai-archive/<id>) |
|
|
33
34
|
| [`pai worker wait <ids...>`](#pai-worker-wait-ids) | Poll workers until they finish; prints each result as one JSON line, exit 1 on failure or timeout |
|
|
34
35
|
| [`pai worker discard <id>`](#pai-worker-discard-id) | Drop a worker's worktree and branch, keeping nothing |
|
|
35
36
|
| [`pai worker gc`](#pai-worker-gc) | Archive leftover worker worktrees (state kept under refs/pai-archive/<id>; gitignored files are not kept) |
|
|
@@ -93,6 +94,10 @@ Grant MCP tools by naming mcp__server__tool in --allowedTools (the server loads
|
|
|
93
94
|
| `--no-pane` | Do not open a follow pane for this worker | |
|
|
94
95
|
| `--worktree` | Run in a git worktree on branch worker/<id> (default for implement/complex/plan in a git repo) | |
|
|
95
96
|
| `--no-worktree` | Run in place, no worktree | |
|
|
97
|
+
| `--max-minutes <n>` | Stop the run after n minutes, salvaging its work (0 = no limit; default workers.defaultMaxMinutes, 60) | |
|
|
98
|
+
| `--deadline <HH:MM>` | Stop the run at this local time (tomorrow if already past); exclusive with --max-minutes | |
|
|
99
|
+
| `--browser` | Allow browser tools (re-enables them when workers.noBrowserByDefault is on) | |
|
|
100
|
+
| `--no-browser` | Strip --chrome and every browser MCP tool from this worker and deny them in its hooks | |
|
|
96
101
|
| `--print-cmd` | Print the assembled claude argv as JSON and exit, without spawning (audit tool) | |
|
|
97
102
|
| `--report <format>` | Final-report contract/parser: json or ag2 (default: ag2, or PAI_WORKER_REPORT) | |
|
|
98
103
|
| `--no-report-retry` | Skip the one bounded re-ask when the final AG2 message fails validation | |
|
|
@@ -243,6 +248,31 @@ Merge a worker's worktree branch (worker/<id>) into the original checkout, then
|
|
|
243
248
|
|----------|------|
|
|
244
249
|
| `<id>` | required |
|
|
245
250
|
|
|
251
|
+
**Options**
|
|
252
|
+
|
|
253
|
+
| Option | Description | Default |
|
|
254
|
+
|--------|-------------|---------|
|
|
255
|
+
| `--no-commit` | apply the changes as uncommitted working-tree changes; keep branch and worktree | |
|
|
256
|
+
| `--patch` | alias for --no-commit | |
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
### pai worker verify <id>
|
|
260
|
+
|
|
261
|
+
Compare a worker's committed changes with the working tree (or --against <ref>); exit 0 when every file is identical, so discarding is safe. Also reads an archived worker (refs/pai-archive/<id>)
|
|
262
|
+
|
|
263
|
+
**Arguments**
|
|
264
|
+
|
|
265
|
+
| Argument | Kind |
|
|
266
|
+
|----------|------|
|
|
267
|
+
| `<id>` | required |
|
|
268
|
+
|
|
269
|
+
**Options**
|
|
270
|
+
|
|
271
|
+
| Option | Description | Default |
|
|
272
|
+
|--------|-------------|---------|
|
|
273
|
+
| `--against <ref>` | compare against this ref instead of the working tree | |
|
|
274
|
+
| `--json` | print the result as JSON | |
|
|
275
|
+
|
|
246
276
|
|
|
247
277
|
### pai worker wait <ids...>
|
|
248
278
|
|
package/package.json
CHANGED
|
@@ -57,6 +57,13 @@ const DENY: [string, ReturnType<typeof bash>, WorkerGuardContext, string][] = [
|
|
|
57
57
|
["tilde settings.json sed -i", bash("sed -i s/a/b/ ~/.claude/settings.json"), wtCtx(), "temp-dir copies"],
|
|
58
58
|
["cp onto pai yaml", bash("cp /tmp/x.yaml ~/.claude/pai/config.yaml"), wtCtx(), "temp-dir copies"],
|
|
59
59
|
["mv pai json", bash("mv /tmp/x ~/.claude/pai/config.json"), wtCtx(), "temp-dir copies"],
|
|
60
|
+
["pkill -f real command", bash(`pkill -f "uvicorn paperfull_server.main:app --port 8490"`), wtCtx(), "by PID"],
|
|
61
|
+
["pkill after &&", bash("cd x && pkill node"), wtCtx(), "by PID"],
|
|
62
|
+
["sudo killall", bash("sudo killall node"), wtCtx(), "by PID"],
|
|
63
|
+
["kill $(pgrep)", bash("kill $(pgrep -f uvicorn)"), wtCtx(), "by PID"],
|
|
64
|
+
["kill backtick pgrep", bash("kill -9 `pgrep node`"), wtCtx(), "by PID"],
|
|
65
|
+
["pgrep | xargs kill", bash("pgrep -f uvicorn | xargs kill"), wtCtx(), "by PID"],
|
|
66
|
+
["pkill in bash -c", bash(`bash -c 'pkill -9 node'`), wtCtx(), "by PID"],
|
|
60
67
|
];
|
|
61
68
|
|
|
62
69
|
const ALLOW: [string, ReturnType<typeof bash>, WorkerGuardContext][] = [
|
|
@@ -77,6 +84,11 @@ const ALLOW: [string, ReturnType<typeof bash>, WorkerGuardContext][] = [
|
|
|
77
84
|
["read live config", bash("cat ~/.claude/settings.json ~/.claude.json | head"), wtCtx()],
|
|
78
85
|
["write other Notes file", bash("echo x > Notes/other.md"), wtCtx()],
|
|
79
86
|
["write deep pai file", bash("echo x > ~/.claude/pai/logs/x.json"), wtCtx()],
|
|
87
|
+
["kill by pid", bash("kill 1234"), wtCtx()],
|
|
88
|
+
["kill pidfile", bash("kill $(cat /tmp/x.pid)"), wtCtx()],
|
|
89
|
+
["kill job", bash("sleep 5 & kill %1"), wtCtx()],
|
|
90
|
+
["pgrep alone", bash("pgrep -f uvicorn"), wtCtx()],
|
|
91
|
+
["pkill in echo", bash("echo pkill is denied"), wtCtx()],
|
|
80
92
|
["stderr dup redirect", bash("npm test 2>&1 | tail"), wtCtx()],
|
|
81
93
|
["git checkout -b in worktree", bash("git checkout -b topic"), wtCtx()],
|
|
82
94
|
];
|
|
@@ -19,6 +19,7 @@ import { readFileSync } from "node:fs";
|
|
|
19
19
|
import { dirname, resolve } from "node:path";
|
|
20
20
|
import { type GateDecision } from "./sleep-poll-gate.js";
|
|
21
21
|
import { decideWorkerGitGuard } from "./worker-git-guard.js";
|
|
22
|
+
import { isBrowserTool } from "../../../workers/browser-tools.js";
|
|
22
23
|
|
|
23
24
|
export interface WorkerGuardInput {
|
|
24
25
|
tool_name?: string;
|
|
@@ -193,6 +194,11 @@ function gitSub(args: string[]): { sub: string; rest: string[] } | null {
|
|
|
193
194
|
return null;
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
const PATTERN_KILL = new Set(["pkill", "killall"]);
|
|
198
|
+
const PATTERN_KILL_REASON =
|
|
199
|
+
"pattern-based process kill blocked: kill only what you started, by PID (`cmd & echo $! > /tmp/x.pid`, then " +
|
|
200
|
+
"`kill $(cat /tmp/x.pid)`), never by name or pattern; other workers' command lines contain your spec text";
|
|
201
|
+
|
|
196
202
|
/** Decide one segment; `st.cwd` is the tracked working directory. */
|
|
197
203
|
function decideSegment(seg: Segment, st: { cwd: string; raw: string }, ctx: WorkerGuardContext): GateDecision {
|
|
198
204
|
const words = stripPrefix(seg.words);
|
|
@@ -212,6 +218,10 @@ function decideSegment(seg: Segment, st: { cwd: string; raw: string }, ctx: Work
|
|
|
212
218
|
|
|
213
219
|
const pos = args.filter((a) => !isFlag(a));
|
|
214
220
|
|
|
221
|
+
if (PATTERN_KILL.has(cmd) || (cmd === "xargs" && args.includes("kill")) || (cmd === "kill" && /pgrep|pidof/.test(args.join(" ")))) {
|
|
222
|
+
return deny(PATTERN_KILL_REASON);
|
|
223
|
+
}
|
|
224
|
+
|
|
215
225
|
if (cmd === "cd") {
|
|
216
226
|
const p = pos[0] ? abs(pos[0], st.cwd, ctx.home) : null;
|
|
217
227
|
if (!p) return ALLOW;
|
|
@@ -300,6 +310,9 @@ export function decideWorkerGuard(
|
|
|
300
310
|
const tool = input.tool_name;
|
|
301
311
|
const ti = input.tool_input ?? {};
|
|
302
312
|
|
|
313
|
+
if (env.PAI_WORKER_NO_BROWSER === "1" && tool?.startsWith("mcp__") && isBrowserTool(tool)) {
|
|
314
|
+
return deny(`${tool} blocked: this worker runs with --no-browser`);
|
|
315
|
+
}
|
|
303
316
|
if (tool === "Bash") {
|
|
304
317
|
return typeof ti.command === "string" ? decideCommand(ti.command, ctx()) : ALLOW;
|
|
305
318
|
}
|