A quiet side pane for Claude Code Desktop: what needs you, plan limits, context, agents and risky steps.

vaken is a quiet side pane for the Claude Code DESKTOP app. It needs the pane of the desktop app (macOS, and Windows 11 in beta). It does not work in claude.ai chat, in Cowork or in the terminal program alone.
Not affiliated with Anthropic.
The pane sits to the right of your chat. It shows what needs you, your plan limits, how full the context is, your agents and sessions, pull requests and checks, and the cost per day. It also keeps short notes for your next session.
vaken looks at each step Claude is about to take, before it runs. It warns, asks or stops risky steps: bulk deletes, forced pushes, secrets, outside actions such as posts and payments, and very young or look-alike packages. A destructive command or a secret stops the step when Claude Code would not ask you first. It is a seat belt, not a sandbox. /vaken-pause pauses every change step.
Run these lines in a terminal, then open a NEW session in Claude Code Desktop and type /vaken.
claude plugin marketplace add johanmatsgard/vaken
claude plugin install vaken@vaken
vaken has no server and no telemetry. It sends nothing to its authors. It never changes your settings.json.
Programs it starts on your computer. vaken has no network code of its own. Each program uses the network only as it normally does.
git: branch, status, diff and worktree facts. It never fetches or pushes.gh (GitHub CLI): pull requests, checks and merge. Only if you installed it and signed in.curl: to http://localhost:<port>, to see if your dev server answers. To another address only if you type it.localhost addresses and html files in your project.ps, lsof, top, sysctl, vm_stat, pmset: read the load of your computer and the processes of your sessions. On Windows: tasklist, netstat and PowerShell.caffeinate (PowerShell on Windows): only when you turn on keep-awake.The one network request vaken starts itself. Before an install, the package check runs npm view <package name> (or the same command of pnpm, yarn or bun). This asks the public npm registry, https://registry.npmjs.org/. Only the package name is sent.
What goes to Claude. By default vaken makes no extra model calls. It adds short safety notes to what Claude already reads, and a note about your goal and next steps when a session is compacted. More goes to Claude only when you choose it: features such as Plan first or Prompt review, buttons that send a message, "Say it simpler", and the cache keeper (off by default). All of it goes through Claude Code, with your account.
Built-in app tools. vaken calls the desktop app's own built-in tools on your computer: the session tools (to read your plan usage, list your sessions and, when you press a button, change a session's model or effort) and the window tool (to open the pane in a new window). These calls stay inside the app.
Where it stores data. On your computer only.
cockpit:.~/.claude/cockpit/: copies of changed files for Undo, hand-over notes, and pictures..claude/cockpit-* files and .claude/worktrees/.vaken does not store your prompts or transcripts as text. "Delete everything vaken stored" in Settings removes the store keys and the files under ~/.claude/cockpit/.
Where vaken changes an event before it passes it on, it does so on purpose. This list gives the reason and the setting. Defaults are in hooks/core/settings.ts and the hooks/core/ file of each feature; presets are in hooks/core/presets.ts.
Notes added to what Claude reads.
assumptions.ts, prompt.submit): one line asks Claude to list its assumptions. Setting assume.check, default off.planfirst.ts, prompt.submit): one line asks for a short plan. Only when plan.first is Ask. Default Suggest shows a line to you only.promptcheck.ts, prompt.submit): one line asks Claude to ask one question about a short prompt. Only when prompt.check is Ask. Default Tip shows a line to you only.proof.ts, prompt.submit): a note that a claim has no passing check. Only when claude.notes is "All checks"; default "Safety only". With Claims on (default Warn), a "done" claim without a passing check also gets a one-line warning in the turn result.compaction.ts, session.compact): your goal, branch, changed files and next steps are added to the compaction instructions. Setting compact.card, default on.guard.ts, tool.call): a short safety note after a warned step. Always on.Model routing (route.ts, agent.spawn). With route.mode Auto, a helper that only reads or searches gets a cheaper model. Plan and review helpers, helpers from your own agent files, and a model Claude chose are not changed. Default Suggest changes nothing.
Task runner (orchestra.ts, agent.spawn). When a run started with /vaken-tasks asks Claude to start agents, this hook swaps the placeholder prompt for the full task text and sets the agent type, model and folder. It acts only on agents of your run.
Away mode (away.ts, command.run). It handles its own /vaken-away command and removes a time such as "for 2h" from the arguments before the base switch reads them. away.policy (default Off) makes the guard stricter while you are away.
Permission.
classic.Notification hook (home.tsx) notes when Claude Code asks you for permission, so the pane can show "needs you". It only records that and passes the event on; it never answers.guard.ts, tool.call) answers only deny, or passes the step on. With a block set to Stop it denies a risky step with a reason. With Warn (default) it adds a note. It asks Claude Code what it would decide ($.tool.check) and reads settings ($.settings.read), and changes neither./vaken-guard allow once, stores a one-time permit for that exact step (tool name and a hash of its input). The guard does not hold that step when Claude tries it again, and the permit is used up./vaken-pause denies every step that changes things in this session until you lift it.Hooks that see network, MCP and process calls. The guard reads the name and input of each call to classify it (push, post, payment, install). The agent board, agent cap and away hooks read agent calls to count them. None of them runs, changes or sends the call. They keep counts and short words, not contents. Their only changes are a guard deny and an added note.
agentcap.ts. It limits how many agents run at once. It writes no file and starts no program. It keeps only a count in the plugin store (cockpit:agents:<session>). Write and run functions in its file come from a block all feature files share.
/compact (compaction.ts). Vaken runs it when compact.mode is Auto and the session is idle, or when you press "Compact now". It first asks the app to compact directly, and uses /compact only if the app refuses.
The app's built-in tools (continuity.ts, pane). Nothing leaves your computer.
ccd_session_mgmt: get_usage, list_sessions and search_session_transcripts only read. set_session_model and set_session_effort change another session, only when you press a model-fit button; the pane offers Undo.ccd_window: open_session_in opens this session in its own window when you press Pop out.Agents and prompts (home.tsx, orchestra.ts).
$.agent.spawn) only for a run you started. The next task of that run starts by itself, one at a time.TaskStop is called when you press Stop, or when a task passes the time or cost limit you set.$.prompt.submit) only when you press a button, with fixed text: "Send the better one", "Nudge" (continue), a fix button, and the task runner's plan and start requests. One more case: with resume.mode set to Resume (default Remind), it submits "The plan limit has reset. Continue where you stopped." after a limit resets, with a daily cap.Added to Claude Code. Agents vaken:researcher, vaken:builder, vaken:reviewer and vaken:fixer for the task runner (models set by runner.*). One tool, vaken_status, which returns the session goal, checks and last turn from the store (steer.mode, default on). Slash commands: /vaken, /vaken-away, /vaken-compact, /vaken-explain, /vaken-guard, /vaken-help, /vaken-memory, /vaken-pause, /vaken-resume, /vaken-settings, /vaken-ship, /vaken-tasks, /vaken-today, /vaken-undo, /vaken-usage.
Files it writes, on your computer only. Under ~/.claude/cockpit/: Undo copies (automatic), briefs, pictures and browser profiles. In your project, on request: .claude/cockpit-tasks.md, -packet.md, -audit.md, -decisions.md, .claude/cockpit-eyes/, and .claude/worktrees/. Agent and skill files you accept go in ~/.claude/agents/ and your skills folder; Agent setup keeps a .bak copy.
"Uses a credential from the user's machine." vaken reads no credential, token or key file. It reads only the environment names HOME, USERPROFILE, HOMEDRIVE, HOMEPATH, OS, LANG, LANGUAGE and LC_ALL. Words such as "secret" and "token" in the code are patterns the guard uses to detect a secret in a step of Claude. gh uses its own sign-in.
lines.tsx. A lazy getter builds a card view only when the pane reads it.
License: MIT.
hooks/register.tsx 118 lines1// Vaken: one plugin, one module
2import type { Register } from 'claude-code'
3
4import { registerAgentCap } from './features/agentcap'
5import { registerAway } from './features/away'
6import { registerAttention } from './features/attention'
7import { registerCacheKeeper } from './features/cachekeeper'
8import { registerCalm } from './features/calm'
9import { registerCompaction } from './features/compaction'
10import { registerCost } from './features/cost'
11import { registerInsight } from './features/insight'
12import { registerKeeper } from './features/keeper'
13import { registerInbox } from './features/inbox'
14import { registerContinuity } from './features/continuity'
15import { registerCoach } from './features/coach'
16import { registerCrew3 } from './features/crew3'
17import { registerCrew } from './features/crew'
18import { registerDiet } from './features/diet'
19import { registerErrors } from './features/errors'
20import { registerEyes } from './features/eyes'
21import { registerGuard } from './features/guard'
22import { registerGuardBack } from './features/guardback'
23import { registerGuardWorld } from './features/guardworld'
24import { registerHappened } from './features/happened'
25import { registerHome } from './features/home'
26import { registerLoopBreaker } from './features/loopbreaker'
27import { registerAgentBoard } from './features/agentboard'
28import { registerOrchestra } from './features/orchestra'
29import { registerSimple } from './features/simple'
30import { registerAssumptions } from './features/assumptions'
31import { registerNeighbors } from './features/neighbors'
32import { registerExplain } from './features/explain'
33import { registerFreshStart } from './features/freshstart'
34import { registerPhone } from './features/phone'
35import { registerPhoneDo } from './features/phonedo'
36import { registerPlanFirst } from './features/planfirst'
37import { registerStarters } from './features/starters'
38import { registerSetup } from './features/setup'
39import { registerPlain } from './features/plain'
40import { registerPromptCheck } from './features/promptcheck'
41import { registerShape } from './features/shape'
42import { registerToday } from './features/today'
43import { registerValue } from './features/value'
44import { registerNotes } from './features/notes'
45import { registerPresets } from './features/presets'
46import { registerProof2 } from './features/proof2'
47import { registerProof } from './features/proof'
48import { registerResume } from './features/resume'
49import { registerRoute } from './features/route'
50import { registerSteer } from './features/steer'
51import { registerTrust } from './features/trust'
52import { registerUndo } from './features/undo'
53import { registerShip } from './features/ship'
54import { registerSkills } from './features/skills'
55import { registerStability } from './features/stability'
56import { registerPressLog } from './features/presslog'
57
58export const register: Register = on => {
59 // Its word after /vaken-help run must reach it before the help page answers
60 registerPressLog(on)
61 registerHome(on)
62 registerCompaction(on)
63 registerCacheKeeper(on)
64 registerLoopBreaker(on)
65 registerStability(on)
66 registerAttention(on)
67 registerHappened(on)
68 registerAgentCap(on)
69 registerGuard(on)
70 // RegisterAway wraps the away switch that registerGuardWorld registers, so it comes first
71 registerAway(on)
72 registerGuardWorld(on)
73 registerInbox(on)
74 // Hello on the phone: reads what the inbox reads
75 registerPhone(on)
76 // Run a command from the last answer: /vaken-help run do, for a phone and for the card
77 registerPhoneDo(on)
78 registerGuardBack(on)
79 registerProof(on)
80 registerProof2(on)
81 registerCost(on)
82 registerRoute(on)
83 registerPlain(on)
84 registerPromptCheck(on)
85 registerAssumptions(on)
86 registerShape(on)
87 registerValue(on)
88 registerToday(on)
89 // Focus and the open ends of yesterday
90 registerCalm(on)
91 registerFreshStart(on)
92 registerPlanFirst(on)
93 registerStarters(on)
94 registerSetup(on)
95 registerNeighbors(on)
96 registerAgentBoard(on)
97 registerOrchestra(on)
98 registerSimple(on)
99 registerExplain(on)
100 registerErrors(on)
101 registerSteer(on)
102 registerDiet(on)
103 registerCrew(on)
104 registerCrew3(on)
105 registerEyes(on)
106 registerNotes(on)
107 registerCoach(on)
108 registerInsight(on)
109 registerPresets(on)
110 registerResume(on)
111 registerShip(on)
112 registerUndo(on)
113 registerTrust(on)
114 registerContinuity(on)
115 registerKeeper(on)
116 registerSkills(on)
117}
118hooks/features/agentcap.ts 149 lines1// Agent cap runtime: count helper agents in flight (this session in memory, every session in the shared store) and warn or hold at the limit.
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import { APP_USAGE_KEY, parseStoredUsage } from '../core/appusage'
5import { postBand } from '../core/band'
6import { BOARD_KEY, parseBoard, runningCount } from '../core/agentboard'
7import { CAP_HOLD_WHAT, CAP_WHAT, capMaxOf, capVerdict, parseCount, roomVerdict, totalRunning } from '../core/agentcap'
8import { roomOf } from '../core/computer'
9import type { Room } from '../core/computer'
10import { LAPTOP_KEY } from '../core/crewlaptop'
11import type { Laptop } from '../core/crewlaptop'
12import { withToastGate } from '../core/io'
13import { homeFrom } from '../core/platform'
14import type { Io } from '../core/io'
15import { HISTORY_KEY, parseHistory } from '../core/pace'
16import { HELPER_NOTE_MIN, helperFitNote, windowMinutesLeft } from '../core/planfit'
17import { readSetting } from '../core/settings'
18import { tellsClaude } from '../core/tellclaude'
19import { LEDGER_KEY, pushLog } from './compaction'
20
21// IO-BLOCK-START
22function io($: EngineInterface): Io {
23 return withToastGate({
24 now: () => $.clock.now(),
25 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
26 sessionId: () => $.session.id(),
27 get: key => $.store.get(key),
28 set: (key, value) => $.store.set(key, value),
29 del: key => $.store.delete(key),
30 keys: () => $.store.keys(),
31 read: path => $.fs.read(path),
32 write: (path, text) => $.fs.write(path, text),
33 run: (argv, init) => $.process.run(argv, init),
34 ask: (question, options) => $.ui.ask(question, options),
35 complete: req => $.model.complete(req),
36 toast: text => $.ui.toast(text),
37 invalidate: () => $.ui.invalidate('ui.render'),
38 })
39}
40// IO-BLOCK-END
41
42const PREFIX = 'cockpit:agents:'
43let mine = 0
44let warnedAt = 0
45const WARN_GAP_MS = 5 * 60_000
46let fitAt = -Infinity
47const FIT_GAP_MS = 10 * 60_000
48
49export async function runningNow(i: Io): Promise<number> {
50 const keys = (await i.keys()).filter(k => k.startsWith(PREFIX))
51 const counts = []
52 for (const k of keys) counts.push(parseCount(await i.get(k)))
53 const now = await i.now()
54 // Background helpers are counted from the agent board: only the start call of one runs inside this hook.
55 return totalRunning(counts, now) + runningCount(parseBoard(await i.get(BOARD_KEY)), now)
56}
57async function roomNow(i: Io, running: number): Promise<Room | undefined> {
58 if ((await readSetting(i, 'laptop.mode')) !== 'on') return undefined
59 const kept = (await i.get(LAPTOP_KEY)) as Laptop | undefined
60 if (typeof kept !== 'object' || kept === null || typeof kept.at !== 'number') return undefined
61 return roomOf(kept, { running, max: capMaxOf(await readSetting(i, 'cap.max')), capOn: (await readSetting(i, 'cap.mode')) !== 'off' }, await i.now())
62}
63
64// Never holds a helper.
65async function fitNote(i: Io): Promise<string | undefined> {
66 const now = await i.now()
67 if (now - fitAt < FIT_GAP_MS || (await readSetting(i, 'limits.warn')) === 'off') return undefined
68 const usage = parseStoredUsage(await i.get(APP_USAGE_KEY))
69 if (usage === undefined) return undefined
70 const left = windowMinutesLeft(usage, parseHistory(await i.get(HISTORY_KEY)), now)
71 if (left === undefined || left > HELPER_NOTE_MIN) return undefined
72 fitAt = now
73 return helperFitNote(left)
74}
75
76const publish = async (i: Io): Promise<void> => i.set(PREFIX + (await i.sessionId()), { n: mine, at: await i.now() })
77
78const HEARTBEAT_MS = 4 * 60_000
79let heartbeat: Timer | undefined
80function startHeartbeat($: EngineInterface): void {
81 if (heartbeat !== undefined) return
82 const timer = $.clock.every(HEARTBEAT_MS, () => void publish(io($)).catch(() => undefined))
83 heartbeat = timer
84}
85const stopHeartbeat = (): void => {
86 if (mine > 0) return
87 heartbeat?.cancel()
88 heartbeat = undefined
89}
90
91export function registerAgentCap(on: On): void {
92 on('tool.call', { tool: /(?<agentcap>)/ }, async ($, e, next) => {
93 if (e.tool !== 'Agent') return next(e)
94 const i = io($)
95 let counted = false
96 let note: string | undefined
97 try {
98 const mode = await readSetting(i, 'cap.mode')
99 const running = await runningNow(i)
100 let v = capVerdict(mode, running, capMaxOf(await readSetting(i, 'cap.max')))
101 if (!v.act && mode !== 'off') v = roomVerdict(mode, await roomNow(i, running))
102 if (v.act) {
103 const now = await i.now()
104 if (v.hold) {
105 await pushLog(i, LEDGER_KEY, { at: now, session: await i.sessionId(), what: CAP_HOLD_WHAT, detail: v.reason, how: 'auto', did: true })
106 await postBand(i, 'warn', 'agentcap', v.reason.includes('computer is full') ? 'Agent stopped: the computer is full' : 'Agent cap reached', 3 * 60_000)
107 return { deny: v.reason }
108 }
109 if (now - warnedAt >= WARN_GAP_MS) {
110 warnedAt = now
111 await pushLog(i, LEDGER_KEY, { at: now, session: await i.sessionId(), what: CAP_WHAT, detail: v.reason, how: 'auto', did: true })
112 i.toast(v.reason)
113 }
114 }
115 mine += 1
116 counted = true
117 startHeartbeat($)
118 await publish(i)
119 note = (await tellsClaude(i)) ? await fitNote(i) : undefined
120 } catch {
121 // the limit never breaks the session
122 }
123 try {
124 const result = await next(e)
125 return note === undefined || result.deny !== undefined ? result : { ...result, context: [...(result.context ?? []), note] }
126 } finally {
127 if (counted) {
128 mine = Math.max(0, mine - 1)
129 stopHeartbeat()
130 try {
131 await publish(i)
132 } catch {
133 }
134 }
135 }
136 }).catch(($, e, next) => next(e))
137
138 // A closed session takes its count away at once; it does not wait out the count's time limit.
139 on('session.end', { sessionId: /(?<agentcapend>)/ }, async ($, e, next) => {
140 try {
141 mine = 0
142 stopHeartbeat()
143 await io($).del(PREFIX + e.sessionId)
144 } catch {
145 }
146 return next(e)
147 }).catch(($, e, next) => next(e))
148}
149hooks/features/away.ts 256 lines1// Away recap and the Away mode.
2import { tr } from '../core/lang'
3import type { EngineInterface, On } from 'claude-code'
4
5import { buildDigest, costSince, awayCall, modeLines, parseDigest, parseEvents, parseRun, parseSaved, restoreLines, watchEvents, digestText } from '../core/away'
6import type { Digest, Restore, Run, Saved } from '../core/away'
7import { AWAY_EVENTS_KEY, AWAY_RUN_KEY, AWAY_SAVED_KEY, BUDGET_SET, COST_STATE_PREFIX, DIGEST_KEY, DIGEST_WHAT, NO_DIGEST, POLICY_SET, seenToast, timerEndDetail, timerToast } from '../core/away'
8import { SESSION_BUDGETS, parseState, spendOf } from '../core/cost'
9import { optionNumber } from '../core/budget'
10import { clearBand } from '../core/band'
11import { AWAY_KEY, AWAY_OFF_WHAT, AWAY_POLICY_KEY, awayText, parseAway } from '../core/guardworld'
12import { withToastGate } from '../core/io'
13import { homeFrom } from '../core/platform'
14import type { Io } from '../core/io'
15import { listRecords } from '../core/record'
16import { findSetting, readSetting, settingStoreKey, writeSetting } from '../core/settings'
17import { LEDGER_KEY, pushLog, readLog } from './compaction'
18import { collectInbox, liveRows } from './inbox'
19import { armOnce } from './everyonce'
20import { isFolded, subArgs } from '../core/menu'
21import { BY_PERSON_ONLY, byPerson } from '../core/origin'
22
23// IO-BLOCK-START
24function io($: EngineInterface): Io {
25 return withToastGate({
26 now: () => $.clock.now(),
27 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
28 sessionId: () => $.session.id(),
29 get: key => $.store.get(key),
30 set: (key, value) => $.store.set(key, value),
31 del: key => $.store.delete(key),
32 keys: () => $.store.keys(),
33 read: path => $.fs.read(path),
34 write: (path, text) => $.fs.write(path, text),
35 run: (argv, init) => $.process.run(argv, init),
36 ask: (question, options) => $.ui.ask(question, options),
37 complete: req => $.model.complete(req),
38 toast: text => $.ui.toast(text),
39 invalidate: () => $.ui.invalidate('ui.render'),
40 })
41}
42// IO-BLOCK-END
43
44const CHECK_MS = 20_000
45const BUDGET_KEY = 'cost.budget'
46const SESSION_BUDGET_KEY = 'cost.session'
47
48// Every session also checks, so the mode ends even if this session is gone.
49let endTimer: { cancel: () => void } | undefined
50
51const ledger = async (i: Io, sid: string, what: string, detail: string, how: 'auto' | 'typed'): Promise<void> => pushLog(i, LEDGER_KEY, { at: await i.now(), session: sid, what, detail, how, did: true })
52
53// What each session has cost, in dollars: the engine's own total when it gave one
54async function costNow(i: Io, now: number): Promise<Record<string, number>> {
55 const out: Record<string, number> = {}
56 for (const k of (await i.keys()).filter(x => x.startsWith(COST_STATE_PREFIX))) out[k.slice(COST_STATE_PREFIX.length)] = spendOf(parseState(await i.get(k), now)).usd
57 return out
58}
59
60async function collectEvents(i: Io, run: Run) {
61 const { rows } = await liveRows(i)
62 const known = parseEvents(await i.get(AWAY_EVENTS_KEY))
63 const events = watchEvents(
64 rows.map(r => ({ sid: r.rec.sessionId, name: r.name, kind: r.kind, since: r.kind === 'needs' && r.rec.ask !== undefined ? r.rec.ask.since : r.rec.stateSince })),
65 run.at,
66 known,
67 )
68 if (events.length !== known.length) await i.set(AWAY_EVENTS_KEY, events)
69 return events
70}
71
72async function digestNow(i: Io, run: Run, ended: boolean, to: number): Promise<Digest> {
73 const events = await collectEvents(i, run)
74 const spent = costSince(run.costBase, await costNow(i, to))
75 const { items } = await collectInbox(i)
76 return buildDigest({ from: run.at, to, ledger: await readLog(i, LEDGER_KEY), events, spent, waiting: items, ended })
77}
78
79export async function freshWhileAway(i: Io, text: string): Promise<string> {
80 const fresh = awayText({ away: parseAway(await i.get(AWAY_KEY)) !== undefined, policy: (await i.get(settingStoreKey(AWAY_POLICY_KEY))) === 'careful' ? 'careful' : 'off', refused: [] }).split('\n')[1] ?? ''
81 return text
82 .split('\n')
83 .map(l => (l.startsWith('While away: ') ? fresh : l)) // en-only: the start of the switch's own line, matched, not shown
84 .join('\n')
85}
86
87// Sets only what is not already as wanted, and keeps the old values so the end can put them back.
88async function startRun(i: Io, awayAt: number, forMs: number | undefined): Promise<{ run: Run; saved: Saved } | undefined> {
89 if (parseRun(await i.get(AWAY_RUN_KEY)) !== undefined) return undefined
90 const now = await i.now()
91 const costBase = await costNow(i, now)
92 const run: Run = { at: awayAt, ...(forMs === undefined ? {} : { until: now + forMs }), costBase }
93 // The run is written first: it is the claim, so two sessions cannot both begin the mode.
94 await i.set(AWAY_RUN_KEY, run)
95 await i.set(AWAY_EVENTS_KEY, [])
96 const saved: Saved = {}
97 const rawPolicy = await i.get(settingStoreKey(AWAY_POLICY_KEY))
98 if (rawPolicy !== POLICY_SET && findSetting(AWAY_POLICY_KEY) !== undefined && (await writeSetting(i, AWAY_POLICY_KEY, POLICY_SET))) saved.policy = { old: typeof rawPolicy === 'string' ? rawPolicy : null, set: POLICY_SET }
99 const budget = findSetting(BUDGET_KEY)
100 if (budget !== undefined) {
101 const raw = await i.get(settingStoreKey(BUDGET_KEY))
102 const cur = raw === 'off' || raw === 'warn' || raw === 'stop' ? raw : budget.default
103 if (cur !== BUDGET_SET && (await writeSetting(i, BUDGET_KEY, BUDGET_SET))) {
104 saved.budget = { old: typeof raw === 'string' ? raw : null, set: BUDGET_SET, limit: optionNumber(await i.get(settingStoreKey(SESSION_BUDGET_KEY)), SESSION_BUDGETS, Number(findSetting(SESSION_BUDGET_KEY)?.default ?? 25)) }
105 }
106 }
107 await i.set(AWAY_SAVED_KEY, saved)
108 i.invalidate()
109 return { run, saved }
110}
111
112async function putBack(i: Io, key: string, entry: { old: string | null; set: string }): Promise<'back' | 'kept'> {
113 if ((await i.get(settingStoreKey(key))) !== entry.set) return 'kept'
114 if (entry.old === null) await i.del(settingStoreKey(key))
115 else await writeSetting(i, key, entry.old)
116 return 'back'
117}
118
119async function finishRun(i: Io, how: 'auto' | 'typed'): Promise<{ lines: string[]; digest?: Digest } | undefined> {
120 const run = parseRun(await i.get(AWAY_RUN_KEY))
121 if (run === undefined) return undefined
122 await i.del(AWAY_RUN_KEY)
123 const saved = parseSaved(await i.get(AWAY_SAVED_KEY))
124 await i.del(AWAY_SAVED_KEY)
125 const restore: Restore = {}
126 if (saved.policy !== undefined) restore.policy = await putBack(i, AWAY_POLICY_KEY, saved.policy)
127 if (saved.budget !== undefined) restore.budget = await putBack(i, BUDGET_KEY, saved.budget)
128 const now = await i.now()
129 let digest: Digest | undefined
130 if ((await readSetting(i, 'away.digest')) !== 'off') {
131 digest = await digestNow(i, run, true, now)
132 await i.set(DIGEST_KEY, { at: now, from: run.at, to: now, text: digest.text })
133 await ledger(i, await i.sessionId(), DIGEST_WHAT, digest.headline.slice(0, 220), how)
134 }
135 await i.del(AWAY_EVENTS_KEY)
136 for (const rec of await listRecords(i)) await clearBand(i, 'away', rec.sessionId)
137 i.invalidate()
138 return { lines: restoreLines(saved, restore), ...(digest === undefined ? {} : { digest }) }
139}
140
141async function endByTimer(i: Io): Promise<void> {
142 if (parseAway(await i.get(AWAY_KEY)) !== undefined) {
143 await i.del(AWAY_KEY)
144 await ledger(i, await i.sessionId(), AWAY_OFF_WHAT, timerEndDetail, 'auto')
145 }
146 if ((await finishRun(i, 'auto')) !== undefined) i.toast(tr(timerToast))
147}
148
149function arm($: EngineInterface, ms: number): void {
150 endTimer?.cancel()
151 const timer = $.clock.after(Math.max(0, ms), () => void awayCheck(io($)).catch(() => undefined))
152 endTimer = timer
153}
154
155// Exported so that a test can run one check by hand (a test clock does not drive a timer made at the start of the session).
156export async function awayCheck(i: Io): Promise<void> {
157 try {
158 const now = await i.now()
159 const away = parseAway(await i.get(AWAY_KEY))
160 const run = parseRun(await i.get(AWAY_RUN_KEY))
161 if (away !== undefined && run === undefined) {
162 await startRun(i, away.at, undefined)
163 } else if (away === undefined && run !== undefined) {
164 if ((await finishRun(i, 'auto')) !== undefined) i.toast(tr(seenToast))
165 } else if (away !== undefined && run !== undefined) {
166 if (run.until !== undefined && now >= run.until) await endByTimer(i)
167 else await collectEvents(i, run)
168 }
169 } catch {
170 }
171}
172
173export function registerAway(on: On): void {
174 on('session.start', { cwd: /(?<away>)/ }, async ($, e, next) => {
175 try {
176 const timer = $.clock.every(CHECK_MS, () => void awayCheck(io($)))
177 armOnce('away', timer)
178 const run = parseRun(await io($).get(AWAY_RUN_KEY))
179 const nowMs = await $.clock.now()
180 if (run?.until !== undefined) arm($, run.until - nowMs)
181 } catch {
182 // the digest and the timer are optional: a failure shows less, never breaks the session
183 }
184 return next(e)
185 }).catch(($, e, next) => next(e))
186
187 // It never answers alone: the switch's own answer is the base of what it returns.
188 on('command.run', { command: 'vaken-away' }, async ($, e, next) => {
189 if (isFolded(e.command, e.args)) return next(e)
190 const call = awayCall(e.args)
191 if (call.durationError !== undefined) return { text: call.durationError }
192 const i = io($)
193
194 if (call.ask.kind === 'on') {
195 const r = await next({ ...e, args: call.passArgs })
196 try {
197 const away = parseAway(await i.get(AWAY_KEY))
198 if (away !== undefined) {
199 const now = await i.now()
200 const started = await startRun(i, away.at, call.forMs)
201 let run = started?.run ?? parseRun(await i.get(AWAY_RUN_KEY))
202 const saved = started?.saved ?? parseSaved(await i.get(AWAY_SAVED_KEY))
203 const lines: string[] = []
204 if (started === undefined && run !== undefined && call.forMs !== undefined) {
205 run = { ...run, until: now + call.forMs }
206 await i.set(AWAY_RUN_KEY, run)
207 lines.push('The end time is changed.') // en-only: plain answer of a slash command (text twin)
208 }
209 if (run !== undefined) {
210 if (run.until !== undefined) arm($, run.until - now)
211 else endTimer?.cancel()
212 lines.push(...modeLines(run, saved, now))
213 return { ...r, text: [await freshWhileAway(i, r.text ?? ''), '', ...lines].join('\n') }
214 }
215 }
216 } catch {
217 }
218 return r
219 }
220
221 if (call.ask.kind === 'off') {
222 // Away mode tightens the guard while the person is gone, so only the person may end it.
223 if (!byPerson(e.origin)) return { text: BY_PERSON_ONLY }
224 const r = await next(e)
225 try {
226 if (parseAway(await i.get(AWAY_KEY)) === undefined) {
227 endTimer?.cancel()
228 const done = await finishRun(i, 'typed')
229 if (done !== undefined) return { ...r, text: [await freshWhileAway(i, r.text ?? ''), '', ...(done.digest === undefined ? [] : [done.digest.text, '']), ...done.lines].join('\n').trimEnd() }
230 }
231 } catch {
232 }
233 return r
234 }
235
236 const r = await next(e)
237 try {
238 const run = parseRun(await i.get(AWAY_RUN_KEY))
239 if (run !== undefined && parseAway(await i.get(AWAY_KEY)) !== undefined) return { ...r, text: [r.text ?? '', '', ...modeLines(run, parseSaved(await i.get(AWAY_SAVED_KEY)), await i.now())].join('\n') }
240 } catch {
241 }
242 return r
243 }).catch(($, e, next) => next(e))
244
245 on('command.run', { command: 'vaken-away' }, async ($, e, next) => {
246 const rest0 = subArgs(e.args, 'digest')
247 if (rest0 === undefined) return next(e)
248 const i = io($)
249 const now = await i.now()
250 const run = parseRun(await i.get(AWAY_RUN_KEY))
251 if (run !== undefined) return { text: `${(await digestNow(i, run, false, now)).text}\n\nAway mode is still on. /vaken-away off ends it and keeps the digest.` }
252 const saved = parseDigest(await i.get(DIGEST_KEY))
253 return { text: saved === undefined ? NO_DIGEST : digestText(saved, now) }
254 })
255}
256hooks/features/attention.ts 149 lines1// Attention runtime: the heartbeat (a working session that went quiet), the notification router (toasts for other sessions)
2import type { EngineInterface, On } from 'claude-code'
3
4import { parseStoredUsage, APP_USAGE_KEY } from '../core/appusage'
5import { parseStoredSessions, APP_SESSIONS_KEY } from '../core/appsessions'
6import { RECAP_AFTER_MS, batchText, glanceLines, heartbeat, isQuiet, notices, recapWords } from '../core/attention'
7import type { Seen } from '../core/attention'
8import { clearBand, postBand } from '../core/band'
9import { cacheWords } from '../core/cachefacts'
10import { didToday, todaySummary } from '../core/ledger'
11import { withToastGate } from '../core/io'
12import { homeFrom } from '../core/platform'
13import type { Io } from '../core/io'
14import { alivePids, listRecords, readRecord } from '../core/record'
15import { readSetting } from '../core/settings'
16import { WATCH_BAND_ID, WATCH_BAND_MS, stepWatches } from '../core/watch'
17import { buildView, cacheTtlMs, hereWord, limitAgo, limitBlocks, limitLabel, needsCount, resetSaid } from './home/logic'
18import { loadView } from './home/load'
19import { armOnce } from './everyonce'
20import { subArgs } from '../core/menu'
21
22// IO-BLOCK-START
23function io($: EngineInterface): Io {
24 return withToastGate({
25 now: () => $.clock.now(),
26 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
27 sessionId: () => $.session.id(),
28 get: key => $.store.get(key),
29 set: (key, value) => $.store.set(key, value),
30 del: key => $.store.delete(key),
31 keys: () => $.store.keys(),
32 read: path => $.fs.read(path),
33 write: (path, text) => $.fs.write(path, text),
34 run: (argv, init) => $.process.run(argv, init),
35 ask: (question, options) => $.ui.ask(question, options),
36 complete: req => $.model.complete(req),
37 toast: text => $.ui.toast(text),
38 invalidate: () => $.ui.invalidate('ui.render'),
39 })
40}
41// IO-BLOCK-END
42
43const CHECK_MS = 20_000
44const TOLD_KEY = 'cockpit:notified'
45
46let prev: Record<string, Seen> = {}
47
48async function viewNow(i: Io) {
49 const now = await i.now()
50 const records = await listRecords(i)
51 const alive = await alivePids(i, records)
52 return buildView(records, now, await i.sessionId(), alive, parseStoredUsage(await i.get(APP_USAGE_KEY)), undefined, parseStoredSessions(await i.get(APP_SESSIONS_KEY)))
53}
54
55async function check($: EngineInterface): Promise<void> {
56 const i = io($)
57 try {
58 const now = await i.now()
59 const id = await i.sessionId()
60 if ((await readSetting(i, 'heartbeat.mode')) === 'on') {
61 const rec = await readRecord(i, id)
62 if (rec !== undefined) {
63 const silent = now - rec.lastEventAt
64 const beat = rec.ask !== undefined ? 'ok' : heartbeat(rec.state, silent)
65 if (beat === 'ok') await clearBand(i, 'heartbeat')
66 else await postBand(i, beat === 'stuck' ? 'urgent' : 'warn', 'heartbeat', beat === 'stuck' ? 'May be stuck' : 'Gone idle', 2 * 60_000)
67 }
68 }
69 const mode = await readSetting(i, 'notify.mode')
70 const view = await viewNow(i)
71 const rows = [...view.needs, ...view.waiting, ...view.working].map(r => ({ id: r.rec.sessionId, name: r.name, kind: r.kind, reason: r.reason, since: r.rec.ask !== undefined && r.kind === 'needs' ? r.rec.ask.since : r.rec.stateSince }))
72 const raw = await i.get(TOLD_KEY)
73 const told: Record<string, string> = typeof raw === 'object' && raw !== null ? (raw as Record<string, string>) : {}
74 const fired = await stepWatches(i, [...view.needs, ...view.working, ...view.waiting, ...view.idle].map(w => ({ id: w.rec.sessionId, name: w.name, kind: w.kind, ...(w.rec.done === undefined ? {} : { done: w.rec.done }) })), now)
75 for (const f of fired) {
76 i.toast(f.text, true)
77 await postBand(i, 'info', WATCH_BAND_ID, f.text, WATCH_BAND_MS)
78 }
79 const r = notices(prev, rows, told, mode, now)
80 prev = r.next
81 r.notices = r.notices.filter(n => !fired.some(f => f.id === n.id) || !n.marker.startsWith('done:'))
82 if (r.notices.length > 0) {
83 for (const n of r.notices) told[n.id] = n.marker
84 await i.set(TOLD_KEY, told)
85 if (!isQuiet(await readSetting(i, 'notify.quiet'), new Date(now).getHours())) i.toast(batchText(r.notices))
86 }
87 } catch {
88 // the router never breaks the session
89 }
90}
91
92export function registerAttention(on: On): void {
93 on('session.start', { cwd: /(?<attention>)/ }, async ($, e, next) => {
94 try {
95 // Always at full pace
96 const timer = $.clock.every(CHECK_MS, () => void check($).catch(() => undefined))
97 armOnce('attention', timer)
98 } catch {
99 }
100 return next(e)
101 }).catch(($, e, next) => next(e))
102
103 on('prompt.submit', { text: /(?<attention>)/ }, async ($, e, next) => {
104 try {
105 const i = io($)
106 if ((await readSetting(i, 'recap.mode')) === 'on' && !e.text.trimStart().startsWith('/')) {
107 const rec = await readRecord(i, await i.sessionId())
108 const now = await i.now()
109 if (rec !== undefined && rec.state !== 'working' && now - rec.lastEventAt >= RECAP_AFTER_MS) {
110 await postBand(i, 'info', 'recap', recapWords({ gapMs: now - rec.lastEventAt, branch: rec.branch, contextPercent: rec.contextPercent, cache: rec.cache === undefined ? undefined : cacheWords(rec.cache, now, cacheTtlMs(parseStoredUsage(await i.get(APP_USAGE_KEY)))) }), 3 * 60_000)
111 }
112 }
113 } catch {
114 }
115 return next(e)
116 }).catch(($, e, next) => next(e))
117
118 on('command.run', { command: 'vaken-today' }, async ($, e, next) => {
119 const rest0 = subArgs(e.args, 'recap')
120 if (rest0 === undefined) return next(e)
121 const i = io($)
122 const rec = await readRecord(i, await i.sessionId())
123 const now = await i.now()
124 if (rec === undefined) return { text: 'Nothing to recap yet.' }
125 return { text: recapWords({ gapMs: now - rec.lastEventAt, branch: rec.branch, contextPercent: rec.contextPercent, cache: rec.cache === undefined ? undefined : cacheWords(rec.cache, now, cacheTtlMs(parseStoredUsage(await i.get(APP_USAGE_KEY)))) }) }
126 })
127
128 on('command.run', { command: 'vaken-today' }, async ($, e, next) => {
129 const rest0 = subArgs(e.args, 'glance')
130 if (rest0 === undefined) return next(e)
131 // The same load as the pane and /vaken (loadView): the limit that leads with its reset and age
132 const { view, now } = await loadView(io($))
133 const ledger = view.ledger ?? []
134 const lead = limitBlocks(view)[0]
135 return {
136 text: glanceLines({
137 needs: view.needs.map(r => ({ name: r.name, reason: r.reason })),
138 needsTotal: needsCount(view),
139 limitPercent: lead?.percent,
140 limitLabel: lead === undefined ? undefined : limitLabel(lead.label),
141 limitReset: lead === undefined || lead.reset === '' ? undefined : resetSaid(lead.reset),
142 limitAgo: lead === undefined ? undefined : limitAgo([lead]),
143 hereState: hereWord(view.here, 'explained', view.hereWaits),
144 didCount: todaySummary(ledger, now, view.counts).count + didToday(ledger, now, view.counts).reduce((n, d) => n + d.n, 0),
145 }).join('\n'),
146 }
147 })
148}
149hooks/features/cachekeeper.ts 168 lines1// The cache keeper (opt-in, Settings > Money and plan): while a session is quiet
2import { tr } from '../core/lang'
3import type { EngineInterface, On } from 'claude-code'
4
5import { parseStoredUsage, APP_USAGE_KEY } from '../core/appusage'
6import { cacheFromUsage, cacheWords, warmLeftMs } from '../core/cachefacts'
7import { CACHE_TTL_SUBSCRIPTION_MS } from '../core/compactmoment'
8import { KEEPER_MAX_PINGS, KEEPER_PROMPT, KEPT_WHAT, TEST_LAST_MS, TEST_TTL_MS, keeperVerdict, keptDetail, nextPingInMs, pingHeld } from '../core/cachekeeper'
9import { withToastGate } from '../core/io'
10import { homeFrom } from '../core/platform'
11import type { Io } from '../core/io'
12import { putRecord, readRecord } from '../core/record'
13import { readSetting } from '../core/settings'
14import { LEDGER_KEY, logLines, pushLog, readLog, why } from './compaction'
15import { cacheTtlMs } from './home/logic'
16import { armOnce } from './everyonce'
17import { subArgs } from '../core/menu'
18import { fillIn } from '../core/fillin'
19
20// IO-BLOCK-START
21function io($: EngineInterface): Io {
22 return withToastGate({
23 now: () => $.clock.now(),
24 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
25 sessionId: () => $.session.id(),
26 get: key => $.store.get(key),
27 set: (key, value) => $.store.set(key, value),
28 del: key => $.store.delete(key),
29 keys: () => $.store.keys(),
30 read: path => $.fs.read(path),
31 write: (path, text) => $.fs.write(path, text),
32 run: (argv, init) => $.process.run(argv, init),
33 ask: (question, options) => $.ui.ask(question, options),
34 complete: req => $.model.complete(req),
35 toast: text => $.ui.toast(text),
36 invalidate: () => $.ui.invalidate('ui.render'),
37 })
38}
39// IO-BLOCK-END
40
41// The ping goes in the last minutes of the cache's life (leadMs), so this must be well under that.
42const CHECK_MS = 30_000
43// A test ping from the command waits a moment, because a call cannot run inside the command that waits for it.
44const TEST_DELAY_MS = 1_500
45
46let pings = 0
47let stretchOf = 0
48let busyPinging = false
49let testUntil = 0
50
51// How many tokens the window holds: the app's reading
52const windowTokens = (rec: { ctx?: { tokens?: number }; cache?: { read: number; write: number; fresh: number } }): number | undefined => rec.ctx?.tokens ?? (rec.cache === undefined ? undefined : rec.cache.read + rec.cache.write + rec.cache.fresh)
53
54type Pinged = { ok: true; held: boolean; read: number } | { ok: false; why: string }
55
56// Never throws.
57async function pingOnce($: EngineInterface, sessionId: string, n: number): Promise<Pinged> {
58 const i = io($)
59 try {
60 const r = await $.model.fork({ prompt: KEEPER_PROMPT })
61 const now = await i.now()
62 if (!r.isAnswered) {
63 // The host cannot fork a chat that has no reply yet: say it in plain words.
64 const reason = r.reason === 'nothing-to-fork' ? 'This session has no reply yet. Send one message, wait for the answer, then try again.' : 'error' in r && r.error !== undefined ? `${r.reason}: ${String(r.error)}` : r.reason
65 await pushLog(i, LEDGER_KEY, { at: now, session: sessionId, what: 'Keeper could not read the cache', detail: reason, how: 'auto' })
66 return { ok: false, why: reason }
67 }
68 const held = pingHeld(r.usage)
69 const cache = cacheFromUsage(r.usage, now)
70 if (cache !== undefined) {
71 stretchOf = now
72 await putRecord(i, { sessionId, cache: { ...cache, at: now } }, false)
73 }
74 if (held) await pushLog(i, LEDGER_KEY, { at: now, session: sessionId, what: KEPT_WHAT, detail: keptDetail(r.usage.cache_read_input_tokens, n), how: 'auto', did: true })
75 else await pushLog(i, LEDGER_KEY, { at: now, session: sessionId, what: 'Keeper stopped', detail: 'The cache had gone stale, so the read paid for a rewrite.', how: 'auto' })
76 return { ok: true, held, read: r.usage.cache_read_input_tokens }
77 } catch (err) {
78 return { ok: false, why: why(err) }
79 }
80}
81
82// Never throws.
83async function check($: EngineInterface): Promise<void> {
84 if (busyPinging) return
85 const i = io($)
86 try {
87 if ((await readSetting(i, 'cache.keeper')) !== 'on') return
88 const id = await i.sessionId()
89 const rec = await readRecord(i, id)
90 if (rec?.cache === undefined) return
91 if (rec.cache.at !== stretchOf) {
92 pings = 0
93 stretchOf = rec.cache.at
94 }
95 const now = await i.now()
96 const testing = now < testUntil
97 const ttl = testing ? TEST_TTL_MS : (cacheTtlMs(parseStoredUsage(await i.get(APP_USAGE_KEY))) ?? CACHE_TTL_SUBSCRIPTION_MS)
98 if (nextPingInMs(rec.cache.at, ttl, now) > 0) return
99 const v = keeperVerdict({ mode: 'on', tokens: windowTokens(rec), minTokens: testing ? 0 : undefined, pings, busy: rec.state === 'working' || rec.ask !== undefined, warmLeftMs: warmLeftMs(rec.cache, now, ttl) })
100 if (!v.ping) return
101 busyPinging = true
102 pings += 1
103 const r = await pingOnce($, id, pings)
104 if (!r.ok || !r.held) pings = KEEPER_MAX_PINGS
105 i.invalidate()
106 } catch {
107 // the keeper never breaks the session
108 } finally {
109 busyPinging = false
110 }
111}
112
113export function registerCacheKeeper(on: On): void {
114 on('session.start', { cwd: /(?<cockpit>)/ }, async ($, e, next) => {
115 try {
116 const timer = $.clock.every(CHECK_MS, () => void check($))
117 armOnce('cachekeeper', timer)
118 } catch {
119 // the keeper is optional: a failure shows less, never breaks the session
120 }
121 return next(e)
122 }).catch(($, e, next) => next(e))
123
124 on('command.run', { command: 'vaken-help' }, async ($, e, next) => {
125 const rest0 = subArgs(e.args, 'run', 'keeper')
126 if (rest0 === undefined) return next(e)
127 const i = io($)
128 const id = await i.sessionId()
129 if (rest0.trim().toLowerCase() === 'test') {
130 testUntil = (await i.now()) + TEST_LAST_MS
131 pings = 0
132 return { text: `Short test for 10 minutes: the keeper treats the cache as stale after ${Math.round(TEST_TTL_MS / 1000)} seconds and ignores the size limit. It needs Keep session cheap set to On, in Settings > Money and plan. Within about a minute it should read the cache, up to ${KEEPER_MAX_PINGS} times. Run /vaken-help run keeper to see.` }
133 }
134 if (rest0.trim().toLowerCase() === 'now') {
135 $.clock.after(TEST_DELAY_MS, () => {
136 void (async () => {
137 const r = await pingOnce($, id, pings + 1)
138 i.toast(r.ok ? fillIn(tr(r.held ? 'Kept the cache warm: {k}k tokens read from it' : 'Kept the cache warm: {k}k tokens read from it (it had gone stale)'), { k: Math.round(r.read / 1000) }) : fillIn(tr('The cache read failed. {why}'), { why: r.why }))
139 i.invalidate()
140 })()
141 })
142 return { text: 'Reading this session\'s cache once, in a moment. Run /vaken-help run keeper to see the result.' } // en-only: plain answer of a slash command (text twin)
143 }
144 const isOn = (await readSetting(i, 'cache.keeper')) === 'on'
145 const rec = await readRecord(i, id)
146 const now = await i.now()
147 const testing = now < testUntil
148 const ttl = testing ? TEST_TTL_MS : (cacheTtlMs(parseStoredUsage(await i.get(APP_USAGE_KEY))) ?? CACHE_TTL_SUBSCRIPTION_MS)
149 const v = keeperVerdict({ mode: isOn ? 'on' : 'off', tokens: rec === undefined ? undefined : windowTokens(rec), minTokens: testing ? 0 : undefined, pings, busy: rec?.state === 'working', warmLeftMs: rec?.cache === undefined ? 0 : warmLeftMs(rec.cache, now, ttl) })
150 const nextMin = rec?.cache === undefined ? undefined : Math.round(nextPingInMs(rec.cache.at, ttl, now) / 60_000)
151 const last = logLines((await readLog(i, LEDGER_KEY)).filter(x => x.what.startsWith('Kept') || x.what.startsWith('Keeper')), 3) // en-only: matches the title of a stored ledger line
152 return {
153 text: [
154 `Keeper: ${isOn ? 'on' : 'off'}.`,
155 'Claude keeps a saved copy of this session (the cache), so each answer costs less. It goes stale after about an hour of quiet (5 minutes when you pay with usage credits). The keeper reads it just before, so a break does not make your next answer cost more.', // en-only: plain answer of a slash command (text twin)
156 ...(isOn ? [] : ['Turn it on: Settings > Money and plan.']), // en-only: plain answer of a slash command (text twin)
157 '',
158 ...(rec?.cache === undefined ? ['No turn yet, so no cache to keep.'] : [`${cacheWords(rec.cache, now, ttl)}${testing ? ' (short test)' : ''}`]), // en-only: plain answer of a slash command (text twin)
159 `Reads since your last answer: ${pings} of ${KEEPER_MAX_PINGS}.`,
160 v.ping ? (nextMin === undefined || nextMin <= 0 ? 'Next read: due now.' : `Next read: in about ${nextMin} min.`) : `No read now: ${v.why}`, // en-only: plain answer of a slash command (text twin)
161 ...(last.length === 0 ? [] : ['', 'Last:', ...last]),
162 '',
163 'Try: /vaken-help run keeper now (one read now) or /vaken-help run keeper test (a one-minute test).', // en-only: plain answer of a slash command (text twin)
164 ].join('\n'),
165 }
166 })
167}
168hooks/features/calm.ts 224 lines1// Calm: Focus 50 minutes and the open ends of yesterday.
2import type { EngineInterface, On } from 'claude-code'
3
4import { DIGEST_KEY, parseDigest } from '../core/away'
5import { postBand, clearBand } from '../core/band'
6import { CALM_KEY, parseCalm } from '../core/calm'
7import type { Calm } from '../core/calm'
8import { readNote } from '../core/flush'
9import { FOCUS_KEY, FOCUS_MS, FOCUS_POLL_MS, focusMem, focusSummary, isFocused, minutesLeft, parseFocus } from '../core/focus'
10import type { Focus } from '../core/focus'
11import { allowKey } from '../core/guard'
12import { heldKey, parseAllowOnce, parseHeld } from '../core/allowonce'
13import { withToastGate } from '../core/io'
14import { homeFrom } from '../core/platform'
15import type { Io } from '../core/io'
16import { didKind } from '../core/ledger'
17import { OPEN_ENDS_BAND_MS, OPEN_ENDS_PREFIX, OPEN_ENDS_SHOW, noteMatters, openBand, openDayOf, openDayStart, openEnds, openEndsKey, openShown, parseOpenNote } from '../core/openends'
18import type { OpenEnd, OpenNote } from '../core/openends'
19import { ORCH_KEY, parseRun } from '../core/orchestra'
20import { projectName } from '../core/projects'
21import { relevantKinds, readState, stateKey, statusOf } from '../core/proof'
22import { quietly } from '../core/quiet'
23import { alivePids, listRecords } from '../core/record'
24import { serialUpdate } from '../core/serial'
25import { weeklyShown } from '../core/weekly'
26import type { WeeklyNote } from '../core/weekly'
27import { LEDGER_KEY, readLog } from './compaction'
28import { classify } from './home/logic'
29import { armOnce } from './everyonce'
30import { subArgs } from '../core/menu'
31
32// IO-BLOCK-START
33function io($: EngineInterface): Io {
34 return withToastGate({
35 now: () => $.clock.now(),
36 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
37 sessionId: () => $.session.id(),
38 get: key => $.store.get(key),
39 set: (key, value) => $.store.set(key, value),
40 del: key => $.store.delete(key),
41 keys: () => $.store.keys(),
42 read: path => $.fs.read(path),
43 write: (path, text) => $.fs.write(path, text),
44 run: (argv, init) => $.process.run(argv, init),
45 ask: (question, options) => $.ui.ask(question, options),
46 complete: req => $.model.complete(req),
47 toast: text => $.ui.toast(text),
48 invalidate: () => $.ui.invalidate('ui.render'),
49 })
50}
51// IO-BLOCK-END
52
53const calmMem: { checkedDay?: string; root?: string; inTurn: boolean } = { inTurn: false }
54
55export async function readFocus(i: Io): Promise<Focus | undefined> {
56 const f = parseFocus(await i.get(FOCUS_KEY))
57 focusMem.focus = f
58 return f
59}
60
61export async function startFocus(i: Io): Promise<Focus> {
62 const now = await i.now()
63 const f: Focus = { from: now, until: now + FOCUS_MS }
64 await i.set(FOCUS_KEY, f)
65 focusMem.focus = f
66 i.invalidate()
67 return f
68}
69
70async function summaryOf(i: Io, f: Focus, now: number): Promise<string> {
71 const records = await listRecords(i)
72 const alive = await alivePids(i, records)
73 const finished = records.filter(r => r.stateSince >= f.from && classify(r, now, alive) === 'waiting').length
74 const warnings = (await readLog(i, LEDGER_KEY)).filter(x => x.did === true && x.at >= f.from && x.at <= now && didKind(x.what) !== 'did').length
75 return focusSummary({ finished, warnings })
76}
77
78export async function endFocus(i: Io, isPress = false): Promise<string | undefined> {
79 const f = parseFocus(await i.get(FOCUS_KEY))
80 focusMem.focus = undefined
81 if (f === undefined) return undefined
82 await i.del(FOCUS_KEY)
83 const now = await i.now()
84 const text = await summaryOf(i, f, Math.min(now, Math.max(f.from, f.until)))
85 if (isPress || now >= f.until) i.toast(text, true)
86 i.invalidate()
87 return text
88}
89
90export async function focusPoll(i: Io): Promise<void> {
91 try {
92 const before = focusMem.focus
93 const f = await readFocus(i)
94 if (f !== undefined && (await i.now()) >= f.until) await endFocus(i)
95 else if ((before === undefined) !== (f === undefined)) i.invalidate()
96 } catch {
97 }
98}
99
100export async function focusCommand(i: Io, args: string): Promise<string> {
101 const ask = args.trim().toLowerCase()
102 if (ask === 'off' || ask === 'end' || ask === 'stop') return (await endFocus(i)) ?? 'Focus is not on.' // en-only: plain answer of a slash command (text twin)
103 const now = await i.now()
104 const f = await readFocus(i)
105 if (isFocused(f, now)) return `Focus: ${minutesLeft(f, now)} min left. Only what needs you gets through. /vaken-help run focus off ends it.`
106 await startFocus(i)
107 return 'Focus is on for 50 minutes in every session: toasts and the band\'s other lines wait, only what needs you gets through. /vaken-help run focus off ends it.' // en-only: plain answer of a slash command (text twin)
108}
109
110export async function noteOpenEnds(i: Io, sid: string, root: string | undefined, midTurn: boolean): Promise<OpenNote | undefined> {
111 if (root === undefined || root === '') return undefined
112 const proof = readState(await readNote(i, stateKey(sid)))
113 const checks: OpenNote['checks'] = {}
114 for (const k of relevantKinds(proof)) {
115 const st = statusOf(proof, k)
116 if (st === 'ok' || st === 'failed') checks[k] = st
117 }
118 const held = parseHeld(await i.get(heldKey(sid)))
119 const allow = parseAllowOnce(await i.get(allowKey(sid)))
120 const run = parseRun(await i.get(ORCH_KEY(sid)))
121 const note: OpenNote = {
122 at: await i.now(),
123 project: projectName(root),
124 checks,
125 held: held !== undefined && held.keys.length > 0 && !(allow !== undefined && allow.at >= held.at) ? 1 : 0,
126 tasks: run === undefined ? 0 : run.tasks.filter(t => t.state !== 'done').length,
127 midTurn,
128 }
129 if (!noteMatters(note)) return undefined
130 await i.set(openEndsKey(sid), note)
131 return note
132}
133
134// Never throws.
135export async function morningCheck(i: Io): Promise<void> {
136 try {
137 const now = await i.now()
138 const day = openDayOf(now)
139 if (calmMem.checkedDay === day) return
140 calmMem.checkedDay = day
141 if (parseCalm(await i.get(CALM_KEY)).open?.day === day) return
142 const notes: OpenNote[] = []
143 for (const k of (await i.keys()).filter(x => x.startsWith(OPEN_ENDS_PREFIX))) {
144 const n = parseOpenNote(await i.get(k))
145 if (n !== undefined) notes.push(n)
146 }
147 const ends = openEnds(notes, openDayStart(now))
148 await serialUpdate(i, CALM_KEY, old => ({ ...parseCalm(old), open: { day, ends, dismissed: false } }))
149 for (const [n, end] of ends.slice(0, OPEN_ENDS_SHOW).entries()) await postBand(i, 'info', `openends:${n}`, openBand(end), OPEN_ENDS_BAND_MS)
150 } catch {
151 }
152}
153
154export async function dismissOpenEnds(i: Io): Promise<void> {
155 await serialUpdate(i, CALM_KEY, old => {
156 const c = parseCalm(old)
157 return c.open === undefined ? c : { ...c, open: { ...c.open, dismissed: true } }
158 })
159 for (let n = 0; n < OPEN_ENDS_SHOW; n += 1) await clearBand(i, `openends:${n}`)
160 i.invalidate()
161}
162
163export type CalmView = { weekly?: WeeklyNote; open: OpenEnd[]; more: number; focus?: Focus; away?: { to: number; text: string } }
164export async function loadCalm(i: Io, now: number): Promise<CalmView> {
165 await morningCheck(i)
166 const c: Calm = parseCalm(await i.get(CALM_KEY))
167 const weekly = weeklyShown(c.weekly, now)
168 const open = openShown(c.open, now)
169 const focus = focusMem.focus
170 const digest = parseDigest(await i.get(DIGEST_KEY))
171 return { ...(weekly === undefined ? {} : { weekly }), open: open.slice(0, OPEN_ENDS_SHOW), more: Math.max(0, open.length - OPEN_ENDS_SHOW), ...(isFocused(focus, now) ? { focus } : {}), ...(digest === undefined ? {} : { away: { to: digest.to, text: digest.text } }) }
172}
173export const calmSignature = (c: CalmView | undefined, now: number): string =>
174 c === undefined ? '' : `${c.weekly?.at ?? 0}|${c.open.map(x => `${x.project}${x.failed.join('/')}${x.held}${x.tasks}${x.midTurn}`).join(',')}${c.more}|${c.focus === undefined ? '' : minutesLeft(c.focus, now)}|${c.away?.to ?? 0}`
175
176export function registerCalm(on: On): void {
177 on('session.start', { cwd: /(?<calm>)/ }, async ($, e, next) => {
178 try {
179 calmMem.checkedDay = undefined
180 calmMem.inTurn = false
181 try {
182 const root = await $.session.root()
183 calmMem.root = root || e.cwd
184 } catch {
185 calmMem.root = e.cwd
186 }
187 await readFocus(io($))
188 const timer = $.clock.every(FOCUS_POLL_MS, quietly('calm:focus', () => $.clock.now(), () => focusPoll(io($))))
189 armOnce('calm', timer)
190 } catch {
191 // Focus and the open ends are optional: a failure shows less, never breaks the session
192 }
193 return next(e)
194 }).catch(($, e, next) => next(e))
195
196 on('prompt.submit', { text: /(?<calm>)/ }, async ($, e, next) => {
197 await morningCheck(io($))
198 return next(e)
199 }).catch(($, e, next) => next(e))
200
201 on('turn.start', { turnId: /(?<calm>)/ }, async ($, e, next) => {
202 calmMem.inTurn = true
203 return next(e)
204 }).catch(($, e, next) => next(e))
205 on('turn.complete', { turnId: /(?<calm>)/ }, async ($, e, next) => {
206 if (e.agentId === undefined) calmMem.inTurn = false
207 return next(e)
208 }).catch(($, e, next) => next(e))
209
210 on('session.end', { sessionId: /(?<calm>)/ }, async ($, e, next) => {
211 try {
212 await noteOpenEnds(io($), e.sessionId, calmMem.root, calmMem.inTurn)
213 } catch {
214 }
215 return next(e)
216 }).catch(($, e, next) => next(e))
217
218 on('command.run', { command: 'vaken-help' }, async ($, e, next) => {
219 const rest0 = subArgs(e.args, 'run', 'focus')
220 if (rest0 === undefined) return next(e)
221 return { text: await focusCommand(io($), rest0) }
222 })
223}
224hooks/features/compaction.ts 519 lines1// Compaction: what the host lets a mod do (a probe), the keep-list added to every compaction, the record of each compaction
2import type { EngineInterface, On } from 'claude-code'
3import { fieldsOf } from '../core/eventfields'
4
5import { parseStoredUsage, APP_USAGE_KEY } from '../core/appusage'
6import { lastAfterTokens, lastSummaryOut, withCompact } from '../core/cachefacts'
7import { postBand } from '../core/band'
8import { AUDIT_KEY, parseAudit, toAudit, withAudit } from '../core/insight'
9import { compare, diffKey, filesFromMessages } from '../core/compactdiff'
10import { COUNTS_KEY, FREED_TITLE, SHORTENED_TITLE, addCount, countOf } from '../core/counts'
11import { serialUpdate } from '../core/serial'
12import { CACHE_WARM_MS, DEFAULT_AFTER_TOKENS, DEFAULT_SUMMARY_OUT, cardText, compactInstructions, keepWords, openLines, paybackTurns, withKeepList } from '../core/compactmoment'
13import { workSaved } from '../core/compactscore'
14import { fromOf, nudge } from '../core/compactnudge'
15import { AUTO_NOTE_WHAT, noteText, noteWords } from '../core/autosafe'
16import { NEW_TURN, NO_BRIEF, becauseWords, skippedWords, stopWords } from '../core/compactwords'
17import { t, tr } from '../core/lang'
18import { namedKey, parseNamed } from '../core/undo'
19import type { Probe } from '../core/undo'
20import { allowKey } from '../core/guard'
21import { heldKey, heldOpen, parseAllowOnce, parseHeld } from '../core/allowonce'
22import { failedKey, failedLine, parseFailed } from '../core/errors'
23import { BACKGROUND_OPEN_MS, CACHE_MARGIN_MS, REST_KEY, REST_QUIET_MS, WOULD_WHAT, endsWithQuestion, notGoodLines, restChecks, restDetail, restReady, restScore, restScoreFacts } from '../core/compactrest'
24import type { RestFacts } from '../core/compactrest'
25import { BOARD_KEY, boardRows, parseBoard } from '../core/agentboard'
26import { readState, stateKey } from '../core/proof'
27import { shownTitle } from '../core/ledger'
28import type { Entry } from '../core/ledger'
29import { readNote } from '../core/flush'
30import { withToastGate } from '../core/io'
31import { homeFrom } from '../core/platform'
32import type { Io } from '../core/io'
33import { putRecord, readRecord } from '../core/record'
34import { readSetting } from '../core/settings'
35import { tellsCompact } from '../core/tellclaude'
36import { cacheTtlMs } from './home/logic'
37import { hhmmss } from '../core/time'
38
39// IO-BLOCK-START
40function io($: EngineInterface): Io {
41 return withToastGate({
42 now: () => $.clock.now(),
43 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
44 sessionId: () => $.session.id(),
45 get: key => $.store.get(key),
46 set: (key, value) => $.store.set(key, value),
47 del: key => $.store.delete(key),
48 keys: () => $.store.keys(),
49 read: path => $.fs.read(path),
50 write: (path, text) => $.fs.write(path, text),
51 run: (argv, init) => $.process.run(argv, init),
52 ask: (question, options) => $.ui.ask(question, options),
53 complete: req => $.model.complete(req),
54 toast: text => $.ui.toast(text),
55 invalidate: () => $.ui.invalidate('ui.render'),
56 })
57}
58// IO-BLOCK-END
59
60export const NUDGE_KEY = (sessionId: string): string => `cockpit:compact:nudge:${sessionId}`
61export const LEDGER_KEY = 'cockpit:ledger:compact'
62const LOG_CAP = 24
63// The ledger keeps more: the cache keeper adds an entry per ping, and the compacts must not be pushed out.
64const LEDGER_CAP = 60
65const REST_LOOK_MS = REST_QUIET_MS + 5_000
66
67export type { Entry }
68
69const DONE_BAND_MS = 10 * 60_000
70
71// The ledger, the counts and the audit are shared by every session: each write goes through serialUpdate
72const isEntry = (entry: Entry) => (x: unknown): boolean => {
73 const got = x as Entry
74 return typeof got === 'object' && got !== null && got.at === entry.at && got.what === entry.what && got.session === entry.session
75}
76
77export async function pushLog(i: Io, key: string, entry: Entry): Promise<void> {
78 const cap = key === LEDGER_KEY ? LEDGER_CAP : LOG_CAP
79 await serialUpdate(i, key, old => [entry, ...(Array.isArray(old) ? (old as Entry[]) : [])].slice(0, cap), stored => Array.isArray(stored) && stored.some(isEntry(entry)))
80 if (key === LEDGER_KEY) {
81 let written = 0
82 await serialUpdate(
83 i,
84 COUNTS_KEY,
85 old => {
86 let bumped = addCount(old, entry.at, entry.what)
87 written = countOf(bumped, entry.at, entry.what)
88 // A compaction that really made the window smaller also adds to today's two sums (core/counts.ts)
89 if (entry.before !== undefined && entry.after !== undefined && entry.before > entry.after) bumped = addCount(addCount(bumped, entry.at, SHORTENED_TITLE), entry.at, FREED_TITLE, entry.before - entry.after)
90 return bumped
91 },
92 stored => countOf(stored, entry.at, entry.what) >= written,
93 )
94 }
95 if (key === LEDGER_KEY && entry.did === true) {
96 await postBand(i, 'info', `did:${entry.what}`, shownTitle(entry.what), DONE_BAND_MS, entry.session)
97 const row = toAudit(entry)
98 await serialUpdate(i, AUDIT_KEY, old => withAudit(parseAudit(old), row), stored => parseAudit(stored).some(r => r.at === row.at && r.what === row.what && r.session === row.session))
99 }
100}
101
102export async function readLog(i: Io, key: string): Promise<Entry[]> {
103 const v = await i.get(key)
104 return Array.isArray(v) ? (v as Entry[]) : []
105}
106
107export const why = (err: unknown): string => (err instanceof Error ? err.message : String(err)).slice(0, 160)
108
109export function logLines(list: Entry[], max = 12): string[] {
110 return list.slice(0, max).map(x => `${hhmmss(x.at)} ${shownTitle(x.what)}${x.detail === undefined ? '' : `: ${x.detail}`}`)
111}
112
113// Never throws.
114export async function keepList(i: Io, sessionId: string): Promise<string> {
115 const rec = await readRecord(i, sessionId)
116 const base = cardText({ title: rec?.title, branch: rec?.branch })
117 try {
118 const now = await i.now()
119 const held = heldOpen(parseHeld(await i.get(heldKey(sessionId))), parseAllowOnce(await i.get(allowKey(sessionId))), now)
120 const failed = parseFailed(await i.get(failedKey(sessionId)))
121 const open = openLines({ failed: failedLine(failed === undefined ? undefined : { ...failed, what: keepWords(failed.what) }, now), held: held === undefined ? undefined : (held.words ?? 'a step') })
122 return [base, ...open].join('\n')
123 } catch {
124 return base
125 }
126}
127
128let autoGivenUp = false
129
130// The running flag is the one hard block with no way past it: it is claimed with a holder number before anything is awaited
131type How = NonNullable<Entry['how']>
132export const compacting: { pending: boolean; how?: How; owner?: number; seenBy?: number; sent?: { how: How; at: number } } = { pending: false }
133// How long a sent compaction still names its way in the record: a big session can take 16 s to compact
134const SENT_MARK_MS = 5 * 60_000
135let owners = 0
136
137export function claimCompacting(how: How): number | undefined {
138 if (compacting.pending) return undefined
139 owners += 1
140 compacting.pending = true
141 compacting.how = how
142 compacting.owner = owners
143 return owners
144}
145
146export function releaseCompacting(owner: number): void {
147 if (compacting.owner !== owner) return
148 compacting.pending = false
149 compacting.how = undefined
150 compacting.owner = undefined
151}
152
153// The way the compaction now passing the hook was started: the running one's, a just-sent one's (used once)
154function howNow(now: number): How {
155 if (compacting.pending) {
156 compacting.seenBy = compacting.owner
157 return compacting.how ?? 'button'
158 }
159 const s = compacting.sent
160 compacting.sent = undefined
161 return s !== undefined && now >= s.at && now - s.at < SENT_MARK_MS ? s.how : 'typed'
162}
163
164const rest: { turnId?: string; endedAt: number; question: boolean; background: number[]; lastAutoAt?: number } = { endedAt: 0, question: false, background: [] }
165
166// What a compaction needs from the engine
167export type CompactOps = {
168 io: Io
169 cwd: () => Promise<string>
170 probe: Probe
171 agents: () => Promise<number> // helper agents the engine lists as running
172 command: (args: string) => Promise<unknown>
173 direct?: (instructions: string) => Promise<{ skip?: string; tokensBefore?: number; tokensAfter?: number; outTokens?: number }>
174}
175
176// What the rule needs, from the session's record, the proof state, the agent board, the engine's agent list
177async function restFacts(i: Io, agents: () => Promise<number>, sessionId: string, turnId?: string): Promise<RestFacts | undefined> {
178 const rec = await readRecord(i, sessionId)
179 if (rec === undefined) return undefined
180 const now = await i.now()
181 // Unknown plan: the short cache time, so a lapsed cache is never called warm.
182 const ttl = cacheTtlMs(parseStoredUsage(await i.get(APP_USAGE_KEY))) ?? CACHE_WARM_MS
183 const board = boardRows(parseBoard(await i.get(BOARD_KEY)), now).filter(r => r.session === sessionId.slice(0, 8) && r.state === 'running').length
184 let engine = 0
185 try {
186 engine = await agents()
187 } catch {
188 }
189 rest.background = rest.background.filter(at => now - at < BACKGROUND_OPEN_MS)
190 const tokens = rec.ctx?.tokens
191 const lastCompact = (rec.compacts ?? []).reduce((m, c) => Math.max(m, c.at), 0)
192 const cacheLeftMs = rec.cache?.at === undefined ? undefined : ttl - (now - rec.cache.at)
193 const proof = readState(await readNote(i, stateKey(sessionId)))
194 let checkpointAt: number | undefined
195 try {
196 checkpointAt = parseNamed(await i.get(namedKey(sessionId))).reduce<number | undefined>((m, n) => (m === undefined || n.at > m ? n.at : m), undefined)
197 } catch {
198 checkpointAt = undefined
199 }
200 const guardHeld = heldOpen(parseHeld(await i.get(heldKey(sessionId))), parseAllowOnce(await i.get(allowKey(sessionId))), now) !== undefined
201 return {
202 turnEnded: (turnId === undefined || rest.turnId === turnId) && rec.state !== 'working',
203 endedWithQuestion: rest.question,
204 waitsForYou: rec.ask !== undefined,
205 agentsRunning: Math.max(board, engine),
206 backgroundRecent: rest.background.length,
207 guardHeld,
208 from: fromOf(await readSetting(i, 'compact.from')),
209 saved: workSaved(proof, checkpointAt),
210 proof,
211 tokens,
212 ...(rec.contextPercent === undefined ? {} : { percent: rec.contextPercent }),
213 payback: tokens === undefined ? undefined : paybackTurns({ tokens, after: lastAfterTokens(rec.compacts) ?? DEFAULT_AFTER_TOKENS, out: lastSummaryOut(rec.compacts) ?? DEFAULT_SUMMARY_OUT, warm: (cacheLeftMs ?? 0) > 0, ttlMs: ttl }),
214 quietMs: now - rest.endedAt,
215 cacheLeftMs,
216 sinceCompactMs: lastCompact === 0 ? undefined : now - lastCompact,
217 sinceAutoMs: rest.lastAutoAt === undefined ? undefined : now - rest.lastAutoAt,
218 }
219}
220
221// Never throws.
222export async function notGoodNow(ops: CompactOps, sessionId: string, say: (name: string, vars?: Record<string, string | number>) => string): Promise<string[]> {
223 try {
224 const f = await restFacts(ops.io, ops.agents, sessionId)
225 return f === undefined ? [] : notGoodLines(restChecks(f), f, say)
226 } catch {
227 return []
228 }
229}
230
231// Never throws.
232async function restLook($: EngineInterface, sessionId: string, turnId: string): Promise<void> {
233 const i = io($)
234 try {
235 if (rest.turnId !== turnId) return
236 const mode = await readSetting(i, 'compact.mode')
237 if (mode !== 'watch' && mode !== 'auto') return
238 const f = await restFacts(i, agentsOf($), sessionId, turnId)
239 if (f === undefined) return
240 const checks = restChecks(f)
241 const ready = restReady(checks)
242 const now = await i.now()
243 await i.set(REST_KEY(sessionId), { at: now, ready, checks })
244 if (!ready) {
245 if (f.cacheLeftMs !== undefined && f.cacheLeftMs - REST_QUIET_MS >= CACHE_MARGIN_MS) $.clock.after(REST_QUIET_MS, () => void restLook($, sessionId, turnId))
246 return
247 }
248 if (mode === 'watch') {
249 rest.turnId = undefined
250 await pushLog(i, LEDGER_KEY, { at: now, session: sessionId, what: WOULD_WHAT, did: true, detail: restDetail(f) })
251 return
252 }
253 if (!autoGivenUp) await runAuto($, sessionId, turnId, f)
254 } catch {
255 // an automatic step never breaks the session
256 }
257}
258
259// What a path is, from the engine (a file, a folder, or something else
260function probeOf($: EngineInterface): Probe {
261 return async path => {
262 try {
263 const s = await $.fs.stat(path)
264 return { kind: s.kind, size: s.size }
265 } catch {
266 return undefined
267 }
268 }
269}
270
271// The helper agents the engine lists as running.
272function agentsOf($: EngineInterface): () => Promise<number> {
273 return async () => {
274 const list = await $.agent.list()
275 return list.filter(a => a.status === 'running').length
276 }
277}
278
279function opsOf($: EngineInterface): CompactOps {
280 return {
281 io: io($),
282 cwd: () => $.session.cwd(),
283 probe: probeOf($),
284 agents: agentsOf($),
285 command: args => $.command.run({ command: 'compact', args }),
286 direct: async instructions => {
287 const r = await $.session.compact({ instructions })
288 if (r.skip !== undefined) return { skip: r.skip }
289 return { tokensBefore: r.tokensBefore, tokensAfter: r.tokensAfter, outTokens: r.usage?.output_tokens }
290 },
291 }
292}
293
294type Saved = { ok: true; name: string; shown: string; checkpoint: boolean } | { ok: false }
295type AutoSaver = (i: Io, sessionId: string, cwd: string, probe: Probe) => Promise<Saved>
296let autoSaver: AutoSaver | undefined
297export const setAutoSaver = (fn: AutoSaver | undefined): void => {
298 autoSaver = fn
299}
300
301// It is a note, not an undo (the engine has no restore of a compaction).
302async function postNote(i: Io, sessionId: string, name: string): Promise<void> {
303 try {
304 const text = noteText(name)
305 await pushLog(i, LEDGER_KEY, { at: await i.now(), session: sessionId, what: AUTO_NOTE_WHAT, detail: text, how: 'auto', did: true })
306 i.toast(noteWords(text, tr))
307 } catch {
308 }
309}
310
311export type CompactEnd = 'busy' | 'skipped' | 'no-brief' | 'sent' | 'failed'
312
313// Claims the running flag (nothing awaited before it), looks at `stops` (Auto's checks
314export async function runCompaction(r: { how: 'button' | 'auto'; sessionId: string; ops: CompactOps; stops?: () => Promise<string | undefined>; because?: string }): Promise<CompactEnd> {
315 const owner = claimCompacting(r.how)
316 if (owner === undefined) return 'busy'
317 const i = r.ops.io
318 const auto = r.how === 'auto'
319 const log = async (what: string, detail?: string): Promise<void> => pushLog(i, LEDGER_KEY, { at: await i.now(), session: r.sessionId, what, ...(detail === undefined ? {} : { detail }) })
320 const skip = async (why: string): Promise<CompactEnd> => {
321 await log('auto-compact skipped', skippedWords(why))
322 return 'skipped'
323 }
324 i.invalidate()
325 try {
326 const first = r.stops === undefined ? undefined : await r.stops()
327 if (first !== undefined) return await skip(first)
328 const saved: Saved = autoSaver === undefined ? { ok: false } : await autoSaver(i, r.sessionId, await r.ops.cwd(), r.ops.probe)
329 if (!saved.ok) {
330 if (auto) return await skip(NO_BRIEF)
331 i.toast(t('toast.compact_no_brief'), true)
332 await log('compact now failed', NO_BRIEF)
333 return 'no-brief'
334 }
335 const late = r.stops === undefined ? undefined : await r.stops()
336 if (late !== undefined) return await skip(late)
337 rest.turnId = undefined
338 rest.lastAutoAt = await i.now()
339 const keep = compactInstructions((await readSetting(i, 'compact.card')) === 'on' ? await keepList(i, r.sessionId) : undefined, saved.shown)
340 if (auto) {
341 await log('auto-compact starts', r.because)
342 if (r.because !== undefined) i.toast(r.because)
343 } else {
344 await log('compact now (button)')
345 i.toast(tr('Compacting the session'), true)
346 }
347 let refused: unknown
348 if (r.ops.direct !== undefined) {
349 try {
350 const res = await r.ops.direct(keep)
351 if (res.skip !== undefined) {
352 await log('auto-compact skipped', res.skip)
353 return 'skipped'
354 }
355 await noteCompact(i, r.sessionId, 'plugin', res.tokensBefore, res.tokensAfter, res.outTokens, r.how)
356 if (auto) await postNote(i, r.sessionId, saved.name)
357 return 'sent'
358 } catch (err) {
359 refused = err === undefined ? new Error('refused') : err
360 }
361 }
362 try {
363 await r.ops.command(keep)
364 if (compacting.seenBy !== owner) compacting.sent = { how: r.how, at: await i.now() }
365 if (refused !== undefined) await log('auto-compact sent as /compact', `direct call refused: ${why(refused)}`)
366 if (auto) await postNote(i, r.sessionId, saved.name)
367 return 'sent'
368 } catch (err2) {
369 if (auto) {
370 autoGivenUp = true
371 await log('auto-compact could not run', refused === undefined ? why(err2) : `${why(refused)} | ${why(err2)}`)
372 } else {
373 i.toast(tr('Could not compact the session from here. Type /compact yourself.'), true)
374 await log('compact now failed', why(err2))
375 }
376 return 'failed'
377 }
378 } catch {
379 // a compaction never breaks the session
380 return 'failed'
381 } finally {
382 releaseCompacting(owner)
383 i.invalidate()
384 }
385}
386
387// Never throws.
388export async function runAuto($: EngineInterface, sessionId: string, turnId: string, looked: RestFacts): Promise<void> {
389 const ops = opsOf($)
390 const stops = async (): Promise<string | undefined> => {
391 if (rest.turnId !== turnId) return NEW_TURN
392 const fresh = await restFacts(ops.io, ops.agents, sessionId, turnId)
393 if (fresh === undefined) return NEW_TURN
394 const checks = restChecks(fresh)
395 if (restReady(checks)) return undefined
396 const first = restScore(fresh).hardStops[0]
397 return first !== undefined ? stopWords(first) : (checks.find(c => !c.ok)?.label ?? NEW_TURN)
398 }
399 await runCompaction({ how: 'auto', sessionId, ops, stops, because: becauseWords(restScore(looked).reasons, tr) })
400}
401
402// Keeps what one compaction did to the window: in this session's record (so the next advice uses the real summary size) and in the ledger.
403async function noteCompact(i: Io, sessionId: string, trigger: string, before?: number, after?: number, outTokens?: number, how?: Entry['how'], isLedger = true): Promise<void> {
404 const now = await i.now()
405 const old = await readRecord(i, sessionId)
406 await putRecord(i, { sessionId, compacts: withCompact(old?.compacts, { at: now, trigger, before, after, outTokens }) }, true)
407 if (isLedger) await pushLog(i, LEDGER_KEY, { at: now, session: sessionId, ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }), ...(how === undefined ? {} : { how }), what: `compacted (${trigger})`, detail: `${before === undefined ? '?' : Math.round(before / 1000)}k to ${after === undefined ? '?' : Math.round(after / 1000)}k tokens${outTokens === undefined ? '' : `, summary output ${outTokens}`}` })
408}
409
410export function registerCompaction(on: On): void {
411 on('session.start', { cwd: /(?<cockpit>)/ }, async ($, e, next) => {
412 try {
413 } catch {
414 // the probe is a tool for us: a failure shows less, never breaks the session
415 }
416 return next(e)
417 }).catch(($, e, next) => next(e))
418
419 // Every compaction passes here (the person's /compact, the engine's own auto-compaction, a precompute).
420 on('session.compact', async ($, e, next) => {
421 let sent = e
422 let id = ''
423 try {
424 const i = io($)
425 id = await i.sessionId()
426 if (e.agentId === undefined) {
427 const parts = [(await readSetting(i, 'compact.card')) === 'on' ? await keepList(i, id) : ''].filter(x => x !== '')
428 const instructions = parts.reduce((acc: string | undefined, part) => withKeepList(acc, part), e.instructions)
429 if (instructions !== e.instructions) {
430 sent = { ...e, instructions }
431 }
432 }
433 } catch {
434 sent = e
435 }
436 const result = await next(sent)
437 try {
438 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined && result.messages.length > 0) {
439 const i = io($)
440 const rec = await readRecord(i, id)
441 await i.set(diffKey(id), compare(filesFromMessages(e.messages ?? []), result.messages[0].text, rec?.branch, await i.now()))
442 }
443 } catch {
444 }
445 try {
446 // Our own automatic compact does not pass here (a call skips its caller's hooks), so it notes itself
447 if (e.agentId === undefined && e.trigger !== 'precompute') {
448 const doneAt = await $.clock.now()
449 if (result.skip === undefined) await noteCompact(io($), id, e.trigger, result.tokensBefore, result.tokensAfter, result.usage?.output_tokens, howNow(doneAt))
450 else await pushLog(io($), LEDGER_KEY, { at: doneAt, session: id, what: `compaction skipped (${e.trigger})`, detail: result.skip })
451 }
452 } catch {
453 }
454 return result
455 }).catch(($, e, next) => next(e))
456
457 on('turn.complete', { turnId: /(?<cockpit>)/ }, async ($, e, next) => {
458 if (e.agentId === undefined) {
459 try {
460 const i = io($)
461 const id = await i.sessionId()
462 rest.turnId = e.turnId
463 rest.endedAt = await i.now()
464 rest.question = endsWithQuestion(e.answer)
465 $.clock.after(REST_LOOK_MS, () => void restLook($, id, e.turnId))
466 } catch {
467 }
468 }
469 return next(e)
470 }).catch(($, e, next) => next(e))
471
472 // A new main turn: the person is back, so the rest the rule waited on is over.
473 on('turn.start', { turnId: /(?<cockpit>)/ }, async ($, e, next) => {
474 rest.turnId = undefined
475 return next(e)
476 }).catch(($, e, next) => next(e))
477
478 // The quiet note to Claude at the crossing (core/compactnudge.ts): once per crossing of `compact.from`
479 on('tool.call', { tool: /(?<cockpitnudge>)/ }, async ($, e, next) => {
480 const result = await next(e)
481 try {
482 if (e.agentId !== undefined || result.deny !== undefined) return result
483 const i = io($)
484 const mode = await readSetting(i, 'compact.mode')
485 const id = await i.sessionId()
486 const key = NUDGE_KEY(id)
487 const sent = (await i.get(key)) === true
488 const from = fromOf(await readSetting(i, 'compact.from'))
489 const pct = (await readRecord(i, id))?.contextPercent
490 if (mode === 'off' || pct === undefined || pct < from) {
491 if (sent && mode !== 'off') await i.set(key, false)
492 return result
493 }
494 if (sent) return result
495 if (!(await tellsCompact(i))) return result
496 const facts = await restFacts(i, agentsOf($), id, rest.turnId ?? '')
497 if (facts === undefined) return result
498 const r = nudge({ mode, sent, from, facts: restScoreFacts(facts) })
499 if (r.sent !== sent) await i.set(key, r.sent)
500 return r.note === undefined ? result : { ...result, context: [...(result.context ?? []), r.note] }
501 } catch {
502 return result
503 }
504 }).catch(($, e, next) => next(e))
505
506 on('tool.call', { tool: /(?<cockpit>^(?:Bash|PowerShell)$)/ }, async ($, e, next) => {
507 const r = await next(e)
508 try {
509 const shown = r.result as { backgroundTaskId?: unknown } | undefined
510 if (shown?.backgroundTaskId !== undefined || fieldsOf(e).run_in_background === true) {
511 const nowMs = await $.clock.now()
512 rest.background.push(nowMs)
513 }
514 } catch {
515 }
516 return r
517 }).catch(($, e, next) => next(e))
518}
519hooks/features/cost.ts 330 lines1// Cost, why it cost, the limit consequence and budgets.
2import { tr } from '../core/lang'
3import type { EngineInterface, On } from 'claude-code'
4
5import { APP_SESSIONS_KEY, parseStoredSessions } from '../core/appsessions'
6import { APP_USAGE_KEY, parseStoredUsage } from '../core/appusage'
7import { COST_SETTINGS, DAY_BUDGETS, SESSION_BUDGETS, addSample, addTool, addTurn, costText, estimateTotal, parseState, spendOf, turnOf, whyText } from '../core/cost'
8import type { CostState } from '../core/cost'
9import { EXTENDED_WHAT, HELD_WHAT, WARNED_WHAT, budgetLines, isCostly, ledgerHeld, ledgerWarned, noteText, optionNumber, parseAsk, readings, rises, stopReason, toExtend, toastText, worst } from '../core/budget'
10import type { Facts, Level, Which } from '../core/budget'
11import { limitsText, runningNow } from '../core/costlimits'
12import { withToastGate } from '../core/io'
13import { homeFrom, lastName } from '../core/platform'
14import type { Io } from '../core/io'
15import { HISTORY_KEY, parseHistory } from '../core/pace'
16import { PRICES_DATE, money } from '../core/prices'
17import { alivePids, listRecords } from '../core/record'
18import { readSetting, settingStoreKey } from '../core/settings'
19import { tellsClaude } from '../core/tellclaude'
20import { LEDGER_KEY, pushLog } from './compaction'
21import { ymd } from '../core/time'
22import { subArgs } from '../core/menu'
23
24// IO-BLOCK-START
25function io($: EngineInterface): Io {
26 return withToastGate({
27 now: () => $.clock.now(),
28 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
29 sessionId: () => $.session.id(),
30 get: key => $.store.get(key),
31 set: (key, value) => $.store.set(key, value),
32 del: key => $.store.delete(key),
33 keys: () => $.store.keys(),
34 read: path => $.fs.read(path),
35 write: (path, text) => $.fs.write(path, text),
36 run: (argv, init) => $.process.run(argv, init),
37 ask: (question, options) => $.ui.ask(question, options),
38 complete: req => $.model.complete(req),
39 toast: text => $.ui.toast(text),
40 invalidate: () => $.ui.invalidate('ui.render'),
41 })
42}
43// IO-BLOCK-END
44
45const STATE_PREFIX = 'cockpit:cost:s:'
46const DAY_PREFIX = 'cockpit:cost:day:'
47const DAY_EXTRA_PREFIX = 'cockpit:cost:dayextra:'
48const KEEP_MS = 3 * 24 * 3_600_000
49
50const DEFAULT = (key: string): string => COST_SETTINGS.find(s => s.key === key)?.default ?? ''
51
52type Conf = { mode: string; session: number; day: number; why: boolean }
53async function conf(i: Io): Promise<Conf> {
54 return {
55 mode: await readSetting(i, 'cost.budget'),
56 session: optionNumber(await i.get(settingStoreKey('cost.session')), SESSION_BUDGETS, Number(DEFAULT('cost.session'))),
57 day: optionNumber(await i.get(settingStoreKey('cost.day')), DAY_BUDGETS, Number(DEFAULT('cost.day'))),
58 why: (await readSetting(i, 'cost.why')) === 'on',
59 }
60}
61
62let live: CostState | undefined
63let liveId = ''
64const told: Record<Which, Level> = { session: 'ok', day: 'ok' }
65let notes: string[] = []
66const heldLogged = new Set<string>()
67
68async function ensure(i: Io): Promise<CostState> {
69 const id = await i.sessionId()
70 if (live === undefined || liveId !== id) {
71 live = parseState(await i.get(STATE_PREFIX + id), await i.now())
72 liveId = id
73 told.session = 'ok'
74 told.day = 'ok'
75 notes = []
76 heldLogged.clear()
77 }
78 return live
79}
80
81const save = async (i: Io, s: CostState): Promise<void> => i.set(STATE_PREFIX + liveId, s)
82
83type DayRec = { base: number; usd: number }
84const isDayRec = (v: unknown): v is DayRec => typeof v === 'object' && v !== null && typeof (v as DayRec).base === 'number' && typeof (v as DayRec).usd === 'number'
85
86async function noteDay(i: Io, s: CostState, startedBeforeToday: () => Promise<boolean>): Promise<void> {
87 const now = await i.now()
88 const key = `${DAY_PREFIX}${ymd(now)}:${liveId}`
89 const old = await i.get(key)
90 const spend = spendOf(s).usd
91 const base = isDayRec(old) ? old.base : (await startedBeforeToday()) ? spend : 0
92 await i.set(key, { base, usd: Math.max(0, spend - base) } satisfies DayRec)
93}
94
95const OTHERS_EVERY_MS = 60_000
96let othersToday: { prefix: string; at: number; usd: number; sessions: number } | undefined
97async function daySum(i: Io): Promise<{ usd: number; sessions: number }> {
98 const now = await i.now()
99 const prefix = `${DAY_PREFIX}${ymd(now)}:`
100 const own = prefix + liveId
101 if (othersToday === undefined || othersToday.prefix !== prefix || now - othersToday.at < 0 || now - othersToday.at >= OTHERS_EVERY_MS) {
102 let usd = 0
103 let sessions = 0
104 for (const k of (await i.keys()).filter(x => x.startsWith(prefix) && x !== own)) {
105 const v = await i.get(k)
106 if (isDayRec(v)) {
107 usd += v.usd
108 sessions += 1
109 }
110 }
111 othersToday = { prefix, at: now, usd, sessions }
112 }
113 const mine = await i.get(own)
114 return isDayRec(mine) ? { usd: othersToday.usd + mine.usd, sessions: othersToday.sessions + 1 } : { usd: othersToday.usd, sessions: othersToday.sessions }
115}
116
117const dayExtra = async (i: Io): Promise<number> => {
118 const v = await i.get(DAY_EXTRA_PREFIX + ymd(await i.now()))
119 return typeof v === 'number' && v > 0 ? v : 0
120}
121
122async function factsNow(i: Io, s: CostState, c: Conf): Promise<{ facts: Facts; day: { usd: number; sessions: number }; extra: { session: number; day: number } }> {
123 const spend = spendOf(s)
124 const day = await daySum(i)
125 const extra = { session: s.extra, day: await dayExtra(i) }
126 return { facts: { mode: c.mode, source: spend.source, session: { usd: spend.usd, limit: c.session + extra.session }, day: { usd: day.usd, limit: c.day + extra.day } }, day, extra }
127}
128
129// Never throws.
130async function evaluate(i: Io): Promise<void> {
131 try {
132 const c = await conf(i)
133 if (c.mode === 'off') return
134 const s = await ensure(i)
135 const { facts } = await factsNow(i, s, c)
136 for (const r of rises(told, readings(facts))) {
137 // Warn tells the person only, unless the person chose All checks (core/tellclaude.ts).
138 if (c.mode === 'stop' || (await tellsClaude(i))) notes.push(noteText(r, c.mode))
139 i.toast(toastText(r, tr))
140 await pushLog(i, LEDGER_KEY, { at: await i.now(), session: liveId, what: WARNED_WHAT, detail: ledgerWarned(r), how: 'auto', did: true })
141 i.invalidate()
142 }
143 } catch {
144 }
145}
146
147// Whether this session began before today's midnight: the engine says when it began.
148async function beganBeforeToday($: EngineInterface, now: number): Promise<boolean> {
149 try {
150 const u = await $.session.usage()
151 return u.startedAt < new Date(ymd(now).replace(/-/g, '/')).getTime()
152 } catch {
153 return false
154 }
155}
156
157const folder = (cwd: string): string => lastName(cwd)
158
159export function registerCost(on: On): void {
160 on('session.start', { cwd: /(?<cost>)/ }, async ($, e, next) => {
161 try {
162 const i = io($)
163 live = undefined
164 await ensure(i)
165 const now = await i.now()
166 for (const k of await i.keys()) {
167 if (k.startsWith(STATE_PREFIX)) {
168 const st = parseState(await i.get(k), now)
169 const lastAt = Math.max(st.engineAt ?? 0, st.recent.at(-1)?.at ?? 0, st.startedAt)
170 if (now - lastAt > KEEP_MS && k !== STATE_PREFIX + liveId) await i.del(k)
171 } else if (k.startsWith(DAY_PREFIX) || k.startsWith(DAY_EXTRA_PREFIX)) {
172 const day = /(\d{4}-\d{2}-\d{2})/.exec(k)?.[1]
173 if (day !== undefined && now - new Date(day.replace(/-/g, '/')).getTime() > KEEP_MS) await i.del(k)
174 }
175 }
176 } catch {
177 // the cost is optional: a failure shows less, never breaks the session
178 }
179 return next(e)
180 }).catch(($, e, next) => next(e))
181
182 // The engine's own total, and what this session has spent today.
183 on('session.measure', { changed: /(?<cost>)/ }, async ($, e, next) => {
184 try {
185 if (e.cost !== undefined) {
186 const i = io($)
187 const s = await ensure(i)
188 const now = await i.now()
189 addSample(s, now, e.cost.usd)
190 await noteDay(i, s, () => beganBeforeToday($, now))
191 await save(i, s)
192 await evaluate(i)
193 }
194 } catch {
195 }
196 return next(e)
197 }).catch(($, e, next) => next(e))
198
199 // Each turn's tokens and model, from the engine.
200 on('turn.complete', { turnId: /(?<cost>)/ }, async ($, e, next) => {
201 try {
202 const i = io($)
203 const s = await ensure(i)
204 const t = turnOf(e.usage, await i.now(), e.durationMs, e.agentId !== undefined)
205 if (t !== undefined) {
206 addTurn(s, t)
207 const now = await i.now()
208 await noteDay(i, s, () => beganBeforeToday($, now))
209 await save(i, s)
210 await evaluate(i)
211 }
212 } catch {
213 }
214 return next(e)
215 }).catch(($, e, next) => next(e))
216
217 on('tool.call', { tool: /(?<cost>)/ }, async ($, e, next) => {
218 const i = io($)
219 try {
220 if (isCostly(e.tool)) {
221 const c = await conf(i)
222 if (c.mode === 'stop') {
223 const s = await ensure(i)
224 const { facts } = await factsNow(i, s, c)
225 const over = worst(readings(facts).filter(r => r.level === 'over'))
226 if (over !== undefined) {
227 const key = `${e.tool}:${over.which}`
228 if (!heldLogged.has(key)) {
229 heldLogged.add(key)
230 await pushLog(i, LEDGER_KEY, { at: await i.now(), session: liveId, what: HELD_WHAT, detail: ledgerHeld(e.tool, over), how: 'auto', did: true })
231 i.toast(tr('Stopped a {tool} call: the {which} cost budget is used up. Type /vaken-usage budget extend to go on.').replace('{tool}', e.tool).replace('{which}', tr(over.which)))
232 i.invalidate()
233 }
234 return { deny: stopReason(e.tool, over) }
235 }
236 }
237 }
238 } catch {
239 }
240 const result = await next(e)
241 // Nothing below may throw: a failure after next must not run the tool twice.
242 try {
243 if (result.deny === undefined && live !== undefined) {
244 addTool(live, (result.text ?? '').length)
245 if (notes.length > 0) {
246 const out = { ...result, context: [...(result.context ?? []), ...notes] }
247 notes = []
248 return out
249 }
250 }
251 } catch {
252 }
253 return result
254 }).catch(($, e, next) => next(e))
255
256 on('command.run', { command: 'vaken-usage' }, async ($, e, next) => {
257 const rest0 = subArgs(e.args, 'cost')
258 if (rest0 === undefined) return next(e)
259 const i = io($)
260 const s = await ensure(i)
261 const c = await conf(i)
262 const { facts, day, extra } = await factsNow(i, s, c)
263 const model = s.recent.filter(t => !t.sub).at(-1)?.model
264 return { text: costText({ state: s, now: await i.now(), ...(model === undefined ? {} : { model }), day, budget: budgetLines(facts, extra, day.sessions) }) }
265 })
266
267 on('command.run', { command: 'vaken-usage' }, async ($, e, next) => {
268 const rest0 = subArgs(e.args, 'why')
269 if (rest0 === undefined) return next(e)
270 const i = io($)
271 const c = await conf(i)
272 if (!c.why) return { text: 'The "why it cost" paragraph is off. Turn it on: Settings > Money and plan.' } // en-only: plain answer of a slash command (text twin)
273 const s = await ensure(i)
274 const source = s.engineUsd === undefined ? '' : ` The engine's own total for the session is ${money(s.engineUsd)}.`
275 return { text: `${whyText(s)}${estimateTotal(s) > 0 ? `${source}\nSource: the token counts the engine reported for each answer, priced with the list prices of ${PRICES_DATE}. Estimate.` : ''}` }
276 })
277
278 on('command.run', { command: 'vaken-usage' }, async ($, e, next) => {
279 const rest0 = subArgs(e.args, 'limits')
280 if (rest0 === undefined) return next(e)
281 const i = io($)
282 const now = await i.now()
283 const usage = parseStoredUsage(await i.get(APP_USAGE_KEY))
284 const records = await listRecords(i)
285 const alive = await alivePids(i, records)
286 const live2 = records.filter(r => alive === undefined || r.pid === undefined || alive.has(r.pid))
287 const running = runningNow(live2, parseStoredSessions(await i.get(APP_SESSIONS_KEY)), now)
288 const spenders = live2
289 .filter(r => r.state === 'working' && r.costUsd !== undefined)
290 .sort((a, b) => (b.costUsd ?? 0) - (a.costUsd ?? 0))
291 .map((r): [string, number] => [r.title ?? folder(r.cwd), r.costUsd ?? 0])
292 return { text: limitsText({ usage, history: parseHistory(await i.get(HISTORY_KEY)), running, spenders, now }) }
293 })
294
295 on('command.run', { command: 'vaken-usage' }, async ($, e, next) => {
296 const rest0 = subArgs(e.args, 'budget')
297 if (rest0 === undefined) return next(e)
298 const i = io($)
299 const ask = parseAsk(rest0)
300 if (ask.kind === 'bad') return { text: ask.why }
301 const c = await conf(i)
302 const s = await ensure(i)
303 const now = await factsNow(i, s, c)
304 if (ask.kind === 'status') return { text: [...budgetLines(now.facts, now.extra, now.day.sessions), '', 'At 100% in Stop mode, /vaken-usage budget extend lifts the block. It adds one more budget to the one that is at 80% or more.'].join('\n') } // en-only: plain answer of a slash command (text twin)
305 if (c.mode === 'off') return { text: 'The budget is off, so there is nothing to extend. Turn it on: Settings > Money and plan.' } // en-only: plain answer of a slash command (text twin)
306 const which = toExtend(ask, readings(now.facts))
307 if (which.length === 0) return { text: 'Nothing to extend: no budget is at 80% yet. To raise one anyway: /vaken-usage budget extend session, or /vaken-usage budget extend day.' } // en-only: plain answer of a slash command (text twin)
308 const added: string[] = []
309 for (const w of which) {
310 if (w === 'session') {
311 s.extra += c.session
312 await save(i, s)
313 added.push(`session budget +${money(c.session)}, now ${money(c.session + s.extra)}`)
314 } else {
315 const extra = now.extra.day + c.day
316 await i.set(DAY_EXTRA_PREFIX + ymd(await i.now()), extra)
317 added.push(`day budget +${money(c.day)}, now ${money(c.day + extra)}`)
318 }
319 }
320 const after = await factsNow(i, s, c)
321 rises(told, readings(after.facts))
322 notes = []
323 heldLogged.clear()
324 await pushLog(i, LEDGER_KEY, { at: await i.now(), session: liveId, what: EXTENDED_WHAT, detail: `${added.join('; ')} (estimates).`, how: 'typed', did: true })
325 i.invalidate()
326 const still = worst(readings(after.facts).filter(r => r.level === 'over'))
327 return { text: `Extended: ${added.join('; ')}. ${still === undefined ? 'Costly calls are allowed again.' : `The ${still.which} budget is still used up. Extend again to go on.`}` }
328 })
329}
330hooks/features/insight.ts 203 lines1// Insight runtime: the audit export, wait tracking, the quota planner, review by story and cost per outcome.
2import type { EngineInterface, On } from 'claude-code'
3import { fieldsOf } from '../core/eventfields'
4
5import { parseStoredUsage, APP_USAGE_KEY } from '../core/appusage'
6import { parseState as parseCost, spendOf } from '../core/cost'
7import { freePath } from '../core/fileops'
8import { tr } from '../core/lang'
9import { refusedWords, safeWrite, takenProbe } from '../core/safewrite'
10import type { SafeFs } from '../core/safewrite'
11import { readNote } from '../core/flush'
12import { withToastGate } from '../core/io'
13import { isShellTool } from '../core/tools'
14import { homeFrom } from '../core/platform'
15import type { Io } from '../core/io'
16import { AUDIT_KEY, OUTCOMES_KEY, tidyKeys, tidyText, addOutcome, addWait, auditFile, auditText, fitText, outcomesText, parseAudit, parseNumstat, parseOutcomes, parseWaits, storyText, waitsKey, waitsText } from '../core/insight'
17import { doneMet, readState, stateKey } from '../core/proof'
18import { listRecords, readRecord } from '../core/record'
19import { serialUpdate } from '../core/serial'
20import { LEDGER_KEY, pushLog } from './compaction'
21import { armOnce } from './everyonce'
22import { subArgs } from '../core/menu'
23
24// IO-BLOCK-START
25function io($: EngineInterface): Io {
26 return withToastGate({
27 now: () => $.clock.now(),
28 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
29 sessionId: () => $.session.id(),
30 get: key => $.store.get(key),
31 set: (key, value) => $.store.set(key, value),
32 del: key => $.store.delete(key),
33 keys: () => $.store.keys(),
34 read: path => $.fs.read(path),
35 write: (path, text) => $.fs.write(path, text),
36 run: (argv, init) => $.process.run(argv, init),
37 ask: (question, options) => $.ui.ask(question, options),
38 complete: req => $.model.complete(req),
39 toast: text => $.ui.toast(text),
40 invalidate: () => $.ui.invalidate('ui.render'),
41 })
42}
43// IO-BLOCK-END
44
45// The engine's file calls a safe write needs (core/safewrite.ts).
46function safeFs($: EngineInterface): SafeFs {
47 return { stat: (path, options) => $.fs.stat(path, options), list: path => $.fs.list(path) }
48}
49
50let askOpen: { since: number; kind: 'question' | 'permission' } | undefined
51const COST_PREFIX = 'cockpit:cost:s:'
52const lastCostKey = (id: string): string => `cockpit:outcomes:last:${id}`
53const MIN_OUTCOME_USD = 0.01
54let doneMark = ''
55
56async function noteWait(i: Io, kind: 'question' | 'permission' | 'after', ms: number): Promise<void> {
57 const now = await i.now()
58 await serialUpdate(i, waitsKey(), old => addWait(parseWaits(old, now), kind, ms))
59}
60
61async function noteOutcome(i: Io, kind: 'done' | 'merged'): Promise<void> {
62 const id = await i.sessionId()
63 const now = await i.now()
64 const spend = spendOf(parseCost(await i.get(COST_PREFIX + id), now))
65 const before = typeof (await i.get(lastCostKey(id))) === 'number' ? ((await i.get(lastCostKey(id))) as number) : 0
66 const usd = Math.max(0, spend.usd - before)
67 if (usd < MIN_OUTCOME_USD) return
68 await i.set(lastCostKey(id), spend.usd)
69 const outcome = { at: now, kind, usd, session: id.slice(0, 8) }
70 await serialUpdate(i, OUTCOMES_KEY, old => addOutcome(parseOutcomes(old), outcome), stored => parseOutcomes(stored).some(o => o.at === now && o.session === outcome.session && o.kind === kind))
71 await pushLog(i, LEDGER_KEY, { at: now, session: id, what: kind === 'merged' ? 'Noted a merged pull request' : 'Noted finished work', detail: `Cost since the last outcome: about $${usd.toFixed(2)} (estimate).`, how: 'auto' })
72}
73
74export function registerInsight(on: On): void {
75 on('session.start', { cwd: /(?<insight>)/ }, async ($, e, next) => {
76 try {
77 const timer = $.clock.every(15_000, () => {
78 void (async () => {
79 try {
80 const i = io($)
81 const rec = await readRecord(i, await i.sessionId())
82 const now = await i.now()
83 if (rec?.ask !== undefined && askOpen === undefined) askOpen = { since: rec.ask.since, kind: rec.ask.kind }
84 else if (rec?.ask === undefined && askOpen !== undefined) {
85 await noteWait(i, askOpen.kind, now - askOpen.since)
86 askOpen = undefined
87 }
88 } catch {
89 }
90 })()
91 })
92 armOnce('insight', timer)
93 } catch {
94 }
95 return next(e)
96 }).catch(($, e, next) => next(e))
97
98 on('prompt.submit', { text: /(?<insight>)/ }, async ($, e, next) => {
99 try {
100 const i = io($)
101 if (!e.text.trimStart().startsWith('/')) {
102 const rec = await readRecord(i, await i.sessionId())
103 if (rec !== undefined && rec.state === 'waiting') await noteWait(i, 'after', (await i.now()) - rec.stateSince)
104 }
105 } catch {
106 }
107 return next(e)
108 }).catch(($, e, next) => next(e))
109
110 on('tool.call', { tool: /(?<insight>)/ }, async ($, e, next) => {
111 const result = await next(e)
112 try {
113 if (isShellTool(e.tool) && e.agentId === undefined && result.deny === undefined && result.isError !== true && /(^|[;&|]\s*)gh\s+pr\s+merge\b/.test(String(fieldsOf(e).command ?? ''))) await noteOutcome(io($), 'merged')
114 } catch {
115 }
116 return result
117 }).catch(($, e, next) => next(e))
118
119 on('turn.complete', { turnId: /(?<insight>)/ }, async ($, e, next) => {
120 if (e.agentId === undefined) {
121 $.clock.after(4_000, () => {
122 void (async () => {
123 try {
124 const i = io($)
125 const s = readState(await readNote(i, stateKey(await i.sessionId())))
126 const mark = `${s.seq}:${s.done.join(',')}`
127 if (s.done.length > 0 && doneMet(s, s.done) && mark !== doneMark) {
128 doneMark = mark
129 await noteOutcome(i, 'done')
130 }
131 } catch {
132 }
133 })()
134 })
135 }
136 return next(e)
137 }).catch(($, e, next) => next(e))
138
139 on('command.run', { command: 'vaken-help' }, async ($, e, next) => {
140 const rest0 = subArgs(e.args, 'run', 'audit')
141 if (rest0 === undefined) return next(e)
142 const i = io($)
143 const rows = parseAudit(await i.get(AUDIT_KEY))
144 if (rest0.trim().toLowerCase() === 'export') {
145 if (rows.length === 0) return { text: 'Nothing to export yet.' } // en-only: plain answer of a slash command (text twin)
146 // An export never overwrites a file that is there: it takes the next free name (cockpit-audit-2.md).
147 const root = await $.session.root()
148 const fs = safeFs($)
149 const path = await freePath(takenProbe(fs), `${root}/.claude/cockpit-audit.md`)
150 if (path === undefined) return { text: 'Too many exports are in the .claude folder already. Remove an old .claude/cockpit-audit*.md file and try again.' } // en-only: plain answer of a slash command (text twin)
151 if (!(await safeWrite(fs, (p, x) => i.write(p, x), root, path, auditFile(rows))).ok) return { text: refusedWords(path, tr) }
152 return { text: `Wrote ${path} (${rows.length} actions).` }
153 }
154 return { text: auditText(rows) }
155 })
156
157 on('command.run', { command: 'vaken-today' }, async ($, e, next) => {
158 const rest0 = subArgs(e.args, 'waits')
159 if (rest0 === undefined) return next(e)
160 const i = io($)
161 return { text: waitsText(parseWaits(await i.get(waitsKey()), await i.now())) }
162 })
163
164 on('command.run', { command: 'vaken-usage' }, async ($, e, next) => {
165 const rest0 = subArgs(e.args, 'fit')
166 if (rest0 === undefined) return next(e)
167 const i = io($)
168 return { text: fitText(parseStoredUsage(await i.get(APP_USAGE_KEY)), Number(rest0.trim().replace('%', '')), await i.now()) }
169 })
170
171 on('command.run', { command: 'vaken-ship' }, async ($, e, next) => {
172 const rest0 = subArgs(e.args, 'review-story')
173 if (rest0 === undefined) return next(e)
174 const i = io($)
175 try {
176 const cwd = await $.session.cwd()
177 const r = await i.run(['git', 'diff', '--numstat', 'HEAD'], { cwd, timeoutMs: 5_000 })
178 const goal = readState(await readNote(i, stateKey(await i.sessionId()))).goal?.text
179 return { text: storyText(goal, r.exitCode === 0 ? parseNumstat(r.stdout) : []) }
180 } catch {
181 return { text: 'Vaken could not read the change with git.' } // en-only: plain answer of a slash command (text twin)
182 }
183 })
184
185 on('command.run', { command: 'vaken-help' }, async ($, e, next) => {
186 const rest0 = subArgs(e.args, 'run', 'outcomes')
187 if (rest0 === undefined) return next(e)
188 const i = io($)
189 return { text: outcomesText(parseOutcomes(await i.get(OUTCOMES_KEY)), await i.now()) }
190 })
191
192 on('command.run', { command: 'vaken-help' }, async ($, e, next) => {
193 const rest0 = subArgs(e.args, 'run', 'tidy')
194 if (rest0 === undefined) return next(e)
195 const i = io($)
196 const live = [...(await listRecords(i)).map(r => r.sessionId), await i.sessionId()]
197 const keys = tidyKeys(await i.keys(), live)
198 if (rest0.trim().toLowerCase() !== 'yes') return { text: tidyText(keys, false) }
199 for (const k of keys) await i.del(k)
200 return { text: tidyText(keys, true) }
201 })
202}
203hooks/features/keeper.ts 186 lines1// The store keeper runtime: removes a session's notes when it ends, sweeps old notes once a day, and makes room when the store is full.
2import type { EngineInterface, On } from 'claude-code'
3
4import { briefSweepArgv, isBriefFile } from '../core/autosafe'
5import { removeEmptyDir, removeFiles } from '../core/fileops'
6import { withToastGate } from '../core/io'
7import { bindSafeFs } from '../core/safewrite'
8import { detectStyle, homeFrom } from '../core/platform'
9import type { Io } from '../core/io'
10import { DAY_KEY, ENDED_KEY, GONE_MS, KEPT_FAMILIES, endKeys, fullToast, localDay, parseEnded, planSweep, withEnded } from '../core/keeper'
11import { CHECKPOINTS_REL, copyDir, indexKey, isCopyFile, loadIndex, namedKey, parseNamed, removeCopies, removeCopyDir, safeId, shrinkIndex } from '../core/undo'
12
13// IO-BLOCK-START
14function io($: EngineInterface): Io {
15 // Bound to the safe file calls
16 return bindSafeFs(withToastGate({
17 now: () => $.clock.now(),
18 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
19 sessionId: () => $.session.id(),
20 get: key => $.store.get(key),
21 set: (key, value) => $.store.set(key, value),
22 del: key => $.store.delete(key),
23 keys: () => $.store.keys(),
24 read: path => $.fs.read(path),
25 write: (path, text) => $.fs.write(path, text),
26 run: (argv, init) => $.process.run(argv, init),
27 ask: (question, options) => $.ui.ask(question, options),
28 complete: req => $.model.complete(req),
29 toast: text => $.ui.toast(text),
30 invalidate: () => $.ui.invalidate('ui.render'),
31 }), { stat: (path, options) => $.fs.stat(path, options), list: path => $.fs.list(path) })
32}
33// IO-BLOCK-END
34
35// The daily sweep waits a little after the session starts, so the start itself stays quick.
36const SWEEP_DELAY_MS = 30_000
37const SESSION_PREFIX = 'cockpit:session:'
38const EYES_PREFIX = 'cockpit:eyes:last:'
39const UNDO_INDEX_PREFIX = 'cockpit:undo:index:'
40
41let sweeping = false
42let toldFull = false
43
44const atOf = (v: unknown): number | undefined => {
45 const at = (v as { at?: unknown; updatedAt?: unknown } | undefined)?.at ?? (v as { updatedAt?: unknown } | undefined)?.updatedAt
46 return typeof at === 'number' && Number.isFinite(at) ? at : undefined
47}
48
49export async function sweep(i: Io, full: boolean): Promise<number> {
50 const now = await i.now()
51 const me = await i.sessionId()
52 const keys = await i.keys()
53 const records = new Map<string, number>()
54 const live = new Set<string>([me])
55 const eyesAt = new Map<string, number | undefined>()
56 for (const k of keys) {
57 if (k.startsWith(SESSION_PREFIX)) {
58 const at = atOf(await i.get(k))
59 const sid = k.slice(SESSION_PREFIX.length)
60 if (at !== undefined) records.set(sid, at)
61 if (at !== undefined && now - at <= GONE_MS) live.add(sid)
62 } else if (k.startsWith(EYES_PREFIX)) eyesAt.set(k, atOf(await i.get(k)))
63 }
64 const before = parseEnded(await i.get(ENDED_KEY))
65 const plan = planSweep(keys, { now, live, ended: before, eyesAt, records, full })
66 const home = await i.home()
67 let n = 0
68 for (const k of plan.remove) {
69 try {
70 if (k.startsWith(UNDO_INDEX_PREFIX) && home !== '') {
71 const sid = k.slice(UNDO_INDEX_PREFIX.length)
72 await removeCopies(i, home, sid, (await loadIndex(i, sid)).snaps.filter(s => s.kind === 'saved').map(s => s.id))
73 await removeCopyDir(i, home, sid)
74 }
75 await i.del(k)
76 n += 1
77 } catch {
78 }
79 }
80 if (JSON.stringify(plan.ended) !== JSON.stringify(before)) await i.set(ENDED_KEY, plan.ended).catch(() => undefined)
81 return n
82}
83
84const linesOfOut = (out: string): string[] => out.split('\n').map(l => l.trim()).filter(l => l !== '')
85
86// Folders on disk vaken made and nothing else removes, swept with the daily sweep, never on a hot path.
87export async function sweepFolders(i: Io, keys: readonly string[]): Promise<number> {
88 let n = 0
89 try {
90 const home = await i.home()
91 if (home === '' || detectStyle(home) !== 'posix') return 0
92 const old = await i.run(briefSweepArgv(home), { timeoutMs: 10_000 })
93 const briefs = old.exitCode === 0 ? linesOfOut(old.stdout).filter(p => isBriefFile(home, p)) : []
94 if (briefs.length > 0 && (await removeFiles({ run: i.run }, briefs))) n += briefs.length
95 const known = new Set(keys.filter(k => k.startsWith(UNDO_INDEX_PREFIX)).map(k => safeId(k.slice(UNDO_INDEX_PREFIX.length))))
96 const root = `${home.replace(/\/+$/, '')}/${CHECKPOINTS_REL}`
97 const keepDays = KEPT_FAMILIES[UNDO_INDEX_PREFIX] ?? 7
98 const dirs = await i.run(['find', root, '-mindepth', '1', '-maxdepth', '1', '-type', 'd', '-mtime', `+${keepDays}`], { timeoutMs: 10_000 })
99 for (const dir of dirs.exitCode === 0 ? linesOfOut(dirs.stdout) : []) {
100 const name = dir.slice(root.length + 1)
101 if (!/^[A-Za-z0-9_-]{1,80}$/.test(name) || known.has(name) || copyDir(home, name) !== dir) continue
102 const inside = await i.run(['find', dir, '-maxdepth', '1', '-type', 'f', '-name', '*.txt'], { timeoutMs: 10_000 })
103 const files = (inside.exitCode === 0 ? linesOfOut(inside.stdout) : []).filter(p => isCopyFile(home, p))
104 if (files.length > 0 && (await removeFiles({ run: i.run }, files))) n += files.length
105 await removeEmptyDir({ run: i.run }, dir)
106 }
107 } catch {
108 }
109 return n
110}
111
112async function atEnd(i: Io, sid: string): Promise<void> {
113 const keys = await i.keys()
114 for (const k of endKeys(keys, sid)) await i.del(k).catch(() => undefined)
115 const now = await i.now()
116 await i.set(ENDED_KEY, withEnded(parseEnded(await i.get(ENDED_KEY)), sid, now))
117 if (keys.includes(indexKey(sid))) {
118 const idx = await loadIndex(i, sid)
119 const cut = shrinkIndex(idx, parseNamed(await i.get(namedKey(sid))))
120 if (cut.idx.snaps.length < idx.snaps.length) {
121 await i.set(indexKey(sid), cut.idx)
122 const home = await i.home()
123 if (home !== '') await removeCopies(i, home, sid, cut.gone)
124 }
125 }
126}
127
128export function registerKeeper(on: On): void {
129 on('session.start', { cwd: /(?<keeper>)/ }, async ($, e, next) => {
130 try {
131 const i = io($)
132 const today = localDay(await i.now())
133 if ((await i.get(DAY_KEY)) !== today) {
134 await i.set(DAY_KEY, today)
135 $.clock.after(SWEEP_DELAY_MS, () => {
136 void (async () => {
137 if (sweeping) return
138 sweeping = true
139 try {
140 const i2 = io($)
141 await sweep(i2, false)
142 await sweepFolders(i2, await i2.keys())
143 } catch {
144 } finally {
145 sweeping = false
146 }
147 })()
148 })
149 }
150 } catch {
151 }
152 return next(e)
153 }).catch(($, e, next) => next(e))
154
155 on('session.end', { sessionId: /(?<keeper>)/ }, async ($, e, next) => {
156 try {
157 await atEnd(io($), e.sessionId)
158 } catch {
159 }
160 return next(e)
161 }).catch(($, e, next) => next(e))
162
163 on('store.set', { key: /(?<keeper>)/ }, async ($, e, next) => {
164 try {
165 return await next(e)
166 } catch (err) {
167 if (sweeping) throw err
168 sweeping = true
169 let n = 0
170 try {
171 n = await sweep(io($), true)
172 } catch {
173 n = 0
174 } finally {
175 sweeping = false
176 }
177 if (n === 0) throw err
178 if (!toldFull) {
179 toldFull = true
180 io($).toast(fullToast(n), true)
181 }
182 return await next(e)
183 }
184 }).catch(($, e, next) => next(e))
185}
186hooks/features/inbox.ts 328 lines1// Approvals inbox and Steering. /cockpit inbox lists, across all sessions, what waits for the person: steps the guard held
2import { tr } from '../core/lang'
3import type { EngineInterface, On } from 'claude-code'
4import { fieldsOf } from '../core/eventfields'
5
6import { parseStoredUsage, APP_USAGE_KEY } from '../core/appusage'
7import { parseStoredSessions, APP_SESSIONS_KEY } from '../core/appsessions'
8import { clearBand, postBand, readBand } from '../core/band'
9import { tickDue } from '../core/quiet'
10import { mayScan } from '../core/leader'
11import { allowKey, allowLive, haltKey, haltMem, parseAllow, parseHalt } from '../core/guard'
12import { HERE_FALLBACK, INBOX_BAND_ID, INBOX_SEEN_KEY, OFFER_MAX, QUEUE_OFFER_WHAT, QUEUE_TEXT_MAX, sendButtons, STEER_SEND_WHAT, STEER_STOP_WHAT, addQueued, buildInbox, cleanQueued, dropSent, heldCount, inboxBandWords, inboxText, markOffered, nextOffer, noSuchSession, notSentWords, offerToast, parseQueue, parseSteerAsk, queuedWords, sendQuestion, sentWords, steerKey, steerText, stopWords } from '../core/inbox'
13import type { InboxItem, SessionInfo, SteerOther } from '../core/inbox'
14import { readNote } from '../core/flush'
15import type { Entry } from '../core/ledger'
16import { withToastGate } from '../core/io'
17import { saidYes } from '../core/confirm'
18import { homeFrom } from '../core/platform'
19import type { Io } from '../core/io'
20import { alivePids, listRecords, listRecordsLight } from '../core/record'
21import { readState, stateKey } from '../core/proof'
22import { readSetting } from '../core/settings'
23import { LEDGER_KEY, pushLog, readLog } from './compaction'
24import { buildView } from './home/logic'
25import type { Row } from './home/logic'
26import { armOnce } from './everyonce'
27import { subArgs } from '../core/menu'
28
29
30// IO-BLOCK-START
31function io($: EngineInterface): Io {
32 return withToastGate({
33 now: () => $.clock.now(),
34 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
35 sessionId: () => $.session.id(),
36 get: key => $.store.get(key),
37 set: (key, value) => $.store.set(key, value),
38 del: key => $.store.delete(key),
39 keys: () => $.store.keys(),
40 read: path => $.fs.read(path),
41 write: (path, text) => $.fs.write(path, text),
42 run: (argv, init) => $.process.run(argv, init),
43 ask: (question, options) => $.ui.ask(question, options),
44 complete: req => $.model.complete(req),
45 toast: text => $.ui.toast(text),
46 invalidate: () => $.ui.invalidate('ui.render'),
47 })
48}
49// IO-BLOCK-END
50
51const CHECK_MS = 20_000
52const BAND_TTL_MS = 5 * 60_000
53const OFFER_DELAY_MS = 2_500
54
55let runningTurn: string | undefined
56
57const ledger = async (i: Io, sid: string, what: string, detail: string, how: 'auto' | 'typed'): Promise<void> => pushLog(i, LEDGER_KEY, { at: await i.now(), session: sid, what, detail, how, did: true })
58
59export async function liveRows(i: Io, light = false): Promise<{ now: number; sid: string; rows: Row[] }> {
60 const now = await i.now()
61 const sid = await i.sessionId()
62 const records = light ? await listRecordsLight(i) : await listRecords(i)
63 const alive = await alivePids(i, records)
64 const view = buildView(records, now, sid, alive, parseStoredUsage(await i.get(APP_USAGE_KEY)), undefined, parseStoredSessions(await i.get(APP_SESSIONS_KEY)))
65 const rows = [view.here, ...view.needs, ...view.working, ...view.waiting, ...view.idle].filter((r): r is Row => r !== undefined)
66 return { now, sid, rows }
67}
68
69// Never throws for a missing key: a missing value means nothing waits there.
70export async function collectInbox(i: Io, light = false): Promise<{ items: InboxItem[]; now: number }> {
71 const { now, sid, rows } = await liveRows(i, light)
72 return { items: await inboxFrom(i, { now, sid, rows, ledger: await readLog(i, LEDGER_KEY) }, true), now }
73}
74
75// The inbox from the rows and the ledger a caller already read (the pane's load reads both anyway): only the three keys of each session and the "first
76export async function inboxFrom(i: Io, f: { now: number; sid: string; rows: Row[]; ledger: Entry[] }, remember: boolean): Promise<InboxItem[]> {
77 const { now, sid, rows } = f
78 const sessions: SessionInfo[] = rows.map(r => ({ sid: r.rec.sessionId, name: r.name, here: r.rec.sessionId === sid, ...(r.rec.promptAt === undefined ? {} : { promptAt: r.rec.promptAt }) }))
79 if (!sessions.some(s => s.here)) sessions.push({ sid, name: HERE_FALLBACK, here: true })
80 const asks = rows.flatMap(r => (r.kind === 'needs' && r.rec.ask !== undefined ? [{ sid: r.rec.sessionId, kind: r.rec.ask.kind, tool: r.rec.ask.tool, since: r.rec.ask.since }] : []))
81 const debts: { sid: string; turns: number }[] = []
82 const halts: { sid: string; at: number }[] = []
83 const allowed: string[] = []
84 for (const s of sessions) {
85 const turns = readState(await readNote(i, stateKey(s.sid))).debt
86 if (turns > 0) debts.push({ sid: s.sid, turns })
87 const halt = parseHalt(await i.get(haltKey(s.sid)))
88 if (halt !== undefined) halts.push({ sid: s.sid, at: halt.at })
89 if (allowLive(parseAllow(await i.get(allowKey(s.sid))), now)) allowed.push(s.sid)
90 }
91 const raw = await i.get(INBOX_SEEN_KEY)
92 const old: Record<string, number> = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? Object.fromEntries(Object.entries(raw as Record<string, unknown>).filter((pair): pair is [string, number] => typeof pair[1] === 'number')) : {}
93 const seen: Record<string, number> = {}
94 for (const d of debts) {
95 const at = old[`review:${d.sid}`] ?? (remember ? now : undefined)
96 if (at !== undefined) seen[`review:${d.sid}`] = at
97 }
98 if (remember && JSON.stringify(seen) !== JSON.stringify(old)) await i.set(INBOX_SEEN_KEY, seen)
99 return buildInbox({ now, sessions, ledger: f.ledger, allowed, asks, debts, halts, seen })
100}
101
102const PANE_INBOX_FRESH_MS = 15_000
103let paneInbox: { at: number; sig: string; items: InboxItem[] } | undefined
104export async function inboxForPane(i: Io, f: { now: number; sid: string; rows: Row[]; ledger: Entry[] }, extra: string): Promise<InboxItem[]> {
105 const sig = `${f.sid}|${f.rows.map(r => `${r.rec.sessionId}:${r.rec.promptAt ?? 0}`).join(',')}|${f.ledger.length}:${f.ledger[0]?.at ?? 0}|${extra}`
106 if (paneInbox !== undefined && paneInbox.sig === sig && f.now - paneInbox.at >= 0 && f.now - paneInbox.at < PANE_INBOX_FRESH_MS) return paneInbox.items
107 const items = await inboxFrom(i, f, false)
108 paneInbox = { at: f.now, sig, items }
109 return items
110}
111export const forgetPaneInbox = (): void => {
112 paneInbox = undefined
113}
114
115async function bandNow(i: Io, held: number): Promise<void> {
116 const sid = await i.sessionId()
117 if (held === 0) return clearBand(i, INBOX_BAND_ID, sid)
118 const text = inboxBandWords(held)
119 const mine = (await readBand(i, sid)).find(x => x.id === INBOX_BAND_ID)
120 if (mine !== undefined && mine.text === text && (await i.now()) - mine.at < BAND_TTL_MS / 2) return
121 await postBand(i, 'warn', INBOX_BAND_ID, text, BAND_TTL_MS, sid)
122}
123
124// The box takes it only when it is empty, so a draft the person is typing is never touched.
125async function offerQueued(i: Io, suggest: Suggest): Promise<void> {
126 if (runningTurn !== undefined) return
127 const sid = await i.sessionId()
128 const now = await i.now()
129 const queue = parseQueue(await i.get(steerKey(sid)))
130 const item = nextOffer(queue, now)
131 if (item === undefined) return
132 if (!(await suggest(item.text))) return
133 await i.set(steerKey(sid), markOffered(queue, item.text, now))
134 await ledger(i, sid, QUEUE_OFFER_WHAT, `A queued prompt shows in the prompt box (offer ${item.offers + 1} of ${OFFER_MAX}).`, 'auto')
135 i.toast(tr(offerToast))
136 i.invalidate()
137}
138
139const INBOX_SHARED_KEY = 'cockpit:inbox:held'
140const SHARED_FRESH_MS = CHECK_MS / 2
141async function heldNow(i: Io): Promise<number> {
142 const now = await i.now()
143 const raw = (await i.get(INBOX_SHARED_KEY)) as { at?: unknown; held?: unknown } | undefined
144 if (typeof raw?.at === 'number' && typeof raw.held === 'number' && now - raw.at >= 0 && now - raw.at < SHARED_FRESH_MS) return raw.held
145 // Older: the leader session looks again (core/leader.ts)
146 if (typeof raw?.at === 'number' && typeof raw.held === 'number' && !(await mayScan(i, raw.at, CHECK_MS))) return raw.held
147 const held = heldCount((await collectInbox(i, true)).items)
148 await i.set(INBOX_SHARED_KEY, { at: now, held })
149 return held
150}
151
152// Exported so that a test can run it by hand (a test clock does not drive a timer made at the start of the session).
153export async function inboxCheck(i: Io, suggest: Suggest): Promise<void> {
154 try {
155 if ((await readSetting(i, 'inbox.mode')) === 'off') await clearBand(i, INBOX_BAND_ID)
156 else await bandNow(i, await heldNow(i))
157 } catch {
158 }
159 try {
160 await offerQueued(i, suggest)
161 } catch {
162 }
163}
164
165type Suggest = (text: string) => Promise<boolean>
166
167// One check of the timer, quiet when the person is away (core/quiet.ts)
168async function inboxTimer(i: Io, suggest: Suggest): Promise<void> {
169 if (tickDue('inbox', await i.now())) await inboxCheck(i, suggest)
170}
171
172// Shows a suggestion in the prompt box; false when it could not be shown
173async function suggestText($: EngineInterface, text: string): Promise<boolean> {
174 try {
175 const shown = await $.prompt.suggest({ text })
176 return shown.isShown
177 } catch {
178 return false
179 }
180}
181
182const stateWord = (r: Row): string => (r.kind === 'needs' ? 'needs you' : r.kind === 'working' ? 'working' : r.kind === 'waiting' ? 'finished, waits for you' : 'idle')
183
184async function othersOf(i: Io): Promise<{ sid: string; name: string; row: Row }[]> {
185 const { sid, rows } = await liveRows(i)
186 return rows
187 .filter(r => r.rec.sessionId !== sid)
188 .map(r => ({ sid: r.rec.sessionId, name: r.name, row: r }))
189 .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.sid < b.sid ? -1 : 1))
190}
191
192export function registerInbox(on: On): void {
193 on('session.start', { cwd: /(?<inbox>)/ }, async ($, e, next) => {
194 try {
195 const tickIo = io($)
196 const tickSuggest: Suggest = text => suggestText($, text)
197 const timer = $.clock.every(CHECK_MS, () => void inboxTimer(tickIo, tickSuggest).catch(() => undefined))
198 armOnce('inbox', timer)
199 } catch {
200 // the commands and the band are optional: a failure shows less, never breaks the session
201 }
202 return next(e)
203 }).catch(($, e, next) => next(e))
204
205 // The running answer of the main thread, so that stop can cancel it.
206 on('turn.start', { turnId: /(?<inbox>)/ }, async ($, e, next) => {
207 if (fieldsOf(e).agentId === undefined) runningTurn = e.turnId
208 return next(e)
209 }).catch(($, e, next) => next(e))
210
211 on('turn.complete', { turnId: /(?<inbox>)/ }, async ($, e, next) => {
212 const r = await next(e)
213 try {
214 if (e.agentId === undefined) {
215 runningTurn = undefined
216 const offerIo = io($)
217 const offerSuggest: Suggest = text => suggestText($, text)
218 $.clock.after(OFFER_DELAY_MS, () => void offerQueued(offerIo, offerSuggest).catch(() => undefined))
219 }
220 } catch {
221 }
222 return r
223 }).catch(($, e, next) => next(e))
224
225 on('prompt.submit', { text: /(?<inbox>)/ }, async ($, e, next) => {
226 try {
227 if (!e.text.trimStart().startsWith('/')) {
228 const i = io($)
229 const sid = await i.sessionId()
230 const queue = parseQueue(await i.get(steerKey(sid)))
231 if (queue.length > 0) {
232 const rest = dropSent(queue, e.text)
233 if (rest.length !== queue.length) await i.set(steerKey(sid), rest)
234 }
235 }
236 } catch {
237 }
238 return next(e)
239 }).catch(($, e, next) => next(e))
240
241 on('command.run', { command: 'vaken' }, async ($, e, next) => {
242 const rest0 = subArgs(e.args, 'inbox')
243 if (rest0 === undefined) return next(e)
244 const i = io($)
245 const { items, now } = await collectInbox(i)
246 return { text: inboxText(items, now, (await readSetting(i, 'inbox.mode')) !== 'off') }
247 })
248
249 on('command.run', { command: 'vaken-pause' }, async ($, e, next) => {
250 const rest0 = subArgs(e.args, 'steer')
251 if (rest0 === undefined) return next(e)
252 const i = io($)
253 const sid = await i.sessionId()
254 const now = await i.now()
255 const ask = parseSteerAsk(rest0)
256 if (ask.kind === 'bad') return { text: ask.why }
257
258 if (ask.kind === 'list') {
259 const { rows } = await liveRows(i)
260 const here = rows.find(r => r.rec.sessionId === sid)
261 const others: SteerOther[] = []
262 for (const [k, o] of (await othersOf(i)).entries()) others.push({ n: k + 1, name: o.name, state: stateWord(o.row), queued: parseQueue(await i.get(steerKey(o.sid))).length })
263 return { text: steerText({ name: here?.name ?? 'this session', queue: parseQueue(await i.get(steerKey(sid))), halted: parseHalt(await i.get(haltKey(sid))) !== undefined, others, now }) }
264 }
265
266 if (ask.kind === 'clear') {
267 await i.set(steerKey(sid), [])
268 return { text: 'The queue of this session is empty.' }
269 }
270
271 if (ask.kind === 'stop') {
272 // Cancelling the running answer is the same as pressing Escape.
273 await i.set(haltKey(sid), { at: now })
274 haltMem.held = true
275 let stopped: boolean | undefined
276 if (runningTurn !== undefined) {
277 try {
278 await $.turn.abort({ turnId: runningTurn })
279 stopped = true
280 } catch {
281 stopped = false
282 }
283 }
284 await ledger(i, sid, STEER_STOP_WHAT, stopped === true ? 'Paused all changes and cancelled the running answer.' : 'Paused all changes.', 'typed')
285 i.toast(tr('Paused for safety. Resume with /vaken-pause off.'), true)
286 i.invalidate()
287 return { text: stopWords(stopped) }
288 }
289
290 const others = await othersOf(i)
291 const target = ask.kind === 'send' || ask.target !== undefined ? others[(ask.target as number) - 1] : undefined
292 if ((ask.kind === 'send' || ask.target !== undefined) && target === undefined) return { text: noSuchSession(ask.target as number) }
293
294 if (ask.kind === 'queue') {
295 const toSid = target?.sid ?? sid
296 const r = addQueued(parseQueue(await i.get(steerKey(toSid))), ask.text, now, sid)
297 if (!r.ok) return { text: r.why as string }
298 await i.set(steerKey(toSid), r.queue)
299 i.invalidate()
300 if (toSid === sid) {
301 const offerIo = io($)
302 const offerSuggest: Suggest = text => suggestText($, text)
303 $.clock.after(OFFER_DELAY_MS, () => void offerQueued(offerIo, offerSuggest).catch(() => undefined))
304 }
305 return { text: queuedWords(target === undefined ? 'this session' : `"${target.name}"`, cleanQueued(ask.text)) }
306 }
307
308 const to = target as { sid: string; name: string }
309 const text = cleanQueued(ask.text).slice(0, QUEUE_TEXT_MAX)
310 let answer = ''
311 const buttons = sendButtons()
312 try {
313 answer = await i.ask(sendQuestion(to.name), [buttons.yes, buttons.no])
314 } catch {
315 return { text: notSentWords('The question was dismissed.') }
316 }
317 if (!saidYes(answer, buttons)) return { text: notSentWords('You said no.') }
318 try {
319 const r = await $.session.send({ to: { sessionId: to.sid }, text })
320 if (!r.isDelivered) return { text: notSentWords(r.reason) }
321 } catch (err) {
322 return { text: notSentWords(err instanceof Error ? err.message.slice(0, 160) : 'The send failed.') }
323 }
324 await ledger(i, sid, STEER_SEND_WHAT, `Sent a message to "${to.name}".`, 'typed')
325 return { text: sentWords(to.name) }
326 })
327}
328hooks/features/continuity.ts 387 lines1// Continuity (Settings > Prompts and memory): the verified handoff note, the session packet, resume by name, the timeline, search across sessions
2import { t, tr } from '../core/lang'
3import { fillIn } from '../core/fillin'
4import { saidYes } from '../core/confirm'
5import type { EngineInterface, On } from 'claude-code'
6import { noteCall } from '../core/selfcost'
7
8import { APP_SESSIONS_KEY, SESSIONS_TOOL, matchSession, parseSessions, parseStoredSessions } from '../core/appsessions'
9import type { AppSession, AppSessions } from '../core/appsessions'
10import { USAGE_SERVER } from '../core/appusage'
11import { ymd } from '../core/time'
12import { OFF_TEXT, PACKET_PATH, PACKET_WHAT, briefMarkLines, parseBranch, parseGitStatus, relativeTo, renderBrief, renderPacket, storyLines } from '../core/continuity'
13import type { BriefFacts, GitFile } from '../core/continuity'
14import { FIND_LIMIT, FIND_MIN, NOT_AVAILABLE, findText, jsonArrayIn, lastWork, matchWords, parseCount, parseHits, resumeText, standupLines, timelineText } from '../core/continuity2'
15import type { DaySession, TimelineFacts } from '../core/continuity2'
16import { commandsKey, parseEntries } from '../core/guardcmds'
17import { readNote } from '../core/flush'
18import { withToastGate } from '../core/io'
19import { homeFrom, lastPart, under } from '../core/platform'
20import type { Io } from '../core/io'
21import { UNPROVEN_WHAT, readState, stateKey } from '../core/proof'
22import { ANOMALY_WHAT, DRIFT_WHAT, readState2, state2Key } from '../core/proof2'
23import { DROP_MS, listRecords, readRecord } from '../core/record'
24import type { SessionRecord } from '../core/record'
25import { readSetting } from '../core/settings'
26import { didToday, startOfDay } from '../core/ledger'
27import { LEDGER_KEY, pushLog, readLog, setAutoSaver, why } from './compaction'
28import { CHECKPOINT_NAME, briefName, briefPath, briefShown } from '../core/autosafe'
29import { makeCheckpoint } from '../core/undo'
30import { checkWrite, refusedWords, safeWrite } from '../core/safewrite'
31import type { SafeFs } from '../core/safewrite'
32import type { Probe } from '../core/undo'
33import { asksYou } from './home/logic'
34import { subArgs, isFolded } from '../core/menu'
35
36// IO-BLOCK-START
37function io($: EngineInterface): Io {
38 return withToastGate({
39 now: () => $.clock.now(),
40 home: () => homeFrom({ HOME: () => $.env.get('HOME'), USERPROFILE: () => $.env.get('USERPROFILE'), HOMEDRIVE: () => $.env.get('HOMEDRIVE'), HOMEPATH: () => $.env.get('HOMEPATH') }),
41 sessionId: () => $.session.id(),
42 get: key => $.store.get(key),
43 set: (key, value) => $.store.set(key, value),
44 del: key => $.store.delete(key),
45 keys: () => $.store.keys(),
46 read: path => $.fs.read(path),
47 write: (path, text) => $.fs.write(path, text),
48 run: (argv, init) => $.process.run(argv, init),
49 ask: (question, options) => $.ui.ask(question, options),
50 complete: req => $.model.complete(req),
51 toast: text => $.ui.toast(text),
52 invalidate: () => $.ui.invalidate('ui.render'),
53 })
54}
55// IO-BLOCK-END
56
57const GIT_TIMEOUT_MS = 4_000
58const LIST_LIMIT = 100
59const SEARCH_TOOL = 'search_session_transcripts'
60
61// The reply when the block is off, else undefined.
62const offText = async (i: Io): Promise<string | undefined> => {
63 return (await readSetting(i, 'brief.mode')) === 'off' ? OFF_TEXT : undefined
64}
65const failText = (err: unknown): string => `That did not work: ${why(err)}`
66
67type Called = { ok: true; text: string; blocks: number } | { ok: false; why: string }
68
69// Never throws.
70async function callApp($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<Called> {
71 try {
72 noteCall()
73 const r = await $.mcp.call(USAGE_SERVER, tool, args)
74 const texts = r.content.filter(c => c.type === 'text').map(c => (c as { text?: string }).text ?? '')
75 const text = texts.join('\n')
76 if (r.isError === true) return { ok: false, why: text.slice(0, 160) || 'the tool reported an error' } // en-only: plain answer of a slash command (text twin)
77 return { ok: true, text, blocks: r.content.length }
78 } catch {
79 // The engine's own error ("no implementation for ...") means nothing to a person: the tool is not there in this kind of session.
80 return { ok: false, why: 'It is not offered in this kind of session.' } // en-only: plain answer of a slash command (text twin)
81 }
82}
83
84// The app's session list, fresh if the tool answers, else the copy the Home pane keeps (with how old it is).
85async function sessionList($: EngineInterface, i: Io, archived: boolean): Promise<{ list: AppSessions; fresh: boolean } | undefined> {
86 const r = await callApp($, SESSIONS_TOOL, { limit: LIST_LIMIT, ...(archived ? { include_archived: true } : {}) })
87 if (r.ok) {
88 const list = parseSessions(r.text, await i.now())
89 if (list !== undefined) return { list, fresh: true }
90 }
91 const old = parseStoredSessions(await i.get(APP_SESSIONS_KEY))
92 return old === undefined ? undefined : { list: old, fresh: false }
93}
94
95const gitRun = async (i: Io, argv: string[]): Promise<string | undefined> => {
96 try {
97 const r = await i.run(argv, { timeoutMs: GIT_TIMEOUT_MS })
98 return r.exitCode === 0 ? r.stdout : undefined
99 } catch {
100 return undefined
101 }
102}
103
104const freshRecord = (rec: SessionRecord | undefined, now: number): SessionRecord | undefined => (rec !== undefined && now - rec.updatedAt < DROP_MS ? rec : undefined)
105
106type PrKept = { at?: number; branch?: string; none?: boolean; view?: { number?: number; state?: string; isDraft?: boolean; url?: string } }
107
108async function briefFacts($: EngineInterface, i: Io): Promise<BriefFacts> {
109 const cwd = await $.session.cwd()
110 return briefFactsAt(i, cwd)
111}
112
113export async function briefFactsAt(i: Io, cwd: string): Promise<BriefFacts> {
114 const sid = await i.sessionId()
115 const now = await i.now()
116 const [branchOut, statusOut] = await Promise.all([gitRun(i, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']), gitRun(i, ['git', '-c', 'core.quotepath=off', 'status', '--short'])])
117 const gitBranch = branchOut === undefined ? undefined : parseBranch(branchOut)
118 const gitFiles: GitFile[] | undefined = statusOut === undefined ? undefined : parseGitStatus(statusOut)
119 const proof = readState(await readNote(i, stateKey(sid)))
120 const proof2 = readState2(await readNote(i, state2Key(sid)))
121 const rec = freshRecord(await readRecord(i, sid), now)
122 const kept = (await i.get(`cockpit:pr:state:${sid}`)) as PrKept | undefined
123 let pr: BriefFacts['pr']
124 if (typeof kept === 'object' && kept !== null && !(kept.branch !== undefined && gitBranch !== undefined && kept.branch !== gitBranch)) {
125 const v = kept.view
126 if (v !== undefined && typeof v.number === 'number') pr = { number: v.number, state: typeof v.state === 'string' ? v.state : 'open', isDraft: v.isDraft === true, url: typeof v.url === 'string' ? v.url : '', at: typeof kept.at === 'number' ? kept.at : now }
127 else if (kept.none === true) pr = 'none'
128 }
129 return {
130 now,
131 cwd,
132 proof,
133 edited: Object.keys(proof2.files).map(p => relativeTo(cwd, p)),
134 ...(gitBranch === undefined ? {} : { gitBranch }),
135 ...(gitFiles === undefined ? {} : { gitFiles }),
136 ...(rec?.branch === undefined ? {} : { recordBranch: rec.branch }),
137 ...(rec?.ask?.kind === 'question' ? { askingSince: rec.ask.since } : {}),
138 ...(pr === undefined ? {} : { pr }),
139 }
140}
141
142export async function briefMarks(i: Io, cwd: string): Promise<ReturnType<typeof briefMarkLines>> {
143 return briefMarkLines(await briefFactsAt(i, cwd))
144}
145
146const folderName = (path: string): string => lastPart(path)
147
148// ---- Before Auto compacts the chat (core/autosafe.ts) ---- Saves the handoff note (the packet's text) in vaken's own folder under HOME
149export async function saveBeforeAuto(i: Io, sessionId: string, cwd: string, probe: Probe): Promise<{ ok: true; name: string; shown: string; checkpoint: boolean } | { ok: false }> {
150 try {
151 const facts = await briefFactsAt(i, cwd)
152 const home = await i.home()
153 await i.write(briefPath(home, sessionId), renderPacket(facts, folderName(facts.cwd)))
154 let checkpoint = false
155 try {
156 checkpoint = (await makeCheckpoint(i, probe, sessionId, CHECKPOINT_NAME)).ok
157 } catch {
158 checkpoint = false
159 }
160 return { ok: true, name: briefName(sessionId), shown: briefShown(sessionId, home), checkpoint }
161 } catch {
162 return { ok: false }
163 }
164}
165
166// The engine's file calls a safe write needs (core/safewrite.ts).
167function safeFs($: EngineInterface): SafeFs {
168 return { stat: (path, options) => $.fs.stat(path, options), list: path => $.fs.list(path) }
169}
170
171async function packetText($: EngineInterface, i: Io): Promise<string> {
172 const facts = await briefFacts($, i)
173 const path = under(facts.cwd, PACKET_PATH)
174 // A link at the packet, or at the .claude folder, is never followed.
175 const fs = safeFs($)
176 if (!(await checkWrite(fs, facts.cwd, path)).ok) return refusedWords(path, tr)
177 let exists = false
178 try {
179 exists = (await i.read(path)) !== ''
180 } catch {
181 exists = false
182 }
183 let answer: string
184 const buttons = { yes: t('engines.packet_write_it'), no: t('engines.packet_do_not_write') }
185 try {
186 answer = await i.ask(fillIn(tr(exists ? 'Write the session packet to {path}? It replaces the file that is there.' : 'Write the session packet to {path}?'), { path }), [buttons.yes, buttons.no])
187 } catch {
188 return 'Nothing was written: the question was not answered.' // en-only: plain answer of a slash command (text twin)
189 }
190 if (!saidYes(answer, buttons)) return 'Nothing was written.' // en-only: plain answer of a slash command (text twin)
191 const text = renderPacket(facts, folderName(facts.cwd))
192 if (!(await safeWrite(fs, (p, x) => i.write(p, x), facts.cwd, path, text)).ok) return refusedWords(path, tr)
193 const lines = text.split('\n').length
194 await pushLog(i, LEDGER_KEY, { at: await i.now(), session: await i.sessionId(), what: PACKET_WHAT, detail: `${PACKET_PATH}, ${lines} lines`, how: 'typed', did: true })
195 i.toast(tr('Session packet written.'))
196 i.invalidate()
197 return [`Wrote ${path} (${lines} lines).`, 'The next session can read it first. It holds counts, names, paths and your goal (key words of your first message, or the sentence you set; secrets hidden), and no other conversation text.', `Git will show it as a new file. Add ${PACKET_PATH} to .gitignore if you do not want to commit it.`].join('\n') // en-only: plain answer of a slash command (text twin)
198}
199
200async function timelineFacts(i: Io): Promise<TimelineFacts> {
201 const sid = await i.sessionId()
202 const rec = await readRecord(i, sid)
203 return {
204 ...(rec === undefined ? {} : { rec }),
205 proof: readState(await readNote(i, stateKey(sid))),
206 proof2: readState2(await readNote(i, state2Key(sid))),
207 cmds: parseEntries(await readNote(i, commandsKey(sid))),
208 ledger: (await readLog(i, LEDGER_KEY)).filter(x => x.session === sid),
209 }
210}
211
212async function storyText(i: Io): Promise<string> {
213 const sid = await i.sessionId()
214 const now = await i.now()
215 const proof = readState(await readNote(i, stateKey(sid)))
216 const mine = (await readLog(i, LEDGER_KEY)).filter(x => x.session === sid)
217 const rec = freshRecord(await readRecord(i, sid), now)
218 return storyLines({
219 proof,
220 anomalies: mine.filter(x => x.what === ANOMALY_WHAT || x.what === DRIFT_WHAT).length,
221 unproven: mine.filter(x => x.what === UNPROVEN_WHAT).length,
222 ...(rec?.ask?.kind === 'question' ? { askingSince: rec.ask.since } : {}),
223 }).join('\n')
224}
225
226async function standupText($: EngineInterface, i: Io): Promise<string> {
227 const now = await i.now()
228 const day = startOfDay(now)
229 const app = await sessionList($, i, false)
230 const records = new Map((await listRecords(i)).map(r => [r.sessionId, r]))
231 const prefix = stateKey('')
232 const sessions: DaySession[] = []
233 for (const key of (await i.keys()).filter(k => k.startsWith(prefix))) {
234 const sid = key.slice(prefix.length)
235 const p = readState(await i.get(key))
236 const rec = records.get(sid) ?? (await readRecord(i, sid))
237 if (lastWork(p, rec) < day || (p.edits === 0 && p.receipts.length === 0)) continue
238 const p2 = readState2(await readNote(i, state2Key(sid)))
239 const todays = p2.changes.filter(c => c.at >= day)
240 const files = rec !== undefined && rec.startedAt >= day ? p.files.length : todays.reduce((n, c) => n + c.files, 0)
241 const match: AppSession | undefined = rec === undefined ? undefined : matchSession(app?.list, { sessionId: sid, cwd: rec.cwd })
242 const name = match?.title || rec?.title || (rec === undefined ? sid.slice(0, 8) : folderName(rec.cwd))
243 sessions.push({ name, ...(p.goal === undefined ? {} : { goal: p.goal.text }), files, edits: p.edits, debt: p.debt, asking: rec !== undefined && freshRecord(rec, now) !== undefined && asksYou(rec, now) })
244 }
245 let usd = 0
246 let costSessions = 0
247 const costPrefix = `cockpit:cost:day:${ymd(now)}:`
248 for (const k of (await i.keys()).filter(x => x.startsWith(costPrefix))) {
249 const v = (await i.get(k)) as { usd?: unknown } | undefined
250 if (typeof v?.usd === 'number') {
251 usd += v.usd
252 costSessions += 1
253 }
254 }
255 const actions = didToday(await readLog(i, LEDGER_KEY), now).reduce((n, d) => n + d.n, 0)
256 if (sessions.length === 0) return `Nothing to report yet today: no session has changed a file or run a check. Vaken acted ${actions} times.`
257 return [...standupLines({ now, sessions, cockpitActions: actions, ...(costSessions === 0 ? {} : { costUsd: usd }), costSessions }), ...(app === undefined || app.fresh ? [] : ['(Session names may be old: the app did not answer.)'])].join('\n')
258}
259
260async function resumeText_($: EngineInterface, i: Io, words: string): Promise<string> {
261 const got = await sessionList($, i, false)
262 if (got === undefined) return NOT_AVAILABLE('session list', 'Open the sidebar and look for the session by hand.') // en-only: plain answer of a slash command (text twin)
263 const rows = words === '' ? [...got.list.sessions].sort((a, b) => (Date.parse(b.lastActivityAt ?? '') || 0) - (Date.parse(a.lastActivityAt ?? '') || 0)) : matchWords(got.list.sessions, words)
264 return resumeText(words, rows, await i.now(), got.fresh ? "the app's session list" : 'the last list vaken kept (the app did not answer)') // en-only: plain answer of a slash command (text twin)
265}
266
267async function findAnswer($: EngineInterface, i: Io, query: string): Promise<string> {
268 if (query.length < FIND_MIN) return `Type at least ${FIND_MIN} characters: /vaken-resume find <words>. It searches what was said in your other sessions.`
269 const r = await callApp($, SEARCH_TOOL, { query, limit: FIND_LIMIT })
270 if (!r.ok) return NOT_AVAILABLE('search tool', r.why) // en-only: plain answer of a slash command (text twin)
271 const hits = parseHits(r.text)
272 if (hits === undefined) return NOT_AVAILABLE('search tool', 'It answered in a form vaken does not know.') // en-only: plain answer of a slash command (text twin)
273 const links = new Map<string, string>()
274 if (hits.length > 0) {
275 const got = await sessionList($, i, true)
276 for (const s of got?.list.sessions ?? []) if (s.link !== undefined) links.set(s.id, s.link)
277 }
278 return findText(query, hits, links, await i.now())
279}
280
281async function briefCommand($: EngineInterface, i: Io): Promise<string> {
282 try {
283 return (await offText(i)) ?? renderBrief(await briefFacts($, i)).join('\n')
284 } catch (err) {
285 return failText(err)
286 }
287}
288
289async function packetCommand($: EngineInterface, i: Io): Promise<string> {
290 try {
291 return (await offText(i)) ?? (await packetText($, i))
292 } catch (err) {
293 return failText(err)
294 }
295}
296
297async function resumeCommand($: EngineInterface, i: Io, args: string): Promise<string> {
298 try {
299 return (await offText(i)) ?? (await resumeText_($, i, args))
300 } catch (err) {
301 return failText(err)
302 }
303}
304
305async function findCommand($: EngineInterface, i: Io, args: string): Promise<string> {
306 try {
307 return (await offText(i)) ?? (await findAnswer($, i, args))
308 } catch (err) {
309 return failText(err)
310 }
311}
312
313async function standupCommand($: EngineInterface, i: Io): Promise<string> {
314 try {
315 return (await offText(i)) ?? (await standupText($, i))
316 } catch (err) {
317 return failText(err)
318 }
319}
320
321async function storyCommand(i: Io): Promise<string> {
322 try {
323 return (await offText(i)) ?? (await storyText(i))
324 } catch (err) {
325 return failText(err)
326 }
327}
328
329async function timelineCommand(i: Io, args: string): Promise<string> {
330 try {
331 const off = await offText(i)
332 if (off !== undefined) return off
333 const n = parseCount(args)
334 if (n === undefined) return 'Type a number: /vaken-resume timeline 20 shows the last 20 events.' // en-only: plain answer of a slash command (text twin)
335 return timelineText(await timelineFacts(i), n, await i.now())
336 } catch (err) {
337 return failText(err)
338 }
339}
340
341export function registerContinuity(on: On): void {
342 setAutoSaver(saveBeforeAuto)
343 on('session.start', { cwd: /(?<continuity>)/ }, async ($, e, next) => {
344 try {
345 await $.command.register({ name: 'vaken-resume', description: 'Find a session by words in its title or folder. A word after it runs a part: find, timeline.', argumentHint: 'find | timeline | <words>', immediate: true })
346 } catch {
347 // the commands are optional: a failure shows less, never breaks the session
348 }
349 return next(e)
350 }).catch(($, e, next) => next(e))
351
352 on('command.run', { command: 'vaken-help' }, async ($, e, next) => {
353 const rest0 = subArgs(e.args, 'run', 'brief')
354 if (rest0 === undefined) return next(e)
355 return { text: await briefCommand($, io($)) }
356 })
357 on('command.run', { command: 'vaken-help' }, async ($, e, next) => {
358 const rest0 = subArgs(e.args, 'run', 'packet')
359 if (rest0 === undefined) return next(e)
360 return { text: await packetCommand($, io($)) }
361 })
362 on('command.run', { command: 'vaken-resume' }, async ($, e, next) => {
363 if (isFolded(e.command, e.args)) return next(e)
364 return { text: await resumeCommand($, io($), e.args.trim()) }
365 })
366 on('command.run', { command: 'vaken-resume' }, async ($, e, next) => {
367 const rest0 = subArgs(e.args, 'find')
368 if (rest0 === undefined) return next(e)
369 return { text: await findCommand($, io($), rest0.trim()) }
370 })
371 on('command.run', { command: 'vaken-today' }, async ($, e, next) => {
372 const rest0 = subArgs(e.args, 'standup')
373 if (rest0 === undefined) return next(e)
374 return { text: await standupCommand($, io($)) }
375 })
376 on('command.run', { command: 'vaken-help' }, async ($, e, next) => {
377 const rest0 = subArgs(e.args, 'run', 'story')
378 if (rest0 === undefined) return next(e)
379 return { text: await storyCommand(io($)) }
380 })
381 on('command.run', { command: 'vaken-resume' }, async ($, e, next) => {
382 const rest0 = subArgs(e.args, 'timeline')
383 if (rest0 === undefined) return next(e)
384 return { text: await timelineCommand(io($), rest0.trim()) }
385 })
386}
387