SLOPSHOPPER

vaken

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

new
★ 2v1.119.8MITupdated 2026-10-14johanmatsgard/vaken/mods/cockpit
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (2 | import { moneyEn as money } from './prices' ^ error: No matching export in "slop-empty:/home/runner/work/slopshopper/slopshopper/.cache/src/johanmatsgard-v
README

vaken

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.

What it shows

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.

The safety stop

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.

Install

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

What vaken runs, sends and stores

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.
  • Google Chrome (headless): takes a picture of a page. The tool that Claude can call takes only 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.

  • The plugin store of Claude Code, with keys that start with cockpit:.
  • Files under ~/.claude/cockpit/: copies of changed files for Undo, hand-over notes, and pictures.
  • In your project, only when you ask: .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/.

For reviewers: what the hooks do

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.

  • Assumption review (assumptions.ts, prompt.submit): one line asks Claude to list its assumptions. Setting assume.check, default off.
  • Plan first (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.
  • Prompt review (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.
  • Checks (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 (compaction.ts, session.compact): your goal, branch, changed files and next steps are added to the compaction instructions. Setting compact.card, default on.
  • Guard (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.

  • A 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.
  • The guard (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.
  • "Allow once" approves nothing itself. Pressing it, or typing /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).

  • Helper agents start ($.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.
  • A prompt is submitted ($.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.

More

License: MIT.

Source 90 files
hooks/register.tsx 118 lines
1// 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}
118
hooks/features/agentcap.ts 149 lines
1// 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}
149
hooks/features/away.ts 256 lines
1// 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}
256
hooks/features/attention.ts 149 lines
1// 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}
149
hooks/features/cachekeeper.ts 168 lines
1// 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}
168
hooks/features/calm.ts 224 lines
1// 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}
224
hooks/features/compaction.ts 519 lines
1// 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}
519
hooks/features/cost.ts 330 lines
1// 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}
330
hooks/features/insight.ts 203 lines
1// 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}
203
hooks/features/keeper.ts 186 lines
1// 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}
186
hooks/features/inbox.ts 328 lines
1// 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}
328
hooks/features/continuity.ts 387 lines
1// 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