Agent traces become reflexes: Adage compiles the paths your agents take into reflexes and tells them, deterministically, what to do next.

The plugin Claude Code installs: the command hooks (hooks/hooks.json, run through the session-start shim scripts/session-start, which links bin/adage, bin/adage-exec and bin/adage-train, and bin/hook to adage or adage-exec) and the mod, a hooks module of function hooks (hooks/register.ts) that bridges Claude Code to the binary and draws what the binary says. The binary decides; the mod never does (architecture.md). Built and tested against Claude Code 2.1.288, CI's pin (hooks/plugin.ts TESTED).
npm install -g @adage-sh/cli, then run adage init in your repository to install this plugin. It stages it from the adage you run (these files are carried inside the binary) as a one-plugin local marketplace under $ADAGE_HOME/plugin/, with adage, adage-exec and adage-train and their dist/SHA256SUMS, then runs claude plugin marketplace add <that directory> and claude plugin install adage@adage-local. Claude Code reads it in place, so adage update (or adage init again) stages the new binaries and modules there and the next session runs them. The mod discovers the plugin's data directory automatically after the session-start shim creates its binary links; no CLAUDE_PLUGIN_DATA export is normally needed. It stays inactive if the binary is missing or does not answer its version check./plugin marketplace add stevenvaughanbooth/adage, then /plugin install adage@adage, with no terminal. Claude Code updates it from that marketplace; adage update run from the plugin's own binary asks it to (claude plugin update adage@adage), or names the steps: /plugin, choose adage, then Update.plugin/build.sh stages this machine's three binaries into dist/, and claude --plugin-dir plugin/claude-code loads this folder for one session (disable an installed adage@adage-local first, or every hook runs twice). The mod discovers the inline plugin's data directory after the session-start shim creates its binary links.claude plugin uninstall adage@adage-local (or adage@adage), which removes its data directory too, then claude plugin marketplace remove adage-local.Whichever route, the shim links the binaries the same way: ${CLAUDE_PLUGIN_DATA}/bin/adage is a stable link to the verified copy adage-<sha256> beside it, and so are bin/adage-exec and bin/adage-train. A Codex or OpenCode registration written by this plugin's adage names that stable link, not the digest-named copy, so an update of the plugin carries it. There is one plugin: the marketplace lists adage alone.
adage (it decides; the mod runs its verbs), adage-exec (it acts once execution is on; not on Windows) and adage-train (adage train hands over to it). The marketplace lists this plugin alone. plugin/build.sh stages this machine's three into dist/; scripts/stage-plugin.mjs stages every target's, with this folder, onto the marketplace branch, and scripts/test-stage-plugin.mjs checks every binary and module file is staged.hooks/bridge.ts ALLOWED_VERBS) and the person-only verbs only through a press (hooks/press.ts); every hook fails open (it answers what next(e) answered).types/index.d.ts is the $.state contract (plugin.json types); userConfig has two rows, band and pane.hooks/register.ts is the only file that touches $: the state table's atoms, mod($, options) (the capability object) and one on(...) line per hook, each delegating to the module that does the work. Every other module takes a Mod and never sees $. The full map, with what each file does, is in mod-structure.md; in short:
| Concern | Modules |
|---|---|
| The bridge to the binary | bridge.ts, binary.ts, press.ts, reads.ts, refresh.ts |
| The session and the feed | session.ts, feed.ts, follower.ts, notify.ts |
| Observing and deciding at the hooks | observe.ts, marks.ts, dag.ts, reflex.ts, step.ts, predict.ts, ledger.ts, call.ts, receipt.ts, substitute.ts, attach.ts, skill.ts, notice.ts |
| The person's decisions | dialog.ts, ask.ts, token.ts, kept.ts, allow.ts, grant.ts, commands.ts, actions.ts |
| What is drawn | view/{text,lines,band,pane,card,graph}.ts (the words; graph.ts lays out the Graph tab), draw/sites.ts (one function per site), draw/{rows,band,card,pane,graph,allow}.tsx (the trees), tokens.ts (generated) |
| Shared | mod.ts (the Mod), plugin.ts (constants and the tables), shape.ts (reading JSON) |
mod.ts Mod = Caps & { rt, options }. Caps is one closure per engine call the mod makes: state.<key>.read() / .update(f), store.<key>.get() / .set(v), the guarded runners run, spawn, pressRun, env.<name>(), plugin.root(), fs.read, command.register / list, session.id / cwd / repo / usage / version, clock.now / every / after, checkBash, and ui.status / toast / notice / open / close / ask / copy / scroll / invalidate / resolve. rt is the runtime one load keeps between hooks (runtime()); options the plugin's userConfig.
$.state (plugin adage), declared in types/index.d.ts, initialised in plugin.ts STATE0, an atom and a cell each in register.ts: fired, predicted, marks, batches, feed, rows, ticks, health, learner, reflexes, checks, lapsed, packs, mode, view, map, turn, press, asked, allow, skills, candidates. Every value is a copy of what the binary said or of what the person did in the pane.
$.store (across sessions), plugin.ts STORE_KEYS, as adage.<key>: tab (the last tab), harness (the version the version line was last shown for), asked (the asks, one per reflex per day), kept (the answers kept for the next session start; asked again, never pressed as they stand).
register.ts's hook builds the Mod and calls <module>.on<Name>(mod($, options), e, next).bin/hook hook claude <Event> with the payload on stdin (binary.ts askBinary), bin/adage <verb> --json for what is drawn (refresh.ts), adage watch --follow --json for the feed (follower.ts), a person-only verb with the press token on stdin (token.ts pressCommand).$.state, and each site draws from it (draw/sites.ts).Where each kind of change goes, one place and a test, is the table at the top of mod-structure.md.
The rules claude plugin validate --strict holds the module to (tested shape by shape in mod-structure.md): $ is followed only into functions declared in the file that registers the hooks, never across an import, and is always spelled $.noun.event(...) at the call (never a noun or an event as a value); a state reference is a literal atom of that file; an environment variable a literal name; an event registered twice needs a matcher on the second. Hence everything that needs $ is in register.ts, and nothing else is.
Tests, from the repository root:
claude plugin test plugin/claude-code # every hooks/**/*.test.ts, each file in its own child
claude plugin validate --strict plugin/claude-code # and . (the marketplace)
claude plugin validate --strict plugin/claude-code 2>&1 | sed "s#$PWD/##g" | diff plugin/claude-code/VALIDATE.txt -
node scripts/check-plugin.mjs # the tables, the hooks' order, the allow-lists, the layout
node scripts/test-check-plugin.mjs # check-plugin refuses a second plugin, an on-PATH description
node scripts/test-stage-plugin.mjs # the release staging: three binaries per target, checksums
plugin/test/session-start.test.sh # the shim (needs target/debug/{adage,adage-exec,adage-train})
A test sits beside its module as <module>.test.ts, names its test-plan id in its describe (or top-level test) title, and takes every fixture from hooks/fixtures/kit.ts: world beneath the plugin when the engine loads the module, fake for a Mod in memory. scripts/check-plugin.mjs fails a module without its test file, a title without an id, and a test file that stubs the engine itself. After a hook is added or moved, regenerate VALIDATE.txt with the sed line above (CI diffs it). Typecheck with tsc (strict, noUncheckedIndexedAccess) over hooks/ and types/ with the declaration file Claude Code writes for the pinned version.
hooks/register.ts 170 lines1// The adage mod's registration (plan B1–B4, C3–C9, D5; docs/claude-mod/impl/mod-structure.md).
2//
3// `claude plugin validate` follows `$` only into functions declared in this file and refuses `$`,
4// or any noun of it, passed across an import; it reads a state reference only as literals in a
5// const of this file, and an environment variable only by a literal name. So this file holds the
6// three things that need `$` and nothing else: the state table's atoms, `mod($, options)` (every
7// call the mod makes, as a closure over `$`), and the `on(...)` lines, one per hook in a fixed
8// order (registrations nest first outermost, so the order is behaviour), each delegating to the
9// module that does the work. Everything else is in modules that take a `Mod` and never see `$`.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, ProcessSpawnRequest, Register } from 'claude-code'
12
13import * as actions from './actions'
14import * as attach from './attach'
15import { guard, guardSpawn } from './bridge'
16import * as call from './call'
17import * as commands from './commands'
18import * as dialog from './dialog'
19import type { Els } from './draw/els'
20import * as sites from './draw/sites'
21import * as ledger from './ledger'
22import type { Mod, Options } from './mod'
23import { runtime } from './mod'
24import * as observe from './observe'
25import { STATE0 } from './plugin'
26import { guardPress } from './press'
27import * as session from './session'
28import * as skill from './skill'
29import * as step from './step'
30
31// The state table (plugin.ts `STATE0`, types/index.d.ts): one atom per `$.state` key.
32const FIRED = atom({ plugin: 'adage', key: 'fired' } as const, STATE0.fired)
33const PREDICTED = atom({ plugin: 'adage', key: 'predicted' } as const, STATE0.predicted)
34const MARKS = atom({ plugin: 'adage', key: 'marks' } as const, STATE0.marks)
35const BATCHES = atom({ plugin: 'adage', key: 'batches' } as const, STATE0.batches)
36const FEED = atom({ plugin: 'adage', key: 'feed' } as const, STATE0.feed)
37const ROWS = atom({ plugin: 'adage', key: 'rows' } as const, STATE0.rows)
38const TICKS = atom({ plugin: 'adage', key: 'ticks' } as const, STATE0.ticks)
39const HEALTH = atom({ plugin: 'adage', key: 'health' } as const, STATE0.health)
40const LEARNER = atom({ plugin: 'adage', key: 'learner' } as const, STATE0.learner)
41const REFLEXES = atom({ plugin: 'adage', key: 'reflexes' } as const, STATE0.reflexes)
42const CHECKS = atom({ plugin: 'adage', key: 'checks' } as const, STATE0.checks)
43const LAPSED = atom({ plugin: 'adage', key: 'lapsed' } as const, STATE0.lapsed)
44const PACKS = atom({ plugin: 'adage', key: 'packs' } as const, STATE0.packs)
45const MODE = atom({ plugin: 'adage', key: 'mode' } as const, STATE0.mode)
46const VIEW = atom({ plugin: 'adage', key: 'view' } as const, STATE0.view)
47const MAP = atom({ plugin: 'adage', key: 'map' } as const, STATE0.map)
48const TURN = atom({ plugin: 'adage', key: 'turn' } as const, STATE0.turn)
49const PRESS = atom({ plugin: 'adage', key: 'press' } as const, STATE0.press)
50const ASKED = atom({ plugin: 'adage', key: 'asked' } as const, STATE0.asked)
51const ALLOW = atom({ plugin: 'adage', key: 'allow' } as const, STATE0.allow)
52const SKILLS = atom({ plugin: 'adage', key: 'skills' } as const, STATE0.skills)
53const CANDIDATES = atom({ plugin: 'adage', key: 'candidates' } as const, STATE0.candidates)
54const DAG = atom({ plugin: 'adage', key: 'dag' } as const, STATE0.dag)
55
56/** What one load of the module keeps between hooks (mod.ts `Runtime`). */
57const rt = runtime()
58
59/** The capabilities over this `$`, the runtime and the options: what every hook function is handed. */
60function mod($: EngineInterface, options: Options): Mod {
61 return {
62 rt,
63 options,
64 state: {
65 fired: { read: () => read($, FIRED), update: f => update($, FIRED, f) },
66 predicted: { read: () => read($, PREDICTED), update: f => update($, PREDICTED, f) },
67 marks: { read: () => read($, MARKS), update: f => update($, MARKS, f) },
68 batches: { read: () => read($, BATCHES), update: f => update($, BATCHES, f) },
69 feed: { read: () => read($, FEED), update: f => update($, FEED, f) },
70 rows: { read: () => read($, ROWS), update: f => update($, ROWS, f) },
71 ticks: { read: () => read($, TICKS), update: f => update($, TICKS, f) },
72 health: { read: () => read($, HEALTH), update: f => update($, HEALTH, f) },
73 learner: { read: () => read($, LEARNER), update: f => update($, LEARNER, f) },
74 reflexes: { read: () => read($, REFLEXES), update: f => update($, REFLEXES, f) },
75 checks: { read: () => read($, CHECKS), update: f => update($, CHECKS, f) },
76 lapsed: { read: () => read($, LAPSED), update: f => update($, LAPSED, f) },
77 packs: { read: () => read($, PACKS), update: f => update($, PACKS, f) },
78 mode: { read: () => read($, MODE), update: f => update($, MODE, f) },
79 view: { read: () => read($, VIEW), update: f => update($, VIEW, f) },
80 map: { read: () => read($, MAP), update: f => update($, MAP, f) },
81 turn: { read: () => read($, TURN), update: f => update($, TURN, f) },
82 press: { read: () => read($, PRESS), update: f => update($, PRESS, f) },
83 asked: { read: () => read($, ASKED), update: f => update($, ASKED, f) },
84 allow: { read: () => read($, ALLOW), update: f => update($, ALLOW, f) },
85 skills: { read: () => read($, SKILLS), update: f => update($, SKILLS, f) },
86 candidates: { read: () => read($, CANDIDATES), update: f => update($, CANDIDATES, f) },
87 dag: { read: () => read($, DAG), update: f => update($, DAG, f) },
88 },
89 // The store table (plugin.ts `STORE_KEYS`): one cell per `$.store` key.
90 store: {
91 tab: { get: () => $.store.get('adage.tab'), set: v => $.store.set('adage.tab', v) },
92 harness: { get: () => $.store.get('adage.harness'), set: v => $.store.set('adage.harness', v) },
93 asked: { get: () => $.store.get('adage.asked'), set: v => $.store.set('adage.asked', v) },
94 kept: { get: () => $.store.get('adage.kept'), set: v => $.store.set('adage.kept', v) },
95 latest: { get: () => $.store.get('adage.latest'), set: v => $.store.set('adage.latest', v) },
96 },
97 // The only ways to a process, each behind its allow-list and CLAUDECODE=1.
98 run: guard((argv, init) => $.process.run(argv, init)),
99 spawn: guardSpawn((request: ProcessSpawnRequest) => $.process.spawn(request)),
100 pressRun: guardPress((argv, init) => $.process.run(argv, init)),
101 env: {
102 pluginData: () => $.env.get('CLAUDE_PLUGIN_DATA'),
103 configDir: () => $.env.get('CLAUDE_CONFIG_DIR'),
104 home: () => $.env.get('HOME'),
105 userProfile: () => $.env.get('USERPROFILE'),
106 observe: () => $.env.get('ADAGE_MOD_OBSERVE'),
107 ascii: () => $.env.get('ADAGE_ASCII'),
108 reducedMotion: () => $.env.get('ADAGE_REDUCED_MOTION'),
109 os: () => $.env.get('OS'),
110 },
111 plugin: { root: () => $.plugin.root, name: () => $.plugin.name },
112 fs: { read: path => $.fs.read(path), exists: path => $.fs.exists(path) },
113 command: { register: c => $.command.register(c), list: () => $.command.list() },
114 session: { id: () => $.session.id(), cwd: () => $.session.cwd(), repo: () => $.session.repo(), usage: a => $.session.usage(a), version: () => $.session.version() },
115 clock: { now: () => $.clock.now(), every: (ms, fn) => $.clock.every(ms, fn), after: (ms, fn) => $.clock.after(ms, fn) },
116 checkBash: async command => (await $.tool.check({ tool: 'Bash', input: { command } })).decision,
117 checkTool: (tool, input) => $.tool.check({ tool, input }),
118 ui: {
119 status: text => $.ui.status(text),
120 toast: text => $.ui.toast(text),
121 notice: (id, text) => $.ui.notice(id, text),
122 open: pane => $.ui.open(pane),
123 close: pane => $.ui.close(pane),
124 ask: (question, answers) => $.ui.ask(question, answers),
125 copy: args => $.ui.copy(args),
126 scroll: args => $.ui.scroll(args),
127 invalidate: () => $.ui.invalidate('ui.render'),
128 resolve: e => $.ui.resolve(e) as Els,
129 },
130 }
131}
132
133export const register: Register = (on, options) => {
134 on('session.start', async ($, e, next) => session.onStart(mod($, options), e, next))
135 on('classic.SessionStart', async ($, e, next) => session.onShimStart(mod($, options), e, next))
136 on('classic.UserPromptSubmit', async ($, e, next) => observe.onPrompt(mod($, options), e, next))
137 on('classic.PostToolBatch', async ($, e, next) => observe.onToolBatch(mod($, options), e, next))
138 on('classic.PostToolUseFailure', async ($, e, next) => observe.onToolFailure(mod($, options), e, next))
139 on('classic.Stop', async ($, e, next) => observe.onStop(mod($, options), e, next))
140 on('classic.PreToolUse', async ($, e, next) => observe.onPreToolUse(mod($, options), e, next))
141 on('turn.step', async function* ($, e, next) { return yield* step.onStep(mod($, options), e, next) })
142 on('turn.complete', async ($, e, next) => ledger.onTurnEnd(mod($, options), e, next))
143 on('tool.call', { tool: 'Bash' }, async ($, e, next) => call.onBashCall(mod($, options), e, next))
144 on('tool.call', async ($, e, next) => call.onPredictedCall(mod($, options), e, next))
145 on('prompt.compose', async ($, e, next) => attach.onCompose(mod($, options), e, next))
146 on('tool.describe', { tool: 'Bash' }, async ($, e, next) => attach.onDescribe(mod($, options), e, next))
147 on('session.compact', async ($, e, next) => attach.onCompact(mod($, options), e, next))
148 on('agent.spawn', async ($, e, next) => attach.onSpawn(mod($, options), e, next))
149 on('command.run', { command: 'adage' }, async ($, e) => commands.onCommand(mod($, options), e))
150 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => sites.onInfoNotice(mod($, options), e, next))
151 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => sites.onUserMessage(mod($, options), e, next))
152 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => sites.onToolUse(mod($, options), e, next))
153 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => sites.onToolResult(mod($, options), e, next))
154 on('ui.render', { component: 'Spinner' }, async ($, e, next) => sites.onSpinner(mod($, options), e, next))
155 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => sites.onTurnDuration(mod($, options), e, next))
156 if (options.band !== false) on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => sites.onAbovePrompt(mod($, options), e, next))
157 on('ui.render', { component: 'Pane', requestId: 'adage' }, async ($, e, next) => sites.onPane(mod($, options), e, next))
158 on('ui.render', { component: 'Pane', requestId: 'adage-inspect' }, async ($, e, next) => sites.onInspectPane(mod($, options), e, next))
159 on('ui.render', { component: 'CommandOutput', props: { command: 'adage' } }, async ($, e, next) => sites.onCommandOutput(mod($, options), e, next))
160 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => dialog.onStart(mod($, options), e, next))
161 on('classic.SessionStart', { hook_event_name: 'SessionStart' }, async ($, e, next) => dialog.onShimStart(mod($, options), e, next))
162 on('turn.complete', { reason: 'answer' }, async ($, e, next) => dialog.onTurnEnd(mod($, options), e, next))
163 on('skill.prompt', async ($, e, next) => skill.onSkillPrompt(mod($, options), e, next))
164 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => sites.onToolGroup(mod($, options), e, next))
165 on('ui.render', { component: 'ToolUse', props: { tool: 'Skill' } }, async ($, e, next) => sites.onSkillToolUse(mod($, options), e, next))
166 on('ui.render', { component: 'Pane', requestId: 'adage-allow' }, async ($, e, next) => sites.onAllowPane(mod($, options), e, next))
167 on('ui.open', { id: 'adage' }, async ($, e, next) => actions.onPaneOpen(mod($, options), e, next))
168 on('ui.close', { id: 'adage' }, async ($, e, next) => actions.onPaneClose(mod($, options), e, next))
169}
170hooks/actions.ts 114 lines1// What the person's presses in the pane do, and opening it (docs/claude-mod/ux-design.md §6): the
2// tabs, the filters, the inspect pane, the pause, copying a command, the Health tab's one action
3// (`adage condition`) and `Explore in browser` (`adage map`, started once). None of these is a
4// decision: a decision is a press (dialog.ts, token.ts), which draw/sites.ts adds to these.
5import { say } from './bridge'
6import type { PaneActions } from './draw/pane'
7import { togglePause } from './follower'
8import type { AnswerAt, EventAt, Mod, NextAt } from './mod'
9import { quietly } from './mod'
10import type { View } from './plugin'
11import { INSPECT, MAP0, PANE } from './plugin'
12import { mapUrl } from './reads'
13import { refresh } from './refresh'
14import { visibleRows } from './view/pane'
15
16/** Opens the pane on a tab, focused: the person asked (`/adage …`, ux-design §6). */
17export async function openPane(m: Mod, tab: View['tab']): Promise<void> {
18 await m.state.view.update(v => ({ ...v, tab }))
19 await m.ui.open({ id: PANE, title: 'adage', focus: true, closeOnEscape: true, holdToasts: true, rows: 20 })
20 if (tab !== 'live') quietly(refresh(m, true))
21}
22
23/** `why`: the pane, focused, on that reflex (or pack), with `e enable` and `n not this one`. */
24export async function showReflex(m: Mod, key: string): Promise<void> {
25 await m.state.view.update(v => ({ ...v, tab: 'reflexes' as const, expanded: key, rfilter: '', strength: 'all', lifecycle: 'all' }))
26 await openPane(m, 'reflexes')
27}
28
29/** Copies a command for the person and says so (`toast.copied`, `toast.copy.unavailable`). */
30export async function copy(m: Mod, command: string, on?: string): Promise<boolean> {
31 const target = on === 'terminal' || on === 'desktop' || on === 'vscode' || on === 'mobile' ? on : undefined
32 const done = await m.ui.copy(target === undefined ? { text: command } : { text: command, surface: target }).catch(() => ({ isCopied: false as const }))
33 m.ui.toast(done.isCopied ? `copied: ${command}` : 'copy is not available here · the command is in /adage health')
34 return done.isCopied
35}
36
37/** `Explore in browser` (ux-design §6): starts `adage map` once (it opens the browser) and keeps its address. */
38export async function explore(m: Mod): Promise<string | undefined> {
39 const now = await m.state.map.read()
40 if (now.url !== '') return now.url
41 if (m.rt.cli === undefined || now.starting) return undefined
42 await m.state.map.update(x => ({ ...x, starting: true }))
43 const stream = m.spawn({ argv: [m.rt.cli, 'map', '--port', '0'], cwd: await m.session.cwd() })
44 return new Promise<string | undefined>(resolve => {
45 let said = ''
46 let found = false
47 quietly((async () => {
48 try {
49 for await (const chunk of stream) {
50 said = (said + chunk.text).slice(-4096)
51 const url = found ? undefined : mapUrl(said)
52 if (url !== undefined) {
53 found = true
54 await m.state.map.update(() => ({ url, starting: false }))
55 resolve(url)
56 }
57 }
58 } catch {
59 // It could not start, or it ended: the link goes, and a press starts it again.
60 }
61 if (!found) resolve(undefined)
62 await m.state.map.update(() => MAP0)
63 })())
64 })
65}
66
67/** The pane's handlers (draw/pane.tsx calls them from presses, picks and fields); the presses are added by draw/sites.ts. */
68export function paneActions(m: Mod): Omit<PaneActions, 'press'> {
69 return {
70 tab: tab => {
71 quietly(m.state.view.update(v => ({ ...v, tab })))
72 quietly(m.store.tab.set(tab))
73 },
74 filter: (value, submit) => quietly(m.state.view.update(v => ({ ...v, filter: submit && value.trim() === '' ? '' : value }))),
75 inspect: seq => {
76 quietly((async () => {
77 const view = await m.state.view.read()
78 const rows = await m.state.rows.read()
79 const ascii = (await m.state.mode.read()).ascii
80 // A row the filter removed is never inspected (ux-design §6.1).
81 if (!visibleRows(rows, view.filter, view.hideQuiet, ascii).some(r => r.seq === seq)) return
82 await m.state.view.update(v => ({ ...v, selected: seq }))
83 await m.ui.open({ id: INSPECT, title: 'adage · inspect', focus: true, closeOnEscape: true, rows: 12 })
84 })())
85 },
86 pause: () => quietly(togglePause(m)),
87 quiet: () => quietly(m.state.view.update(v => ({ ...v, hideQuiet: !v.hideQuiet }))),
88 newest: () => quietly(m.ui.scroll({ in: PANE, to: 'start' })),
89 rfilter: value => quietly(m.state.view.update(v => ({ ...v, rfilter: value }))),
90 pick: (which, value) => quietly(m.state.view.update(v => (which === 'strength' ? { ...v, strength: value } : { ...v, lifecycle: value }))),
91 expand: key => quietly(m.state.view.update(v => ({ ...v, expanded: v.expanded === key ? '' : key }))),
92 copy: (command, on) => quietly(copy(m, command, on)),
93 condition: () => {
94 quietly((async () => {
95 await say(m.run, m.rt.cli, ['condition'], await m.session.cwd())
96 await refresh(m, true)
97 })())
98 },
99 explore: () => quietly(explore(m)),
100 history: () => quietly(m.state.view.update(v => ({ ...v, history: !v.history }))),
101 }
102}
103
104/** Whether the pane is open: the band's `1 pattern noticed` cell shows only while it is closed. */
105export async function onPaneOpen(m: Mod, e: EventAt<'ui.open', { id: 'adage' }>, next: NextAt<'ui.open', { id: 'adage' }>): AnswerAt<'ui.open', { id: 'adage' }> {
106 m.rt.paneOpen = true
107 return next(e)
108}
109
110export async function onPaneClose(m: Mod, e: EventAt<'ui.close', { id: 'adage' }>, next: NextAt<'ui.close', { id: 'adage' }>): AnswerAt<'ui.close', { id: 'adage' }> {
111 m.rt.paneOpen = false
112 return next(e)
113}
114hooks/attach.ts 176 lines1// Pure: where Adage's standing text is attached (docs/claude-mod/architecture.md §1b.3, §3.6–§3.8;
2// plan C5, C9). The binary answers `Standing` once per session (`{ standing, environment, compact,
3// conventions }`, and `subagent` when asked about a subagent); the mod places each part where the
4// engine keeps it: the procedures as a `session` section of the system prompt (cached for the
5// session), the environment note and the learned lookup conventions in the Bash tool's
6// description, the compaction instruction at compaction, and a subagent's text before its prompt. Every part is the binary's words; an empty part attaches
7// nothing, and a failed ask is an empty answer. The answer is asked once per session id and kept
8// in the runtime, so every request of the session reads the same text (the prompt cache keeps it).
9import type { PromptComposeSection } from 'claude-code'
10
11import { askBinary } from './binary'
12import type { AnswerAt, EventAt, Mod, NextAt } from './mod'
13import { isRecord, str } from './shape'
14
15/** The session's answer, kept for the session: the same text at every request (the cache holds it). */
16export type Standing = { standing: string; environment: string; compact: string; conventions: string }
17
18const NO_STANDING: Standing = { standing: '', environment: '', compact: '', conventions: '' }
19
20/** The binary's `Standing` payload (C5, C9), exactly. */
21type StandingPayload = {
22 session_id: string
23 cwd: string
24 hook_event_name: 'Standing'
25 via: 'mod'
26 /** Observe mode: the binary answers as for the control arm (only `compact`). */
27 observe?: true
28 /** A subagent about to start (`agent.spawn`): what it is and what it is asked. */
29 agent?: { type: string; description: string; prompt: string }
30 /**
31 * The session's ask: the binary adds `conventions`, one sentence per learned lookup convention
32 * (O0-fixes-notes.md §6); without it the answer is as before.
33 */
34 conventions?: true
35}
36
37/** `Standing` is read-only and asked off the hot path (once a session, once a spawn). */
38const STANDING_TIMEOUT_MS = 2000
39
40/** The section's id: a plugin's own is `<plugin>:<name>` (the engine's rule). */
41export const SECTION_ID = 'adage:procedures'
42
43/** The answer's four session parts; anything missing or malformed is empty. */
44export function standingOf(answer: unknown): Standing {
45 if (!isRecord(answer)) return NO_STANDING
46 return { standing: str(answer.standing), environment: str(answer.environment), compact: str(answer.compact), conventions: str(answer.conventions) }
47}
48
49/** What the Bash tool's description carries: the environment note, then the lookup conventions. */
50export const describedNote = (s: Standing): string => [s.environment, s.conventions].filter(t => t !== '').join('\n\n')
51
52/** What a subagent is told before it starts (the answer's `subagent`), or empty. */
53export const subagentOf = (answer: unknown): string => (isRecord(answer) ? str(answer.subagent) : '')
54
55/**
56 * The system prompt's sections with the procedures appended as one `session` section (after every
57 * `shared` one, as the engine requires), or unchanged when there is none. A section of the same id
58 * already in the list is replaced, never doubled.
59 */
60export function withSection(sections: readonly PromptComposeSection[], standing: string): readonly PromptComposeSection[] {
61 if (standing === '') return sections
62 return [...sections.filter(s => s.id !== SECTION_ID), { id: SECTION_ID, text: standing, scope: 'session' }]
63}
64
65/** The Bash tool's description with the environment note after it, once; unchanged without one. */
66export function withNote(description: string, note: string): string {
67 if (note === '' || description.includes(note)) return description
68 return description === '' ? note : `${description}\n\n${note}`
69}
70
71/** What compaction is told to keep: the person's own words first, then Adage's line, once. */
72export function withInstruction(instructions: string | undefined, compact: string): string | undefined {
73 if (compact === '' || (instructions ?? '').includes(compact)) return instructions
74 return [instructions, compact].filter((s): s is string => s !== undefined && s !== '').join('\n')
75}
76
77/** A subagent's prompt with what it is told before it: the marked blocks, then its task. */
78export const withPreamble = (prompt: string, subagent: string): string => (subagent === '' ? prompt : `${subagent}\n\n${prompt}`)
79
80/**
81 * Whether a transcript row is an attachment Adage's plugin made, by structure: the row's own
82 * `origin` names this plugin. Never by its text (a row quoting `<adage-reflex` is the person's or
83 * the model's own). Claude Code 2.1.288's rows carry no origin, so nothing is removed there (Q5).
84 */
85export function isAdageAttachment(message: unknown): boolean {
86 if (!isRecord(message) || !isRecord(message.origin)) return false
87 const o = message.origin
88 return o.kind === 'plugin' && o.plugin === 'adage'
89}
90
91/** The messages compaction runs over, without Adage's own attachment rows when they are addressable. */
92export function withoutAttachments<M>(messages: readonly M[]): readonly M[] {
93 return messages.some(isAdageAttachment) ? messages.filter(m => !isAdageAttachment(m)) : messages
94}
95
96/** The `Standing` payload for this session, and for a subagent about to start. */
97async function standingPayload(m: Mod, agent?: StandingPayload['agent']): Promise<StandingPayload> {
98 return {
99 session_id: await m.session.id(),
100 cwd: await m.session.cwd(),
101 hook_event_name: 'Standing',
102 via: 'mod',
103 ...(m.rt.observing ? { observe: true as const } : {}),
104 ...(agent === undefined ? { conventions: true as const } : { agent }),
105 }
106}
107
108/**
109 * The session's standing text, asked once per session id; nothing while the mod is inert (the
110 * question waits for a binary, so a first session whose link the shim makes later still asks).
111 */
112export async function standing(m: Mod): Promise<Standing> {
113 if (m.rt.bin === undefined) return NO_STANDING
114 const session = await m.session.id()
115 if (m.rt.standingFor?.session !== session) {
116 const answer = standingPayload(m)
117 .then(p => askBinary(m, 'Standing', p, STANDING_TIMEOUT_MS))
118 .then(a => (a.ok ? standingOf(a.answer) : NO_STANDING), () => NO_STANDING)
119 m.rt.standingFor = { session, answer }
120 }
121 return m.rt.standingFor.answer
122}
123
124/** Standing text once, from the cache (§3.6, C5): the procedures as one `session` section of the system prompt. */
125export async function onCompose(m: Mod, e: EventAt<'prompt.compose'>, next: NextAt<'prompt.compose'>): AnswerAt<'prompt.compose'> {
126 const composed = await next(e)
127 if (m.rt.bin === undefined) return composed
128 const text = (await standing(m).catch(() => NO_STANDING)).standing
129 return text === '' ? composed : { ...composed, sections: withSection(composed.sections, text) }
130}
131
132/**
133 * The Bash tool's description carries the environment note and the lookup conventions once
134 * (§1b.3, C9): how Python and the tests run here, and how work items are looked up, where the tool
135 * is described rather than at each call.
136 */
137export async function onDescribe(m: Mod, e: EventAt<'tool.describe', { tool: 'Bash' }>, next: NextAt<'tool.describe', { tool: 'Bash' }>): AnswerAt<'tool.describe', { tool: 'Bash' }> {
138 const described = await next(e)
139 if (m.rt.bin === undefined) return described
140 const note = describedNote(await standing(m).catch(() => NO_STANDING))
141 const description = withNote(described.description, note)
142 return description === described.description ? described : { ...described, description }
143}
144
145/**
146 * Compaction keeps what the session followed (§3.7, C5): the binary's instruction, in both arms;
147 * Adage's own attachment rows go only where the engine marks them as the plugin's.
148 */
149export async function onCompact(m: Mod, e: EventAt<'session.compact'>, next: NextAt<'session.compact'>): AnswerAt<'session.compact'> {
150 if (m.rt.bin === undefined) return next(e)
151 const compact = (await standing(m).catch(() => NO_STANDING)).compact
152 const instructions = withInstruction(typeof e.instructions === 'string' ? e.instructions : undefined, compact)
153 // A transcript that is not a list (a host that hands none) is passed on as it came.
154 const messages = Array.isArray(e.messages) ? withoutAttachments(e.messages) : e.messages
155 if (instructions === e.instructions && messages === e.messages) return next(e)
156 return next({ ...e, messages, ...(instructions === undefined ? {} : { instructions }) })
157}
158
159/**
160 * A subagent is told the conventions its task binds before its first request (§3.8, C9). A fork
161 * carries the session's own context already, and is left as it is.
162 */
163export async function onSpawn(m: Mod, e: EventAt<'agent.spawn'>, next: NextAt<'agent.spawn'>): AnswerAt<'agent.spawn'> {
164 if (m.rt.bin === undefined || e.fork) return next(e)
165 let told = ''
166 try {
167 // The session's own answer first: the binary keeps the session's standing text from it.
168 await standing(m)
169 const asked = await askBinary(m, 'Standing', await standingPayload(m, { type: e.subagentType, description: e.description, prompt: e.prompt }), STANDING_TIMEOUT_MS)
170 told = asked.ok ? subagentOf(asked.answer) : ''
171 } catch {
172 // Told nothing: the subagent starts as the model asked.
173 }
174 return told === '' ? next(e) : next({ ...e, prompt: withPreamble(e.prompt, told) })
175}
176hooks/bridge.ts 351 lines1// The one way the mod reaches the binary (docs/claude-mod/architecture.md §2 rule 1): every
2// decision is `<bin> hook claude <Event>` with the payload on stdin; the answer is tagged so a
3// failure is never mistaken for "nothing to say", and every failure is `{ ok: false }` (fail open,
4// §6 invariant 2). What the mod draws it reads from `adage` itself, by a short list of exact
5// argument vectors, every one read-only or a turn-down (`off`).
6//
7// No `$` crosses into this file (`claude plugin validate` follows `$` only within one file, and
8// refuses a module that passes it across an import). register.ts hands `guard` and `guardSpawn`
9// the functions that call `$.process.run` and `$.process.spawn`; both enforce the argv allow-list
10// and set CLAUDECODE=1 on the child (F11: every child the mod runs is an agent session's), so every
11// child below goes through them. The person-only verbs are not here: they run only through the
12// press (plan B4), never from a hook, a feed line or a command.
13import type { HookStream, ProcessRunInit, ProcessRunResult, ProcessSpawnChunk, ProcessSpawnRequest, ProcessSpawnResult } from 'claude-code'
14
15import { BIN, CLI } from './plugin'
16
17/**
18 * The verbs this module may run, by their head (`argv[1]`; the status card's `--json` reads as
19 * `status`). `isAllowed` holds each to its exact shape; `scripts/check-plugin.mjs` holds this
20 * literal to architecture §2's list, plus `condition` (the Health tab's one action, ux-design §6.3)
21 * and `delegate` (only `delegate status --json`: the lapsed grants the band and Health list).
22 */
23export const ALLOWED_VERBS: readonly string[] = ['hook', 'version', 'watch', 'graph', 'doctor', 'status', 'ledger', 'off', 'map', 'condition', 'delegate']
24
25/** `$.process.run`, as register.ts hands it over. */
26export type Run = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
27
28/** `$.process.spawn`, as register.ts hands it over. */
29export type Spawn = (request: ProcessSpawnRequest) => HookStream<ProcessSpawnChunk, ProcessSpawnResult>
30
31/** What a hook event's answer may carry; the binary owns the shape, the mod reads `predict`. */
32type HookAnswer = { [key: string]: unknown }
33
34export type Asked = { ok: true; answer: HookAnswer | undefined } | { ok: false }
35
36/** A `--json` read: the object the verb printed, or a failure. */
37export type Read = { ok: true; json: HookAnswer } | { ok: false }
38
39const isExact = (argv: readonly string[], tail: readonly string[]): boolean =>
40 argv.length === tail.length + 1 && tail.every((t, i) => argv[i + 1] === t)
41
42/** The head of an argv as T-X-04 counts it: the status card (`--json --for-session`) is `status`. */
43export function headOf(argv: readonly string[]): string {
44 const verb = argv[1] ?? ''
45 if (verb === '--json') return 'status'
46 if (verb === 'ledger') return `ledger ${argv[2] ?? ''}`.trim()
47 return verb
48}
49
50/**
51 * Whether `argv` (binary first) is one the mod may run, each verb in its one exact shape:
52 * `version`; `hook claude <Event>` (an event name of letters only); `watch --follow --json --stdin
53 * ignore --repo <dir> --session <id> [--since <cursor>]`; `graph --json`; `doctor --json`; `status --json`;
54 * `--json --for-session` (the status card); `ledger note`; `off`; `map --port 0`; `condition`;
55 * `delegate status --json`.
56 */
57export function isAllowed(argv: readonly string[]): boolean {
58 const verb = argv[1]
59 if (verb === undefined) return false
60 if (verb === '--json') return isExact(argv, ['--json', '--for-session'])
61 if (!ALLOWED_VERBS.includes(verb)) return false
62 switch (verb) {
63 case 'version':
64 case 'off':
65 case 'condition':
66 return argv.length === 2
67 case 'hook':
68 return argv.length === 4 && argv[2] === 'claude' && /^[A-Za-z]+$/.test(argv[3] ?? '')
69 case 'graph':
70 case 'doctor':
71 case 'status':
72 return isExact(argv, [verb, '--json'])
73 case 'ledger':
74 return isExact(argv, ['ledger', 'note'])
75 case 'map':
76 return isExact(argv, ['map', '--port', '0'])
77 case 'delegate':
78 return isExact(argv, ['delegate', 'status', '--json'])
79 case 'watch': {
80 const fixed = ['watch', '--follow', '--json', '--stdin', 'ignore', '--repo']
81 if (!fixed.every((t, i) => argv[i + 1] === t)) return false
82 const repo = argv[7] ?? ''
83 const session = argv[9] ?? ''
84 if (repo === '' || argv[8] !== '--session' || session === '' || session.startsWith('-')) return false
85 if (argv.length === 10) return true
86 return argv.length === 12 && argv[10] === '--since' && (argv[11] ?? '') !== ''
87 }
88 default:
89 return false
90 }
91}
92
93/**
94 * The guarded runner: an argv off the allow-list is refused before any process starts, and the
95 * child's environment always carries CLAUDECODE=1, whatever the caller passed.
96 */
97export function guard(run: Run): Run {
98 return (argv, init = {}) => {
99 if (!isAllowed(argv)) return Promise.reject(new Error(`adage bridge: argv not allowed: ${argv.slice(1).join(' ')}`))
100 return run(argv, { ...init, env: { ...init.env, CLAUDECODE: '1' } })
101 }
102}
103
104/** The guarded spawner: the same allow-list and CLAUDECODE=1 for a child the mod follows. */
105export function guardSpawn(spawn: Spawn): Spawn {
106 return request => {
107 if (!isAllowed(request.argv)) throw new Error(`adage bridge: argv not allowed: ${request.argv.slice(1).join(' ')}`)
108 return spawn({ ...request, env: { ...request.env, CLAUDECODE: '1' } })
109 }
110}
111
112/**
113 * The separator a path is spelled with: a backslash for a Windows path spelled with them (a drive,
114 * `C:\Users\me`, or a share), `/` for every other (a Unix path, or Windows spelled with `/`).
115 */
116export const sepOf = (p: string): string => (/^(?:[A-Za-z]:\\|\\\\)/.test(p) ? '\\' : '/')
117
118/** `base` and `parts` joined with base's own separator, base's trailing separators dropped. */
119export function joinPath(base: string, ...parts: string[]): string {
120 return [base.replace(/[\\/]+$/, ''), ...parts].join(sepOf(base))
121}
122
123/** A data directory's name for a plugin's key (`<plugin>@<source>`): every character but [A-Za-z0-9_-] as `-`. */
124const dataName = (key: string): string => key.replace(/[^a-zA-Z0-9\-_]/g, '-')
125
126/**
127 * The plugin's data directory derived from its root, as Claude Code lays them out for an installed
128 * plugin: root `<config>/plugins/cache/<marketplace>/<plugin>/<version>`, data
129 * `<config>/plugins/data/<plugin@marketplace, non [A-Za-z0-9_-] as '-'>`, either separator.
130 * Undefined for any other root (a --plugin-dir checkout).
131 */
132export function dataDirFromRoot(root: string): string | undefined {
133 const m = /^(.*)[\\/]plugins[\\/]cache[\\/]([^\\/]+)[\\/]([^\\/]+)[\\/][^\\/]+[\\/]?$/.exec(root)
134 if (m === null) return undefined
135 const [, config = '', marketplace = '', plugin = ''] = m
136 return joinPath(config, 'plugins', 'data', dataName(`${plugin}@${marketplace}`))
137}
138
139/**
140 * The config directories the plugin's data may be under: CLAUDE_CONFIG_DIR when set (and then only
141 * it), else `.claude` in the home directory (HOME, or USERPROFILE where Windows has no HOME; both
142 * when they differ, as under Git Bash).
143 */
144export function configDirs(configDir: string | undefined, homes: readonly (string | undefined)[]): string[] {
145 const configs = configDir ? [configDir] : homes.filter((h): h is string => !!h).map(h => joinPath(h, '.claude'))
146 return [...new Set(configs.map(c => c.replace(/[\\/]+$/, '')))]
147}
148
149/**
150 * Where the data directory of a plugin loaded with --plugin-dir may be: the engine keys it
151 * `<name>@inline`, so `<config>/plugins/data/<name>-inline` under each of `configDirs`. Each a
152 * candidate the shim may have written; the caller checks.
153 */
154export function inlineDataDirs(name: string, configDir: string | undefined, homes: readonly (string | undefined)[]): string[] {
155 return configDirs(configDir, homes).map(c => joinPath(c, 'plugins', 'data', dataName(`${name}@inline`)))
156}
157
158/** A path compared as a path: either separator, no trailing one, a Windows drive path in any case. */
159const pathKey = (p: string): string => {
160 const k = p.replace(/\\/g, '/').replace(/\/+$/, '')
161 return /^[A-Za-z]:\//.test(k) ? k.toLowerCase() : k
162}
163const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
164
165/**
166 * What the engine wrote about its plugins under one config directory, as read (the JSON text or
167 * the parsed value; anything unreadable is undefined): `plugins/known_marketplaces.json` (each
168 * marketplace's `installLocation` and `source.path`) and `plugins/installed_plugins.json` (each
169 * `<plugin>@<marketplace>`'s `installPath`s).
170 */
171export type Registry = { config: string; known?: unknown; installed?: unknown }
172
173const parsed = (v: unknown): unknown => {
174 if (typeof v !== 'string') return v
175 try {
176 return JSON.parse(v)
177 } catch {
178 return undefined
179 }
180}
181
182/**
183 * The keys (`<name>@<marketplace>`) under which the engine loads the plugin whose folder is
184 * `root`: a marketplace whose folder holds it (a directory marketplace's plugin is read in place:
185 * `adage init`'s `adage@adage-local`, read from `$ADAGE_HOME/plugin/adage`), and an installed
186 * entry whose `installPath` is it. Only the plugin's own name, in the order the files list them.
187 */
188export function registeredKeys(root: string, name: string, known: unknown, installed: unknown): string[] {
189 const at = pathKey(root)
190 const keys: string[] = []
191 const k = parsed(known)
192 if (isRecord(k)) {
193 for (const [marketplace, entry] of Object.entries(k)) {
194 if (!isRecord(entry)) continue
195 const source = isRecord(entry.source) ? entry.source.path : undefined
196 const homes = [entry.installLocation, source].filter((p): p is string => typeof p === 'string' && p !== '').map(pathKey)
197 if (homes.some(h => at === h || at.startsWith(`${h}/`))) keys.push(`${name}@${marketplace}`)
198 }
199 }
200 const i = parsed(installed)
201 const plugins = isRecord(i) && isRecord(i.plugins) ? i.plugins : {}
202 for (const [key, entries] of Object.entries(plugins)) {
203 if (!key.startsWith(`${name}@`) || !Array.isArray(entries)) continue
204 if (entries.some(e => isRecord(e) && typeof e.installPath === 'string' && pathKey(e.installPath) === at)) keys.push(key)
205 }
206 return [...new Set(keys)]
207}
208
209/**
210 * Where the plugin's data directory may be when the engine's process does not carry
211 * CLAUDE_PLUGIN_DATA (it hands it to command hooks only), in order: derived from an installed
212 * plugin's root (and then only that); else, under each config directory, the data directory of
213 * each key the engine registered the plugin's folder under (`registeredKeys`), then the
214 * --plugin-dir layout's (`<name>-inline`). Each a candidate the shim may have written; the
215 * caller checks. A key the folder is not registered under is never a candidate: its link may be
216 * another install's.
217 */
218export function dataDirCandidates(root: string, name: string, registries: readonly Registry[]): string[] {
219 const installed = dataDirFromRoot(root)
220 if (installed !== undefined) return [installed]
221 const out: string[] = []
222 for (const r of registries) {
223 const data = (key: string) => joinPath(r.config, 'plugins', 'data', dataName(key))
224 out.push(...registeredKeys(root, name, r.known, r.installed).map(data), data(`${name}@inline`))
225 }
226 return [...new Set(out)]
227}
228
229/**
230 * The suffix the session-start shim gives the entries it makes: on Windows it copies `hook.exe`
231 * and `adage.exe` (a link would not run), elsewhere it links `hook` and `adage`. Windows is read
232 * from the engine's environment (`OS=Windows_NT`, which Windows sets for every process).
233 */
234export const exeSuffix = (os: string | undefined): string => (os === 'Windows_NT' ? '.exe' : '')
235
236/**
237 * `<data>/bin/<BIN>`, the link the session-start shim made in the plugin's data directory
238 * (binary.ts `dataDir`). There is no PATH fallback: when the shim linked `scripts/quiet` (no
239 * binary could serve) the probe reads nothing and the mod stays inert, as the command hooks do.
240 */
241export function hookBinary(data: string | undefined, exe = ''): string | undefined {
242 return data ? joinPath(data, 'bin', `${BIN}${exe}`) : undefined
243}
244
245/** `<data>/bin/adage`, which the shim links beside `bin/hook`: the verbs the mod draws from. */
246export function cliBinary(data: string | undefined, exe = ''): string | undefined {
247 return data ? joinPath(data, 'bin', `${CLI}${exe}`) : undefined
248}
249
250/**
251 * The presence probe (`<bin> version`): a binary is there when it exits 0 and prints something.
252 * `scripts/quiet` prints nothing, so with no binary the mod stays inert. Resolves the binary that
253 * serves, or undefined.
254 */
255export async function probe(run: Run, bin: string | undefined): Promise<string | undefined> {
256 if (bin === undefined) return undefined
257 try {
258 const ran = await run([bin, 'version'], { timeoutMs: 1000 })
259 return ran.exitCode === 0 && ran.stdout.trim() !== '' ? bin : undefined
260 } catch {
261 return undefined // cannot start (no link yet), timed out, refused
262 }
263}
264
265const parseObject = (text: string): HookAnswer | undefined => {
266 const parsed: unknown = JSON.parse(text)
267 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
268 return parsed as HookAnswer
269}
270
271/**
272 * Asks the binary about one event. `{ ok: true, answer: undefined }` is "nothing to do" (exit 0,
273 * empty stdout); a JSON object is the answer; everything else is `{ ok: false }`.
274 */
275export async function ask(
276 run: Run,
277 bin: string | undefined,
278 event: string,
279 payload: { cwd?: string; [key: string]: unknown },
280 timeoutMs: number,
281): Promise<Asked> {
282 if (bin === undefined) return { ok: false }
283 try {
284 const init: ProcessRunInit = { stdin: JSON.stringify(payload), timeoutMs }
285 if (typeof payload.cwd === 'string' && payload.cwd !== '') init.cwd = payload.cwd
286 const ran = await run([bin, 'hook', 'claude', event], init)
287 if (ran.exitCode !== 0 || ran.isStdoutTruncated) return { ok: false }
288 const text = ran.stdout.trim()
289 if (text === '') return { ok: true, answer: undefined }
290 const answer = parseObject(text)
291 return answer === undefined ? { ok: false } : { ok: true, answer }
292 } catch {
293 return { ok: false }
294 }
295}
296
297/** How long a read of `adage … --json` may take before the mod draws without it. */
298const READ_TIMEOUT_MS = 10_000
299
300/**
301 * Reads one `adage … --json` object (the card, `graph`, `doctor`, `status`, `delegate status`),
302 * in the session's directory. Anything but exit 0 with one JSON object is `{ ok: false }`, and the
303 * drawing keeps what it had.
304 */
305export async function read(run: Run, cli: string | undefined, argv: readonly string[], cwd: string): Promise<Read> {
306 if (cli === undefined) return { ok: false }
307 try {
308 const init: ProcessRunInit = { timeoutMs: READ_TIMEOUT_MS }
309 if (cwd !== '') init.cwd = cwd
310 const ran = await run([cli, ...argv], init)
311 if (ran.exitCode !== 0 || ran.isStdoutTruncated) return { ok: false }
312 const json = parseObject(ran.stdout.trim())
313 return json === undefined ? { ok: false } : { ok: true, json }
314 } catch {
315 return { ok: false }
316 }
317}
318
319/** Runs one allowed verb that prints a line for the person (`off`, `condition`): its stdout, or undefined. */
320export async function say(run: Run, cli: string | undefined, argv: readonly string[], cwd: string): Promise<string | undefined> {
321 if (cli === undefined) return undefined
322 try {
323 const init: ProcessRunInit = { timeoutMs: 120_000 }
324 if (cwd !== '') init.cwd = cwd
325 const ran = await run([cli, ...argv], init)
326 return ran.exitCode === 0 ? ran.stdout.trim() : undefined
327 } catch {
328 return undefined
329 }
330}
331
332/** How long `adage ledger note` may take: it merges under the ledger's lock, off every hook's path. */
333export const NOTE_TIMEOUT_MS = 10_000
334
335/**
336 * Hands the session's usage to `adage ledger note` (plan A4, architecture §3.9) on stdin, in the
337 * session's directory. True when the binary recorded it; a refusal (exit 2: a malformed note, or a
338 * session the spool does not know) or any failure is false, and nothing else happens (fail open).
339 */
340export async function note(run: Run, cli: string | undefined, payload: unknown, cwd: string): Promise<boolean> {
341 if (cli === undefined) return false
342 try {
343 const init: ProcessRunInit = { stdin: JSON.stringify(payload), timeoutMs: NOTE_TIMEOUT_MS }
344 if (cwd !== '') init.cwd = cwd
345 const ran = await run([cli, 'ledger', 'note'], init)
346 return ran.exitCode === 0
347 } catch {
348 return false
349 }
350}
351hooks/call.ts 289 lines1// The Bash call (docs/claude-mod/architecture.md §3.3, §3.4; plan C3, C6): before a Bash call runs,
2// the binary is asked for its plan (`dry_run`), which names at most a receipt, a replacement for
3// a known dead end, and a rewrite or advice. The person's rules are Claude Code's: each branch
4// asks the engine what they decide (`$.tool.check`, which runs nothing) before the binary is asked
5// for its final word (`verdict`), and only that word changes what happens to the call:
6//
7// - a fresh receipt answers the call in its place, only when the rules would have let the call as
8// written run unasked (no dialog appears for an answered call);
9// - the replacement runs instead of the dead end, only when the rules treat both alike, whether
10// the dead end was learned or is a repository's declared instinct (the binary plans both alike; a
11// dead end piped into output filters is replaced as the whole new pipeline the binary plans, the
12// filters as written, and the rules are read on both whole pipelines);
13// - a rewrite runs the call on the project's interpreter when the rules treat both alike; when
14// they do not, the binary turns it into advice, which the mod hands the model itself (the
15// command hook that follows for the same call prints nothing once a verdict decided it).
16//
17// Everything else, a failure included, runs the call as the model wrote it and leaves the command
18// hook to decide as it always has (§6 invariants 1 and 2). `bashCall` is the call itself over
19// plain deps; `onBashCall` binds it to the mod and keeps the markers each decision earns.
20import type { ToolCallResult } from 'claude-code'
21
22import { askBinary } from './binary'
23import type { Asked } from './bridge'
24import { keepCall } from './ledger'
25import { mark } from './marks'
26import type { AnswerAt, EventAt, Mod, NextAt } from './mod'
27import { madeForAgentLine } from './predict'
28import { answeredMark, receiptAnswerOf, receiptPlanOf, shortId } from './receipt'
29import type { ReceiptAnswer, ReceiptPlan } from './receipt'
30import { isRecord } from './shape'
31import { substituteAnswerOf, substituteOf } from './substitute'
32import type { Substitute } from './substitute'
33import { markText, receiptOf, rewriteOf } from './view/lines'
34
35/** What the mod tells the binary it found out (A1's `verdict`), exactly one decision per call. */
36type Verdict = { engine: 'same' | 'differs'; substitute?: string } | { answered: 'receipt' }
37
38/** The binary's `PreToolUse` payload for the mod's two phases (A1, C3, C6), exactly. */
39export type PreToolPayload = {
40 session_id: string
41 cwd: string
42 hook_event_name: 'PreToolUse'
43 tool_name: 'Bash'
44 tool_input: { command: string }
45 tool_use_id: string
46 /** Every payload the mod sends says so: the binary then reads the mod's capabilities (C9). */
47 via: 'mod'
48 /** The subagent the call is made in (`tool.call`'s `agentId`); absent on the main loop. */
49 agent_id?: string
50 dry_run?: true
51 verdict?: Verdict
52 /** Observe mode: the binary records what it would substitute, as the control arm does. */
53 observe?: true
54}
55
56export type CallDeps = {
57 /** False while the mod is inert: every call runs unread. */
58 isActive: boolean
59 /**
60 * Observe mode (ADAGE_MOD_OBSERVE): the plan is asked for and a replacement's verdict with
61 * `observe`, and nothing is replaced or answered.
62 */
63 isObserving: boolean
64 ask: (payload: PreToolPayload) => Promise<Asked>
65 context: () => Promise<{ sessionId: string; cwd: string }>
66 /** The person's rules on a Bash command now (`$.tool.check`): `allow`, `ask` or `deny`. */
67 check: (command: string) => Promise<string>
68 /** One line under the call's permission dialog, if one opens (`$.ui.notice`). */
69 notice: (toolUseId: string, text: string) => void
70 /** Keeps a receipt that answered the call, for the card in its result row; a failure is ignored. */
71 answered?: (toolUseId: string, answer: ReceiptAnswer) => Promise<void>
72 /**
73 * Keeps a covering receipt that did not answer the call (not fresh, the rules asked, or the
74 * binary withheld it), for the dim line beside the run that happens (`result.receipt.beside`);
75 * a failure is ignored.
76 */
77 beside?: (toolUseId: string, operation: string) => Promise<void>
78 /** Keeps the replacement for the marker under the call's row (`tool.substituted`); a failure is ignored. */
79 made?: (toolUseId: string, command: string) => Promise<void>
80 /**
81 * Keeps a rewrite for the marker under the call's row and says it under the dialog
82 * (`tool.rewrite`); a failure is ignored.
83 */
84 rewrote?: (toolUseId: string, from: string, to: string) => Promise<void>
85 /** Keeps what the final answer told the model beside the run (a receipt note); a failure is ignored. */
86 delivered?: (toolUseId: string, context: readonly string[]) => Promise<void>
87}
88
89/** Both phases run before the call does, so each has the budget a command hook would. */
90const PRE_TOOL_TIMEOUT_MS = 2000
91
92/** The parts of a dry run's plan the mod acts on; a missing or malformed part is undefined. */
93export type Plan = { receipt: ReceiptPlan | undefined; substitute: Substitute | undefined; rewrite: { command: string; kind: string } | undefined }
94
95/**
96 * The dry run's answer (`{ plan }`, docs/claude-mod/impl/wire.md), each part read by its own
97 * reader; undefined when there is no plan at all. The plan's `advice` is not read: the command
98 * hook that follows the call delivers it.
99 */
100export function planOf(answer: unknown): Plan | undefined {
101 if (!isRecord(answer) || !isRecord(answer.plan)) return undefined
102 const p = answer.plan
103 return { receipt: receiptPlanOf(p.receipt), substitute: substituteOf(p.substitute), rewrite: rewritePlanOf(p.rewrite) }
104}
105
106/** The plan's `rewrite`: the command the call would run as, when U11 bound an environment here. */
107export function rewritePlanOf(v: unknown): { command: string; kind: string } | undefined {
108 if (!isRecord(v) || typeof v.command !== 'string' || v.command.trim() === '') return undefined
109 return { command: v.command, kind: typeof v.kind === 'string' ? v.kind : '' }
110}
111
112/**
113 * The final answer to an `engine` verdict, as the command hook's own output reads (A1): the
114 * command the call runs as (`updatedInput.command`, a rewrite), what the model reads beside the
115 * call (`additionalContext`: the rewrite's word, the advice, a receipt note) and the person's line
116 * (`systemMessage`). Undefined when it says nothing the mod can use.
117 */
118export function verdictAnswerOf(answer: unknown): { command: string | undefined; context: string[] } | undefined {
119 if (!isRecord(answer) || !isRecord(answer.hookSpecificOutput)) return undefined
120 const out = answer.hookSpecificOutput
121 const updated = isRecord(out.updatedInput) ? out.updatedInput.command : undefined
122 const command = typeof updated === 'string' && updated.trim() !== '' ? updated : undefined
123 const context = typeof out.additionalContext === 'string' && out.additionalContext !== '' ? [out.additionalContext] : []
124 return command === undefined && context.length === 0 ? undefined : { command, context }
125}
126
127/** A result with more for the model to read after it; a refused call is returned as it was. */
128function withContext(ran: ToolCallResult, context: readonly string[]): ToolCallResult {
129 if (ran.deny !== undefined || context.length === 0) return ran
130 return { ...ran, context: [...(ran.context ?? []), ...context] }
131}
132
133type BashCall = { command: string; tool_use_id: string; agentId?: string }
134
135/**
136 * The hook's body: `on('tool.call', { tool: 'Bash' }, ($, e, next) => bashCall(e, next, deps))`.
137 * A call Adage made in the model's place (`adage_` id) is decided like any other, and its result
138 * carries the line that says whose call it was (`madeForAgentLine`).
139 */
140export async function bashCall<E extends BashCall>(e: E, next: (e: E) => Promise<ToolCallResult>, deps: CallDeps): Promise<ToolCallResult> {
141 const ran = await decided(e, next, deps)
142 const line = deps.isActive ? madeForAgentLine(e.tool_use_id) : undefined
143 return line === undefined ? ran : withContext(ran, [line])
144}
145
146/**
147 * Any other tool's call (`on('tool.call', …)`, every tool but Bash, which `bashCall` answers): a
148 * call Adage made in the model's place (the chain's reads, a skill) runs as it is, and its result
149 * carries the line that says whose call it was; every other call passes untouched.
150 */
151export async function onPredictedCall(m: Mod, e: EventAt<'tool.call'>, next: NextAt<'tool.call'>): AnswerAt<'tool.call'> {
152 // The ledger counts every main-loop call once: Bash's in its own hook, which a receipt may answer
153 // before this one is reached.
154 if (e.tool !== 'Bash') keepCall(m, e, next.origin)
155 const line = e.tool === 'Bash' ? undefined : madeForAgentLine(e.tool_use_id)
156 if (line === undefined) return next(e)
157 const ran = await next(e)
158 return ran.deny !== undefined ? ran : { ...ran, context: [...(ran.context ?? []), line] }
159}
160
161async function decided<E extends BashCall>(e: E, next: (e: E) => Promise<ToolCallResult>, deps: CallDeps): Promise<ToolCallResult> {
162 if (!deps.isActive || typeof e.command !== 'string') return next(e)
163 let ctx: { sessionId: string; cwd: string }
164 try {
165 ctx = await deps.context()
166 } catch {
167 return next(e)
168 }
169 const id = e.tool_use_id
170 const base: PreToolPayload = {
171 session_id: ctx.sessionId,
172 cwd: ctx.cwd,
173 hook_event_name: 'PreToolUse',
174 tool_name: 'Bash',
175 tool_input: { command: e.command },
176 tool_use_id: id,
177 via: 'mod',
178 ...(typeof e.agentId === 'string' && e.agentId !== '' ? { agent_id: e.agentId } : {}),
179 ...(deps.isObserving ? { observe: true as const } : {}),
180 }
181 const ask = (p: PreToolPayload) => deps.ask(p).catch((): Asked => ({ ok: false }))
182 const quiet = (work: Promise<void> | undefined) => (work ?? Promise.resolve()).catch(() => undefined)
183
184 const planned = await ask({ ...base, dry_run: true })
185 const plan = planned.ok ? planOf(planned.answer) : undefined
186 if (plan === undefined) return next(e)
187
188 // The rules on the call as written, asked at most once whichever branches need it; a check that
189 // fails is no answer, and no branch that needs it is taken.
190 let written: Promise<string | undefined> | undefined
191 const asWritten = () => (written ??= deps.check(e.command).then(d => d, () => undefined))
192 const alike = async (other: string): Promise<boolean | undefined> => {
193 const [a, b] = await Promise.all([asWritten(), deps.check(other).then(d => d, () => undefined)])
194 return a === undefined || b === undefined ? undefined : a === b
195 }
196
197 // §3.3 The receipt as the result: no dialog appears for an answered call, so only a call the
198 // rules would have let run unasked is answered. A receipt that is not fresh, or a call the rules
199 // ask about, runs: the binary says nothing more than it would have.
200 const receipt = plan.receipt
201 if (receipt?.fresh === true && receipt.result !== '' && !deps.isObserving && (await asWritten()) === 'allow') {
202 const final = await ask({ ...base, verdict: { answered: 'receipt' } })
203 const answer = final.ok ? receiptAnswerOf(final.answer) : undefined
204 if (answer !== undefined) {
205 await quiet(deps.answered?.(id, answer))
206 return { result: { stdout: answer.result, stderr: '', interrupted: false }, ...(answer.note === '' ? {} : { context: [answer.note] }) }
207 }
208 }
209 // A covering receipt that did not answer the call: the run happens, and the row says a receipt
210 // covers it (T-C3-07). The command hook still delivers a fresh one's note beside the result.
211 if (receipt !== undefined && receipt.operation !== '' && !deps.isObserving) await quiet(deps.beside?.(id, receipt.operation))
212
213 // §3.4 The dead end that never runs: the planned command (learned, or a declared instinct's),
214 // only on the same verdict for the call and for the command the binary planned (the whole
215 // pipeline, when the dead end heads one).
216 const sub = plan.substitute
217 if (sub !== undefined && (await alike(sub.command)) === true) {
218 const final = await ask({ ...base, verdict: { engine: 'same', substitute: sub.route } })
219 const made = final.ok ? substituteAnswerOf(final.answer) : undefined
220 if (made !== undefined && !deps.isObserving) {
221 await quiet(deps.made?.(id, made.command))
222 try {
223 deps.notice(id, made.context)
224 } catch {
225 // No dialog is open for the call: the model's line below still says it.
226 }
227 return withContext(await next({ ...e, command: made.command }), [made.context])
228 }
229 // Withheld (the control arm, or observe mode): the binary records `would_substitute` and leaves
230 // the call undecided, so the interpreter rewrite below still decides it: the verdict here in
231 // the control arm (recorded withheld there), the command hook in observe mode.
232 }
233
234 // §3.4 Rewrites checked by the engine: the engine's comparison replaces the planner's own reading
235 // of the settings files. Advice alone, or nothing, is the command hook's to deliver as before.
236 const rewrite = plan.rewrite
237 if (rewrite === undefined || deps.isObserving) return next(e)
238 const same = await alike(rewrite.command)
239 if (same === undefined) return next(e)
240 const final = await ask({ ...base, verdict: { engine: same ? 'same' : 'differs' } })
241 const said = final.ok ? verdictAnswerOf(final.answer) : undefined
242 if (said === undefined) return next(e)
243 await quiet(deps.delivered?.(id, said.context))
244 if (said.command === undefined || said.command === e.command) return withContext(await next(e), said.context)
245 await quiet(deps.rewrote?.(id, e.command, said.command))
246 return withContext(await next({ ...e, command: said.command }), said.context)
247}
248
249/** The rewrite under a call's row (`tool.rewrite`), and the same line under its dialog. */
250async function rewrote(m: Mod, id: string, from: string, to: string): Promise<void> {
251 const rw = rewriteOf(from, to)
252 if (rw === undefined) return
253 const x = { id, kind: 'rewrite' as const, ...rw }
254 await mark(m, x)
255 try {
256 m.ui.notice(id, markText(x, (await m.state.mode.read()).ascii))
257 } catch {
258 // No dialog is open for the call: the row's marker says it.
259 }
260}
261
262/**
263 * The hook body (`on('tool.call', { tool: 'Bash' }, …)`, §3.3, §3.4; C3, C6): a fresh receipt
264 * answers the call when the rules would have let it run unasked; a dead end's replacement or a rewrite
265 * runs only on the same verdict for both commands. Each decision keeps its marker for the row.
266 */
267export async function onBashCall(m: Mod, e: EventAt<'tool.call', { tool: 'Bash' }>, next: NextAt<'tool.call', { tool: 'Bash' }>): AnswerAt<'tool.call', { tool: 'Bash' }> {
268 keepCall(m, e, next.origin)
269 return bashCall(e, next, {
270 isActive: m.rt.bin !== undefined,
271 isObserving: m.rt.observing,
272 ask: payload => askBinary(m, 'PreToolUse', payload, PRE_TOOL_TIMEOUT_MS),
273 context: async () => ({ sessionId: await m.session.id(), cwd: await m.session.cwd() }),
274 check: command => m.checkBash(command),
275 notice: (id, text) => m.ui.notice(id, text),
276 answered: async (id, a) => {
277 const x = answeredMark(id, a)
278 if (x !== undefined) await mark(m, x)
279 },
280 beside: (id, operation) => mark(m, { id, kind: 'receipt', receipt: shortId(operation) }),
281 made: (id, command) => mark(m, { id, kind: 'substituted', command }),
282 rewrote: (id, from, to) => rewrote(m, id, from, to),
283 delivered: async (id, context) => {
284 const rc = receiptOf(context)
285 if (rc !== undefined) await mark(m, { id, kind: 'receipt', receipt: rc })
286 },
287 })
288}
289hooks/commands.ts 154 lines1// `/adage …` (ux-design §7): one registration, the verb parsed from the arguments. Every verb
2// answers with text a headless session can read; the drawn forms are draw/card.tsx's. No verb
3// runs a person-only command: `/adage on` and `/adage update` open their asks, whose answers are
4// the press (plan B4; onboarding plan O9).
5// `runCommand` answers over plain deps; `onCommand` binds them to the mod.
6import { copy, explore, openPane } from './actions'
7import { read, say } from './bridge'
8import { pressHandlers } from './dialog'
9import { togglePause } from './follower'
10import type { EventAt, Mod } from './mod'
11import { quietly } from './mod'
12import type { View } from './plugin'
13import { cardOf } from './reads'
14import type { Card, Offer } from './reads'
15import { refresh } from './refresh'
16import { SAVINGS_ROWS, cardText, whyText } from './view/card'
17import { upToDate, updateHeadless } from './ask'
18
19/** The verbs `/adage` takes; anything else prints the card (ux-design §7). */
20const VERBS = ['status', 'graph', 'watch', 'reflexes', 'health', 'doctor', 'review', 'why', 'off', 'on', 'pause', 'map', 'savings', 'update'] as const
21export type Verb = (typeof VERBS)[number] | 'card'
22
23/** The hint after the name: the two verbs a person needs (ux-design §7). */
24export const ARGUMENT_HINT = '[review]'
25export const DESCRIPTION = 'What Adage did in this session and this repository; /adage review for what waits for you'
26
27/** The verb in `/adage <args>`: its first word, lowercased; none or an unknown one is the card. */
28export function verbOf(args: string): Verb {
29 const word = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
30 return (VERBS as readonly string[]).includes(word) ? (word as Verb) : 'card'
31}
32
33/** The pane's tab a verb opens, if it opens one. */
34export function tabOf(verb: Verb): View['tab'] | undefined {
35 switch (verb) {
36 case 'graph':
37 case 'card':
38 return 'graph'
39 case 'watch':
40 return 'live'
41 case 'reflexes':
42 case 'review':
43 return 'reflexes'
44 case 'health':
45 case 'doctor':
46 return 'health'
47 default:
48 return undefined
49 }
50}
51
52export const OFF_TEXT = 'adage is off for this repository (.adage/disabled). /adage on turns it back on.'
53/** `/adage on` where no one can be asked (headless, or the mod inert): the terminal is the way. */
54export const ON_TEXT = 'Turning Adage back on here is a decision for a person: run adage on in a terminal.'
55export const PAUSED = 'adage is paused for this session · /adage pause resumes'
56export const RESUMED = 'adage resumed'
57
58export type CommandDeps = {
59 card: () => Promise<Card | undefined>
60 /** Opens the pane on a tab (focused: the person asked). */
61 open: (tab: View['tab']) => Promise<void>
62 why: () => Promise<string>
63 /** Runs `adage off`; the binary's line, or undefined when it could not run. */
64 off: () => Promise<string | undefined>
65 /** Copies a command for the person; whether it took. */
66 copy: (command: string) => Promise<boolean>
67 /** Toggles the pause; true when now paused. */
68 pause: () => Promise<boolean>
69 /** Starts the map if it is not serving; its address, or undefined. */
70 map: () => Promise<string | undefined>
71 /** Opens `/adage on`'s ask; undefined where no one can be asked (headless, or the mod inert). */
72 askOn: (() => void) | undefined
73 /** Opens `/adage update`'s ask; undefined where no one can be asked. */
74 askUpdate: ((u: Offer) => void) | undefined
75}
76
77const NO_CARD = 'adage is not answering here; run adage doctor in a terminal.'
78
79/** The answer to `/adage <args>`: the text for the transcript (empty for a verb that only opens the pane or an ask). */
80export async function runCommand(args: string, deps: CommandDeps): Promise<{ text?: string }> {
81 const verb = verbOf(args)
82 switch (verb) {
83 case 'card':
84 case 'status': {
85 const card = await deps.card()
86 if (verb === 'card' && args.trim() === '') await deps.open('graph')
87 return { text: card === undefined ? NO_CARD : cardText(card) }
88 }
89 case 'savings': {
90 const card = await deps.card()
91 return { text: card === undefined ? NO_CARD : cardText(card, SAVINGS_ROWS) }
92 }
93 case 'graph':
94 case 'watch':
95 case 'reflexes':
96 case 'review':
97 case 'health':
98 case 'doctor':
99 await deps.open(tabOf(verb) ?? 'graph')
100 return {}
101 case 'why':
102 return { text: await deps.why() }
103 case 'off': {
104 const said = await deps.off()
105 if (said === undefined) return { text: NO_CARD }
106 return { text: said.startsWith('adage is off') ? OFF_TEXT : said }
107 }
108 case 'on': {
109 // The ask, never the verb: a command handler never runs a person-only verb (ux-design §7).
110 if (deps.askOn === undefined) return { text: ON_TEXT }
111 deps.askOn()
112 return {}
113 }
114 case 'update': {
115 // The ask, never the verb (onboarding plan O9), and only when the binary says a release is
116 // newer: the card's `update`, as it words it.
117 const card = await deps.card()
118 if (card === undefined) return { text: NO_CARD }
119 if (card.update === undefined) return { text: upToDate(card.version) }
120 if (deps.askUpdate === undefined) return { text: updateHeadless(card.update.latest) }
121 deps.askUpdate(card.update)
122 return {}
123 }
124 case 'pause':
125 return { text: (await deps.pause()) ? PAUSED : RESUMED }
126 case 'map': {
127 const url = await deps.map()
128 return { text: url === undefined ? 'The map did not start; run adage map in a terminal.' : `Explore in browser: ${url}` }
129 }
130 }
131}
132
133/** `/adage …` (ux-design §7): one registration (made at activation), the verb parsed here. */
134export async function onCommand(m: Mod, e: EventAt<'command.run', { command: 'adage' }>): Promise<{ text?: string }> {
135 return runCommand(e.args, {
136 card: async () => {
137 const r = await read(m.run, m.rt.cli, ['--json', '--for-session'], await m.session.cwd())
138 return r.ok ? cardOf(r.json) : undefined
139 },
140 open: tab => (m.rt.interactive ? openPane(m, tab) : Promise.resolve()),
141 why: async () => whyText(await m.state.rows.read(), await m.state.reflexes.read(), (await m.state.mode.read()).ascii, m.rt.skill),
142 off: async () => {
143 const said = await say(m.run, m.rt.cli, ['off'], await m.session.cwd())
144 quietly(refresh(m, true))
145 return said
146 },
147 copy: command => copy(m, command),
148 pause: () => togglePause(m),
149 map: () => explore(m),
150 askOn: m.rt.pressBound ? pressHandlers(m).askOn : undefined,
151 askUpdate: m.rt.pressBound ? pressHandlers(m).askUpdate : undefined,
152 })
153}
154hooks/dialog.ts 284 lines1// Asking the person in the engine's own dialog (plan B4; architecture §3.12; ux-design §4.17, §7,
2// §9.12): at a turn's end, when the model is idle, one waiting item (ask.ts picks it and words it),
3// at most one per turn end and one per reflex per day; `/adage on`'s and `/adage update`'s asks;
4// and the answers kept while no press was held, put to the person again at the next start in their
5// repository. Each answer is a press (token.ts), and so is each press in the pane (`pressHandlers`).
6import { openPane, showReflex } from './actions'
7import { allowYes } from './allow'
8import {
9 KEPT_ANSWERS,
10 ON_ANSWERS,
11 ON_QUESTION,
12 UPDATE_ANSWERS,
13 answerOf,
14 keptOf,
15 keptQuestion,
16 nextAsk,
17 updateFailed,
18 updateKey,
19 updateQuestion,
20 updateSay,
21 updatedToast,
22 whatOf,
23} from './ask'
24import type { AskItem } from './ask'
25import type { PressHandlers } from './draw/pane'
26import { keep, keptNow, loadStore, noteAnswer, repoRoot } from './kept'
27import type { AnswerAt, EventAt, Mod, NextAt } from './mod'
28import { quietly } from './mod'
29import type { Kept } from './plugin'
30import { KEPT_MAX_AGE_MS } from './plugin'
31import { ANSWER, BUTTON, press, pressArgv } from './press'
32import type { Gesture, Pressed } from './press'
33import { recountNoticed } from './notice'
34import type { Offer } from './reads'
35import { updatedOf } from './reads'
36import { refresh } from './refresh'
37import { checkTamper, issue, pressCommand } from './token'
38import type { Started } from './token'
39import { reviewCommand, waiting, worthAsking } from './view/pane'
40import type { Waiting } from './view/pane'
41
42/** The pane's presses and `/adage on`, for the drawings and the command to call. */
43export function pressHandlers(m: Mod): PressHandlers {
44 return {
45 verb: (verb, r) => quietly(pressCommand(m, BUTTON, reviewCommand(verb, r), r.gist || r.summary, `${r.reference}/${r.id}`, verb)),
46 yes: item => quietly(answerYes(m, item, BUTTON)),
47 no: item => quietly(answerNo(m, item, BUTTON)),
48 askOn: () => quietly(askOn(m)),
49 update: () =>
50 quietly(
51 (async () => {
52 const u = (await m.state.mode.read()).update
53 if (u !== null) await pressUpdate(m, BUTTON, u.latest)
54 })(),
55 ),
56 askUpdate: u => quietly(askUpdate(m, u)),
57 }
58}
59
60/** The one command a press runs to update Adage (press.ts `isPressAllowed`: bare, nothing else). */
61const UPDATE = 'adage update'
62
63/**
64 * `adage update` through the press (onboarding plan O9): with no press held the answer is kept for
65 * the next start in this repository (`keepable`), and nothing runs. The binary updates only a
66 * channel whose update is a fetch, writes no settings file, and says what it came to on its last
67 * line (reads.ts `updatedOf`), which is the toast; a failure after the spend names the terminal.
68 * The token moves as token.ts `pressCommand` moves it: whatever the binary printed next is held.
69 */
70async function pressUpdate(m: Mod, gesture: Gesture, latest: string, keepable = true): Promise<Pressed | undefined> {
71 if (m.rt.cli === undefined) return undefined
72 const argv = pressArgv(m.rt.cli, UPDATE)
73 if (argv === undefined) return undefined
74 await checkTamper(m)
75 const p = await m.state.press.read()
76 const session = await m.session.id()
77 if (p.state !== 'held' || p.token === '' || p.session !== session) {
78 if (keepable) {
79 await keep(m, { command: UPDATE, say: updateSay(latest), key: updateKey(latest), session, root: await repoRoot(m), at: await m.clock.now() })
80 await noteAnswer(m, updateKey(latest), 'update')
81 }
82 return undefined
83 }
84 const pressed = await press(gesture, m.pressRun, argv, p.token, p.session, await m.session.cwd())
85 if (pressed.ok || !pressed.refused) {
86 await m.state.press.update(cur => ({
87 ...cur,
88 token: pressed.next ?? '',
89 state: pressed.next !== undefined ? ('held' as const) : ('none' as const),
90 chain: cur.chain + 1,
91 }))
92 }
93 if (pressed.ok) {
94 await noteAnswer(m, updateKey(latest), 'update')
95 const said = updatedOf(pressed.lines)
96 if (m.rt.interactive) m.ui.toast(said === undefined ? updateFailed('') : updatedToast(said))
97 quietly(refresh(m, true))
98 } else if (m.rt.interactive) {
99 m.ui.toast(updateFailed(pressed.error))
100 }
101 return pressed
102}
103
104/**
105 * `/adage update` (onboarding plan O9): the ask, opened only by the command (never unasked).
106 * `update` is the press; `what changes` opens Health, where the version row holds the release's
107 * first lines and the same press; `not now` records that it was asked.
108 */
109export async function askUpdate(m: Mod, u: Offer): Promise<void> {
110 if (m.rt.asking) return
111 m.rt.asking = true
112 try {
113 const said = answerOf({ answers: UPDATE_ANSWERS }, await m.ui.ask(updateQuestion(u), UPDATE_ANSWERS).catch(() => undefined))
114 if (said === 'update') await pressUpdate(m, ANSWER, u.latest)
115 else if (said === 'what changes') await openPane(m, 'health')
116 else if (said === 'not now') await noteAnswer(m, updateKey(u.latest), 'not now')
117 } finally {
118 m.rt.asking = false
119 }
120}
121
122/** The first answer to a waiting item: enable it, stage the action, allow the prediction, use the pack. */
123async function answerYes(m: Mod, item: Waiting, gesture: Gesture): Promise<void> {
124 switch (item.kind) {
125 case 'enable':
126 await pressCommand(m, gesture, item.command, item.say, item.key, 'approve')
127 return
128 case 'allow':
129 if (item.offer === 'predict') await pressCommand(m, gesture, item.command, item.say, item.key, 'predict')
130 else await allowYes(m, item, gesture)
131 return
132 case 'trust':
133 await pressCommand(m, gesture, item.command, item.say, item.key, 'trust')
134 }
135}
136
137/** `not this one` records a dismiss through the press; `not now` records only that it was asked. */
138async function answerNo(m: Mod, item: Waiting, gesture: Gesture): Promise<void> {
139 if (item.kind === 'enable' && item.no !== undefined) await pressCommand(m, gesture, item.no, item.say, item.key, 'dismiss')
140 else await noteAnswer(m, item.key, 'not now')
141}
142
143/** What an answer in the dialog does; a dismissed dialog (`later`) does nothing. */
144async function act(m: Mod, q: AskItem, answer: string | undefined): Promise<void> {
145 if (answer === undefined) return
146 if (answer === 'why') return showReflex(m, q.item.reflex ?? q.key)
147 if (answer === 'not now') return
148 if (answer === 'not this one') return answerNo(m, q.item, ANSWER)
149 return answerYes(m, q.item, ANSWER)
150}
151
152/**
153 * The ask at a turn's end (ux-design §4.17): one item, the dialog's answer, the press. At most one
154 * per session, so a person is never asked something at every turn's end: the rest wait in the
155 * pane under **suggestions**, and the band counts them. A quiet reflex is never asked about.
156 */
157async function turnEnded(m: Mod): Promise<void> {
158 await recountNoticed(m)
159 if (m.rt.asking) return
160 m.rt.asking = true
161 try {
162 const session = await m.session.id()
163 if (m.rt.askedIn === session) return
164 await checkTamper(m)
165 await refresh(m)
166 const items = worthAsking(waiting(await m.state.reflexes.read(), await m.state.packs.read()))
167 const q = nextAsk(items, await m.state.asked.read(), await m.clock.now())
168 if (q === undefined) return
169 m.rt.askedIn = session
170 const said = await m.ui.ask(q.question, q.answers).catch(() => undefined)
171 const answer = answerOf(q, said)
172 await noteAnswer(m, q.key, answer)
173 await act(m, q, answer)
174 } finally {
175 m.rt.asking = false
176 }
177}
178
179/** `/adage on`: the one ask a command opens; the answer is the press (ux-design §7). */
180async function askOn(m: Mod): Promise<void> {
181 if (m.rt.asking) return
182 m.rt.asking = true
183 try {
184 const said = await m.ui.ask(ON_QUESTION, ON_ANSWERS).catch(() => undefined)
185 if (said === ON_ANSWERS[0]) await pressCommand(m, ANSWER, 'adage on', 'adage', 'on', 'on')
186 } finally {
187 m.rt.asking = false
188 }
189}
190
191const sameKept = (a: Kept, b: Kept) => a.command === b.command && a.root === b.root && a.at === b.at
192
193/**
194 * Puts the answers kept while no press was held to the person again, now that one is (ux-design
195 * §9.12, `ask.kept`): never pressed as they stand, since `$.store` is a file the agent's tools can
196 * write. Only those given in this repository are asked, one dialog each, and the press is that
197 * answer's. `drop it` forgets one; a dismissed dialog, a press the binary refused before the spend
198 * or no press held keeps it for the next start; one from another repository waits for a start
199 * there; one older than a week is dropped unasked.
200 */
201async function askKept(m: Mod): Promise<void> {
202 const kept = await keptNow(m)
203 const root = await repoRoot(m)
204 if (kept.length === 0 || m.rt.asking) {
205 m.rt.kept = kept.filter(k => k.root === root).length
206 return
207 }
208 m.rt.asking = true
209 const now = await m.clock.now()
210 const left: Kept[] = []
211 try {
212 for (const k of kept) {
213 if (now - k.at > KEPT_MAX_AGE_MS) continue
214 if (k.root !== root || m.rt.cli === undefined) {
215 left.push(k)
216 continue
217 }
218 // An answer no press could run (the store holds anything a file can) is never asked.
219 if (pressArgv(m.rt.cli, k.command) === undefined) continue
220 const said = await m.ui.ask(keptQuestion(k), KEPT_ANSWERS).catch(() => undefined)
221 if (said === KEPT_ANSWERS[1]) continue
222 if (said !== KEPT_ANSWERS[0]) {
223 left.push(k)
224 continue
225 }
226 const what = whatOf(k.command)
227 const pressed =
228 what === 'update'
229 ? await pressUpdate(m, ANSWER, k.key.replace(/^update:/, ''), false)
230 : await pressCommand(m, ANSWER, k.command, k.say, k.key, what, undefined, false)
231 if (pressed === undefined || (!pressed.ok && pressed.refused)) left.push(k)
232 }
233 } finally {
234 m.rt.asking = false
235 }
236 // An answer kept while the dialogs waited (a pane press with no press held) stays kept.
237 const added = keptOf(await m.store.kept.get().catch(() => undefined)).filter(x => !kept.some(k => sameKept(k, x)))
238 const after = [...left, ...added]
239 await m.store.kept.set(after).catch(() => undefined)
240 m.rt.kept = after.filter(k => k.root === root).length
241 quietly(refresh(m, true))
242}
243
244/** The press is asked for at the start, and the kept answers put again once one is held. */
245async function started(m: Mod, e: Started): Promise<void> {
246 m.rt.pressBound = true
247 await loadStore(m)
248 if (await issue(m, e)) await askKept(m)
249}
250
251/**
252 * `session.start` (the second registration): the presses are bound once a person is at the prompt
253 * (nothing is bound headless), and what the store kept across sessions is read.
254 */
255export async function onStart(m: Mod, e: EventAt<'session.start', { isInteractive: [true, false] }>, next: NextAt<'session.start', { isInteractive: [true, false] }>): AnswerAt<'session.start', { isInteractive: [true, false] }> {
256 const s = await next(e)
257 m.rt.pressBound = e.isInteractive
258 m.rt.skill = undefined
259 m.rt.noticed = 0
260 if (e.isInteractive) quietly(loadStore(m))
261 return s
262}
263
264/**
265 * `classic.SessionStart` (the second registration): the press is asked for after the command
266 * hooks' session start (the window opens there) with that payload's session id and source;
267 * headless sessions issue no token and ask nothing.
268 */
269export async function onShimStart(m: Mod, e: EventAt<'classic.SessionStart', { hook_event_name: 'SessionStart' }>, next: NextAt<'classic.SessionStart', { hook_event_name: 'SessionStart' }>): AnswerAt<'classic.SessionStart', { hook_event_name: 'SessionStart' }> {
270 const answered = await next(e)
271 if (m.rt.interactive) quietly(started(m, e))
272 return answered
273}
274
275/**
276 * The ask, at a turn's end, when the model is idle: never inside the hook (the dialog waits for
277 * the person), at most one per turn end, one per reflex per day (ask.ts).
278 */
279export async function onTurnEnd(m: Mod, e: EventAt<'turn.complete', { reason: 'answer' }>, next: NextAt<'turn.complete', { reason: 'answer' }>): AnswerAt<'turn.complete', { reason: 'answer' }> {
280 const done = await next(e)
281 if (m.rt.bin !== undefined && m.rt.interactive && e.agentId === undefined) quietly(turnEnded(m))
282 return done
283}
284hooks/draw/els.ts 8 lines1// The elements a drawing takes: the ones the terminal and the desktop both have, so one tree
2// validates on both (ux-design §4.12; T-X-07). The `Mod`'s `ui.resolve` hands them over.
3import type { Elements, RenderElement } from 'claude-code'
4
5export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input' | 'Select' | 'Link' | 'Markdown' | 'Code'>
6
7export type Tree = RenderElement
8hooks/draw/sites.ts 251 lines1// Every site Adage draws in (docs/claude-mod/ux-design.md §4–§7), one function per `ui.render`
2// registration. Each tree is a function of `$.state` (read through the `Mod`): a site draws only
3// for a person at the prompt, on the terminal or the desktop (§4.10), and returns next(e) whenever
4// it has nothing to add. The words come from view/*, the trees from the other draw/* files.
5import { paneActions, copy } from '../actions'
6import { allowActions } from '../allow'
7import { verbOf } from '../commands'
8import { pressHandlers } from '../dialog'
9import type { AnswerAt, EventAt, Mod, NextAt } from '../mod'
10import { quietly } from '../mod'
11import type { Mark } from '../plugin'
12import { noticedLines, wordCandidates } from '../notice'
13import { isRecord } from '../shape'
14import { invokedMark, verifiedMark } from '../skill'
15import { band, firedThisSession } from '../view/band'
16import { firedLines, markText, openNotice, receiptBeside, receiptCard, spinnerSuffix, turnSummary } from '../view/lines'
17import type { RowMark } from '../view/lines'
18import { inspectText, waiting, worthAsking } from '../view/pane'
19import { allowTree } from './allow'
20import { bandTree } from './band'
21import { cardTree, whyTree } from './card'
22import { inspectTree, paneTree } from './pane'
23import { beside, receipt, skillRow, under } from './rows'
24
25/** Whether a site may draw: a person at the prompt, on a surface the trees fit (ux-design §4.10). */
26function drawable(m: Mod, e: { surface: string }): boolean {
27 return m.rt.interactive && (e.surface === 'terminal' || e.surface === 'desktop')
28}
29
30/** A pane draws on the terminal and the desktop, whoever opened it. */
31const paneSurface = (e: { surface: string }): boolean => e.surface === 'terminal' || e.surface === 'desktop'
32
33/** The opening line (`open.notice`): the binary's own notice, with the glyph and no prefix. */
34export async function onInfoNotice(m: Mod, e: EventAt<'ui.render', { component: 'InfoNotice' }>, next: NextAt<'ui.render', { component: 'InfoNotice' }>): AnswerAt<'ui.render', { component: 'InfoNotice' }> {
35 if (!drawable(m, e) || m.rt.bin === undefined) return next(e)
36 const text = openNotice(e.props.text, (await m.state.mode.read()).ascii)
37 return text === undefined ? next(e) : next({ ...e, props: { ...e.props, text } })
38}
39
40/**
41 * `prompt.fired`: one line per reflex fired at the prompt, under the engine's row. The row is
42 * matched to the hook's answer by the prompt's text; the payload carries no message id, and a
43 * prompt typed twice draws the later one's lines.
44 */
45export async function onUserMessage(m: Mod, e: EventAt<'ui.render', { component: 'UserMessage' }>, next: NextAt<'ui.render', { component: 'UserMessage' }>): AnswerAt<'ui.render', { component: 'UserMessage' }> {
46 if (!drawable(m, e) || m.rt.bin === undefined || e.props.isExpanded) return next(e)
47 const fired = (await m.state.fired.read()).filter(f => f.event === 'UserPromptSubmit' && f.promptText === e.props.text)
48 const n = Math.max(0, ...fired.map(f => f.prompt ?? 0))
49 const these = fired.filter(f => f.prompt === n)
50 if (these.length === 0) return next(e)
51 const ascii = (await m.state.mode.read()).ascii
52 const lines = firedLines(these, await m.state.reflexes.read(), await m.state.rows.read(), e.viewport?.columns ?? 80, ascii)
53 return lines.length === 0 ? next(e) : under(m.ui.resolve(e), await next(e), lines)
54}
55
56/**
57 * `tool.fired`: the lines for the reflexes the command hooks delivered after a tool batch, drawn
58 * once, under the row of the batch's last call (one of `ids`), whether that row is a call's own or
59 * its group's one line.
60 */
61async function toolFired(m: Mod, ids: readonly string[], columns: number, ascii: boolean): Promise<string[]> {
62 const these = (await m.state.fired.read()).filter(f => f.ids !== undefined && f.ids.length > 0 && ids.includes(f.ids[f.ids.length - 1] ?? ''))
63 if (these.length === 0) return []
64 return firedLines(these, await m.state.reflexes.read(), await m.state.rows.read(), columns, ascii)
65}
66
67/**
68 * The marker under a tool row: a rewrite, a predicted step, a substitution, a dead end headed off;
69 * then what Adage told the agent after it (`tool.fired`).
70 */
71export async function onToolUse(m: Mod, e: EventAt<'ui.render', { component: 'ToolUse' }>, next: NextAt<'ui.render', { component: 'ToolUse' }>): AnswerAt<'ui.render', { component: 'ToolUse' }> {
72 if (!drawable(m, e) || m.rt.bin === undefined) return next(e)
73 const id = e.props.tool_use_id || e.requestId
74 const marks: RowMark[] = (await m.state.marks.read()).filter(
75 (x): x is Extract<Mark, { kind: 'rewrite' | 'substituted' | 'avoided' }> => x.id === id && (x.kind === 'rewrite' || x.kind === 'substituted' || x.kind === 'avoided'),
76 )
77 if ((await m.state.predicted.read()).some(p => p.ids.includes(id))) marks.unshift({ kind: 'predicted' })
78 const ascii = (await m.state.mode.read()).ascii
79 const lines = [...marks.map(x => markText(x, ascii)), ...(await toolFired(m, [id], e.viewport?.columns ?? 80, ascii))]
80 if (lines.length === 0) return next(e)
81 return under(m.ui.resolve(e), await next(e), lines)
82}
83
84/** The receipt card in place of a test run's output, or one line beside a run that happened. */
85export async function onToolResult(m: Mod, e: EventAt<'ui.render', { component: 'ToolResult' }>, next: NextAt<'ui.render', { component: 'ToolResult' }>): AnswerAt<'ui.render', { component: 'ToolResult' }> {
86 if (!drawable(m, e) || m.rt.bin === undefined) return next(e)
87 const id = e.props.tool_use_id || e.requestId
88 const marks = (await m.state.marks.read()).filter(x => x.id === id)
89 const ascii = (await m.state.mode.read()).ascii
90 const answered = marks.find((x): x is Extract<Mark, { kind: 'answered' }> => x.kind === 'answered')
91 if (answered !== undefined) return receipt(m.ui.resolve(e), receiptCard(answered, ascii))
92 const besides = marks.find((x): x is Extract<Mark, { kind: 'receipt' }> => x.kind === 'receipt')
93 if (besides === undefined) return next(e)
94 return under(m.ui.resolve(e), await next(e), [receiptBeside(besides.receipt, ascii)])
95}
96
97/** `spinner.suffix` (`ui.spinner`, off by default): while a reflex was delivered this turn. */
98export async function onSpinner(m: Mod, e: EventAt<'ui.render', { component: 'Spinner' }>, next: NextAt<'ui.render', { component: 'Spinner' }>): AnswerAt<'ui.render', { component: 'Spinner' }> {
99 if (!drawable(m, e) || m.rt.bin === undefined) return next(e)
100 const mode = await m.state.mode.read()
101 if (!mode.spinner) return next(e)
102 const turn = await m.state.turn.read()
103 const n = new Set((await m.state.fired.read()).slice(turn.fired).map(f => f.route)).size
104 const suffix = spinnerSuffix(n, mode.ascii)
105 return suffix === '' ? next(e) : next({ ...e, props: { ...e.props, suffix: `${e.props.suffix}${suffix}` } })
106}
107
108/** `turn.summary` (`ui.turn_line`, off by default; the terminal only): the turn's counts. */
109export async function onTurnDuration(m: Mod, e: EventAt<'ui.render', { component: 'TurnDuration' }>, next: NextAt<'ui.render', { component: 'TurnDuration' }>): AnswerAt<'ui.render', { component: 'TurnDuration' }> {
110 if (!drawable(m, e) || m.rt.bin === undefined) return next(e)
111 const mode = await m.state.mode.read()
112 if (!mode.turnLine) return next(e)
113 const turn = await m.state.turn.read()
114 const marks = (await m.state.marks.read()).slice(turn.marks)
115 const text = turnSummary(
116 (await m.state.predicted.read()).length - turn.predicted,
117 marks.filter(x => x.kind === 'answered').length,
118 marks.filter(x => x.kind === 'avoided' || x.kind === 'substituted').length,
119 mode.ascii,
120 )
121 return text === '' ? next(e) : beside(m.ui.resolve(e), await next(e), text)
122}
123
124/** The band (§5), registered unless the person turned it off (`band` in /config). */
125export async function onAbovePrompt(m: Mod, e: EventAt<'ui.render', { component: 'AbovePrompt' }>, next: NextAt<'ui.render', { component: 'AbovePrompt' }>): AnswerAt<'ui.render', { component: 'AbovePrompt' }> {
126 if (!drawable(m, e) || e.props.hasSurvey) return next(e)
127 const mode = await m.state.mode.read()
128 const view = await m.state.view.read()
129 const rows = await m.state.rows.read()
130 const turn = await m.state.turn.read()
131 const marks = await m.state.marks.read()
132 const learner = await m.state.learner.read()
133 const b = band({
134 active: mode.active,
135 nobinary: mode.nobinary,
136 paused: view.paused,
137 health: await m.state.health.read(),
138 inRepo: learner !== null,
139 reflexes: firedThisSession(await m.state.fired.read(), rows),
140 steps: (await m.state.predicted.read()).length,
141 reused: marks.filter(x => x.kind === 'answered').length,
142 learner,
143 now: await m.clock.now(),
144 lapsed: (await m.state.lapsed.read()).length,
145 review: worthAsking(waiting(await m.state.reflexes.read(), await m.state.packs.read())).length,
146 update: turn.submits === 0 && mode.update?.band === true ? { latest: mode.update.latest, next: mode.update.next } : null,
147 versionLine: turn.submits === 0 ? mode.versionLine : '',
148 columns: e.props.bodyColumns,
149 ascii: mode.ascii,
150 kept: m.rt.kept,
151 noticed: m.rt.noticed,
152 paneOpen: m.rt.paneOpen,
153 withheld: m.rt.withheldSaid !== undefined && m.rt.withheldSaid.submits === turn.submits && m.rt.withheldSaid.session === (await m.session.id()),
154 })
155 return b === undefined ? next(e) : bandTree(m.ui.resolve(e), b, mode.ascii ? '-' : '·')
156}
157
158/** The pane (§6): Graph, Live, Reflexes, Health; its presses once a person is at the prompt. */
159export async function onPane(m: Mod, e: EventAt<'ui.render', { component: 'Pane'; requestId: 'adage' }>, next: NextAt<'ui.render', { component: 'Pane'; requestId: 'adage' }>): AnswerAt<'ui.render', { component: 'Pane'; requestId: 'adage' }> {
160 if (!paneSurface(e)) return next(e)
161 const mode = await m.state.mode.read()
162 const reflexes = await m.state.reflexes.read()
163 return paneTree(
164 m.ui.resolve(e),
165 {
166 view: await m.state.view.read(),
167 rows: await m.state.rows.read(),
168 ticks: await m.state.ticks.read(),
169 health: await m.state.health.read(),
170 feed: (await m.state.feed.read()).status,
171 reflexes,
172 fired: await m.state.fired.read(),
173 waiting: waiting(reflexes, await m.state.packs.read()),
174 checks: await m.state.checks.read(),
175 lapsed: await m.state.lapsed.read(),
176 update: mode.update,
177 map: await m.state.map.read(),
178 nobinary: mode.nobinary,
179 noBridge: e.surface === 'desktop' && !mode.active,
180 columns: e.props.bodyColumns,
181 ascii: mode.ascii,
182 boxes: e.surface === 'terminal',
183 dag: await m.state.dag.read(),
184 predicted: await m.state.predicted.read(),
185 marks: await m.state.marks.read(),
186 now: await m.clock.now(),
187 motion: !mode.reducedMotion,
188 bodyRows: e.props.scroll.bodyRows,
189 },
190 { ...paneActions(m), press: m.rt.pressBound ? pressHandlers(m) : undefined },
191 )
192}
193
194/** The inspect pane over the Live tab: the selected row's directives and evidence. */
195export async function onInspectPane(m: Mod, e: EventAt<'ui.render', { component: 'Pane'; requestId: 'adage-inspect' }>, next: NextAt<'ui.render', { component: 'Pane'; requestId: 'adage-inspect' }>): AnswerAt<'ui.render', { component: 'Pane'; requestId: 'adage-inspect' }> {
196 if (!paneSurface(e)) return next(e)
197 const view = await m.state.view.read()
198 const row = (await m.state.rows.read()).find(r => r.seq === view.selected)
199 if (row === undefined) return next(e)
200 return inspectTree(m.ui.resolve(e), inspectText(row, await m.state.reflexes.read()))
201}
202
203/** `/adage` and `/adage why` drawn in their output row (§7); every other verb's line as printed. */
204export async function onCommandOutput(m: Mod, e: EventAt<'ui.render', { component: 'CommandOutput'; props: { command: 'adage' } }>, next: NextAt<'ui.render', { component: 'CommandOutput'; props: { command: 'adage' } }>): AnswerAt<'ui.render', { component: 'CommandOutput'; props: { command: 'adage' } }> {
205 if (!drawable(m, e) || e.props.isErrored) return next(e)
206 const verb = verbOf(e.props.args)
207 if (verb === 'why') return whyTree(m.ui.resolve(e), e.props.text)
208 if ((verb === 'card' || verb === 'status' || verb === 'savings') && e.props.text.includes('\n')) {
209 return cardTree(m.ui.resolve(e), e.props.text, (command, at) => quietly(copy(m, command, at)))
210 }
211 return next(e)
212}
213
214/** `noticed.forming` / `noticed.review` under the tool batch that completed a candidate (§3.10). */
215export async function onToolGroup(m: Mod, e: EventAt<'ui.render', { component: 'ToolGroup' }>, next: NextAt<'ui.render', { component: 'ToolGroup' }>): AnswerAt<'ui.render', { component: 'ToolGroup' }> {
216 if (!drawable(m, e) || m.rt.bin === undefined) return next(e)
217 const mode = await m.state.mode.read()
218 const ids = e.props.calls.map(c => c.tool_use_id).filter((id): id is string => typeof id === 'string' && id !== '')
219 if (ids.length === 0) return next(e)
220 // Expanded, each call draws its own ToolUse row, which carries `tool.fired`; collapsed, the group's line does.
221 const fired = e.props.isExpanded ? [] : await toolFired(m, ids, e.viewport?.columns ?? 80, mode.ascii)
222 if (!mode.noticed) return fired.length === 0 ? next(e) : under(m.ui.resolve(e), await next(e), fired)
223 const rows = await m.state.rows.read()
224 const lines = [...noticedLines(rows, ids, await m.state.batches.read(), await m.state.candidates.read(), mode.ascii), ...fired]
225 quietly(wordCandidates(m, rows))
226 if (lines.length === 0) return next(e)
227 return under(m.ui.resolve(e), await next(e), lines)
228}
229
230/** The skill markers (`tool.skill.invoked`, `tool.skill.verified`) under a Skill tool row (§4.8). */
231export async function onSkillToolUse(m: Mod, e: EventAt<'ui.render', { component: 'ToolUse'; props: { tool: 'Skill' } }>, next: NextAt<'ui.render', { component: 'ToolUse'; props: { tool: 'Skill' } }>): AnswerAt<'ui.render', { component: 'ToolUse'; props: { tool: 'Skill' } }> {
232 if (!drawable(m, e) || m.rt.bin === undefined) return next(e)
233 const id = e.props.tool_use_id || e.requestId
234 const name = isRecord(e.props.input) && typeof e.props.input.skill === 'string' ? e.props.input.skill : ''
235 const ascii = (await m.state.mode.read()).ascii
236 const lines: string[] = []
237 if ((await m.state.predicted.read()).some(p => p.kind === 'skill' && p.ids.includes(id))) lines.push(invokedMark(name === '' ? 'this' : name, ascii))
238 const verified = (await m.state.skills.read()).find(s => s.skill === name)
239 if (verified !== undefined) lines.push(verifiedMark(verified, ascii))
240 if (lines.length === 0) return next(e)
241 return skillRow(m.ui.resolve(e), await next(e), lines)
242}
243
244/** The grant pane (`see what it would do`, §4.17): the facts between the two acts. */
245export async function onAllowPane(m: Mod, e: EventAt<'ui.render', { component: 'Pane'; requestId: 'adage-allow' }>, next: NextAt<'ui.render', { component: 'Pane'; requestId: 'adage-allow' }>): AnswerAt<'ui.render', { component: 'Pane'; requestId: 'adage-allow' }> {
246 if (!paneSurface(e)) return next(e)
247 const allow = await m.state.allow.read()
248 if (allow === null) return next(e)
249 return allowTree(m.ui.resolve(e), allow, e.props.bodyColumns, (await m.state.mode.read()).ascii, allowActions(m))
250}
251hooks/ledger.ts 142 lines1// The harness ledger note (architecture §3.9, plan A4): at each turn's end the mod hands the
2// binary the session's usage as the engine counted it, step by step, through `adage ledger note`.
3// The binary keeps one line per session, replaced by session id, so every note carries every step
4// of the session so far. Steps Adage took in the model's place are counted with the model that
5// would have answered them (`predicted`), so the savings estimate can price what was not asked.
6// The note also carries the session's main-loop tool calls (`tool_calls`): the binary's count came
7// from a transcript pass only, so with automatic learning off every line read 0 (O0 defect 10).
8//
9// `keepStep` collects the main loop's steps at `turn.step` (step.ts), `keepCall` its tool calls at
10// `tool.call` (call.ts); `onTurnEnd` sends the note
11// at `turn.complete`, off the hook's path, through bridge.ts `note`; a refusal changes nothing.
12import type { TurnUsage } from 'claude-code'
13
14import { note } from './bridge'
15import type { AnswerAt, EventAt, Mod, NextAt } from './mod'
16
17/** One main-loop step as `adage ledger note` reads it (A3-A6-notes.md, "stdin contract"). */
18export type StepUsage = {
19 index: number
20 model: string
21 input: number
22 output: number
23 cache_read: number
24 cache_write: number
25 predicted: boolean
26}
27
28/** The steps noted for one session, in order, and its main-loop tool calls so far. */
29export type Noted = { session: string; steps: StepUsage[]; tool_calls: number }
30export const NOTED0: Noted = { session: '', steps: [], tool_calls: 0 }
31
32/** The binary refuses a note of more steps; a longer session keeps its first ones. */
33export const MAX_STEPS = 20_000
34
35/**
36 * One step's usage: the request's own counts and the model that answered it; a predicted step (no
37 * request was made) counts nothing, under the model the step asked for. A request that reported
38 * no usage (an interrupt, an API error) is no step: undefined.
39 */
40export function stepOf(asked: string, usage: TurnUsage | null | undefined, predicted: boolean): Omit<StepUsage, 'index'> | undefined {
41 if (predicted) return { model: asked, input: 0, output: 0, cache_read: 0, cache_write: 0, predicted: true }
42 if (usage === null || usage === undefined) return undefined
43 return {
44 model: usage.model || asked,
45 input: usage.input_tokens,
46 output: usage.output_tokens,
47 cache_read: usage.cache_read_input_tokens,
48 cache_write: usage.cache_creation_input_tokens,
49 predicted: false,
50 }
51}
52
53/**
54 * The session's steps and tool calls after a turn ended: the turn's steps appended, numbered on,
55 * and its calls added. A turn that ends in another session (a /clear started one) starts both
56 * counts again with that turn's own.
57 */
58export function accrue(noted: Noted, session: string, turn: readonly Omit<StepUsage, 'index'>[], calls = 0): Noted {
59 const same = noted.session === session
60 const was = same ? noted.steps : []
61 const room = Math.max(0, MAX_STEPS - was.length)
62 const counted = Number.isSafeInteger(calls) && calls > 0 ? calls : 0
63 return {
64 session,
65 steps: [...was, ...turn.slice(0, room).map((s, i) => ({ index: was.length + i, ...s }))],
66 // The binary refuses a count past u32; a session never gets near it.
67 tool_calls: Math.min(MAX_CALLS, (same ? noted.tool_calls : 0) + counted),
68 }
69}
70
71/** The most tool calls a note may carry (the binary reads a u32). */
72export const MAX_CALLS = 4_294_967_295
73
74/** The note on `adage ledger note`'s stdin. */
75export type Note = {
76 session_id: string
77 repo: string
78 harness: 'claude-code'
79 harness_version: string
80 started_at: number
81 ended_at: number
82 steps: StepUsage[]
83 /** The session's main-loop tool calls so far: the model's, the predicted ones, those a receipt answered. */
84 tool_calls: number
85 cost_usd?: number
86}
87
88/** The note for the session as it stands; the engine's cost only when it keeps one. */
89export function noteOf(n: { noted: Noted; cwd: string; version: string; startedAt: number; endedAt: number; costUsd: number | undefined }): Note {
90 return {
91 session_id: n.noted.session,
92 repo: n.cwd,
93 harness: 'claude-code',
94 harness_version: n.version,
95 started_at: n.startedAt,
96 ended_at: n.endedAt,
97 steps: n.noted.steps,
98 tool_calls: n.noted.tool_calls,
99 ...(typeof n.costUsd === 'number' && Number.isFinite(n.costUsd) && n.costUsd >= 0 ? { cost_usd: n.costUsd } : {}),
100 }
101}
102
103/** Keeps one main-loop step's usage for the note at the turn's end (a step with no usage is none). */
104export function keepStep(m: Mod, asked: string, usage: TurnUsage | null | undefined, predicted: boolean): void {
105 const step = stepOf(asked, usage, predicted)
106 if (step !== undefined) m.rt.turnSteps.push(step)
107}
108
109/**
110 * Counts one main-loop tool call for the note: every call the engine runs on the main loop, the
111 * model's own, the predicted ones and those a receipt answered, as a transcript lists them; not a
112 * subagent's, and not one a plugin raised (the mod's own `$.ui.ask`).
113 */
114export function keepCall(m: Mod, e: { agentId?: string }, origin: { plugin: string }): void {
115 if (e.agentId === undefined && origin.plugin === 'engine') m.rt.turnCalls += 1
116}
117
118/** `adage ledger note` with the session's steps so far (one line per session, replaced each turn). */
119async function noteLedger(m: Mod): Promise<void> {
120 const session = await m.session.id()
121 const calls = m.rt.turnCalls
122 m.rt.turnCalls = 0
123 m.rt.noted = accrue(m.rt.noted, session, m.rt.turnSteps.splice(0), calls)
124 if (m.rt.cli === undefined || m.rt.noted.steps.length === 0) return
125 const usage = await m.session.usage().catch(() => undefined)
126 const { version } = await m.session.version()
127 const cwd = await m.session.cwd()
128 const payload = noteOf({ noted: m.rt.noted, cwd, version, startedAt: usage?.startedAt ?? 0, endedAt: await m.clock.now(), costUsd: usage?.cost?.usd })
129 await note(m.run, m.rt.cli, payload, cwd)
130}
131
132/**
133 * The harness ledger (§3.9, plan A4): at each main-loop turn's end, the session's steps as the
134 * engine counted them go to `adage ledger note`, off the hook's path (not awaited: the note never
135 * holds the turn, and its failure is nothing).
136 */
137export async function onTurnEnd(m: Mod, e: EventAt<'turn.complete'>, next: NextAt<'turn.complete'>): AnswerAt<'turn.complete'> {
138 const done = await next(e)
139 if (m.rt.bin !== undefined && e.agentId === undefined) void noteLedger(m).catch(() => undefined)
140 return done
141}
142hooks/mod.ts 212 lines1// What every hook function is handed (docs/claude-mod/impl/mod-structure.md): the engine's
2// capabilities as plain closures (`Caps`), the mod's runtime (what one load of the module keeps
3// between hooks) and the plugin's options, together a `Mod`.
4//
5// `claude plugin validate` follows `$` only into functions declared in the file that registers the
6// hooks, and refuses `$` (or any of its nouns) passed across an import. register.ts therefore
7// builds the capabilities over its `$`, one closure per call the mod makes, and hands modules the
8// `Mod`; no other file sees `$`. A module is a function of a `Mod`, which a test builds without the
9// engine (fixtures/kit.ts `fake`).
10import type { EngineInterface, HookFor, MatchedHook, Pattern, Register, RenderSurface, Timer } from 'claude-code'
11
12import type { Run, Spawn } from './bridge'
13import type { Els } from './draw/els'
14import type { StepUsage, Noted } from './ledger'
15import { NOTED0 } from './ledger'
16import type { AdageState, Skill, StoreKey } from './plugin'
17import type { Standing } from './attach'
18import type { Withheld } from './step'
19
20/** One `$.state` value: read (its initial while nothing was written) and updated (read, apply, write). */
21export type Cell<T> = { read: () => Promise<T>; update: (change: (value: T) => T) => Promise<T> }
22
23/** Every `$.state` key of plugin.ts's `STATE0`, one cell each (register.ts binds each to its atom). */
24export type State = { readonly [K in keyof AdageState]: Cell<AdageState[K]> }
25
26/** One `$.store` key (`adage.<key>`): what it holds, untyped, since anything can write the file. */
27export type StoreCell = { get: () => Promise<unknown>; set: (value: unknown) => Promise<void> }
28
29/** Every `$.store` key of plugin.ts's `STORE_KEYS`. */
30export type Store = { readonly [K in StoreKey]: StoreCell }
31
32/** One environment variable the mod reads (each a literal name in register.ts). */
33type Env = () => Promise<string | undefined>
34
35/**
36 * The engine's capabilities the mod uses, each a closure register.ts builds over its `$`. The
37 * three runners are the only way to a process, and each is already behind its allow-list: `run`
38 * and `spawn` behind bridge.ts `guard` and `guardSpawn`, `pressRun` behind press.ts `guardPress`.
39 */
40export type Caps = {
41 state: State
42 store: Store
43 run: Run
44 spawn: Spawn
45 pressRun: Run
46 /** CLAUDE_PLUGIN_DATA; CLAUDE_CONFIG_DIR, HOME and USERPROFILE (a --plugin-dir plugin's data directory is derived from them, binary.ts `dataDir`); the mod's own switches; OS. */
47 env: { pluginData: Env; configDir: Env; home: Env; userProfile: Env; observe: Env; ascii: Env; reducedMotion: Env; os: Env }
48 /** The plugin's folder and its manifest name. */
49 plugin: { root: () => string; name: () => string }
50 fs: { read: (path: string) => Promise<unknown>; exists: (path: string) => Promise<boolean> }
51 command: Pick<EngineInterface['command'], 'register' | 'list'>
52 session: Pick<EngineInterface['session'], 'id' | 'cwd' | 'repo' | 'usage' | 'version'>
53 clock: { now: () => Promise<number>; every: (ms: number, fn: () => void) => Timer; after: (ms: number, fn: () => void) => Timer }
54 /** The person's rules on a Bash command now, as the engine decides them (`allow`, `ask`, `deny`; nothing runs). */
55 checkBash: (command: string) => Promise<string>
56 /**
57 * The engine's verdict on any tool's call now (`$.tool.check`: `{ decision, reason?, rule? }`;
58 * nothing runs, no dialog opens, auto mode's classifier is not asked).
59 */
60 checkTool: (tool: string, input: Record<string, unknown>) => Promise<unknown>
61 ui: {
62 status: (text: string) => void
63 toast: (text: string) => void
64 notice: (toolUseId: string, text: string) => void
65 open: EngineInterface['ui']['open']
66 close: EngineInterface['ui']['close']
67 ask: (question: string, answers: readonly string[]) => Promise<string>
68 copy: EngineInterface['ui']['copy']
69 scroll: EngineInterface['ui']['scroll']
70 /** Asks for the drawings to be drawn again (`ui.render`). */
71 invalidate: () => void
72 /** The elements of the surface a drawing hook draws on (`$.ui.resolve(e)`). */
73 resolve: (e: Parameters<EngineInterface['ui']['resolve']>[0]) => Els
74 }
75}
76
77/** The plugin's options (`userConfig`): `band` and `pane`. */
78export type Options = Parameters<Register>[1]
79
80/** A child the mod can end (the follower, the map). */
81type Stoppable = AsyncGenerator<unknown, unknown>
82
83/**
84 * What one load of the module keeps between hooks: none of it is drawn (what is drawn is in
85 * `$.state`), and a reload starts it again, which loses nothing the binary cannot say again.
86 */
87export type Runtime = {
88 /** The hook binary that answered the probe; undefined while the mod is inert. */
89 bin: string | undefined
90 /** `adage` itself, beside it (`<data>/bin/adage`): the verbs the mod reads and draws from. */
91 cli: string | undefined
92 /** The suffix of the shim's entries: `.exe` on Windows, where it copies `hook.exe` and `adage.exe`. */
93 exe: string
94 /** Whether a person is at the prompt (`session.start`'s `isInteractive`): nothing draws headless. */
95 interactive: boolean
96 /** The surface the session drew on at start. */
97 surface: RenderSurface | null
98 /**
99 * Observe mode (ADAGE_MOD_OBSERVE set and non-empty; the demo's `mod-observe` arm): the binary is
100 * still asked at every step and Bash call, with `observe`, and records what it would have done as
101 * the control arm does; the mod never yields a predicted call and never replaces a command.
102 */
103 observing: boolean
104 /** The follower: the session it follows, its generation (a new one ends the old), its child. */
105 feedSession: string
106 feedGen: number
107 paused: boolean
108 child: Stoppable | undefined
109 /** The redraw timers (the learner's countdown, `learned` fading, the Graph tab's newest node settling). */
110 countdown: Timer | undefined
111 fading: Timer | undefined
112 pulse: Timer | undefined
113 /** When the `--json` reads were last made, and `status --json` for the candidates' words. */
114 lastRefresh: number
115 lastCandidates: number
116 /** The hook binary's failed answers in a row (three: a status warning, ux-design §9.2). */
117 failures: number
118 /** The person's routine model (`runtime.routine_model`, from the status card); '' routes nothing. */
119 routineModel: string
120 /** The repository the session works in, as the status card names it (`repo`); '' until it is read. */
121 repo: string
122 /** Whether a session that draws nothing has read the card for it (once, at its first step that could route). */
123 settled: boolean
124 /**
125 * The session's `Standing` answer, asked once per session id (a /clear is a new one): the section,
126 * the tool description and the compaction instruction read the same text all session, so the
127 * prompt cache keeps it. A failed ask is the empty answer for the session.
128 */
129 standingFor: { session: string; answer: Promise<Standing> } | undefined
130 /** The main-loop steps and tool calls of the turn under way, and the session's noted so far. */
131 turnSteps: Omit<StepUsage, 'index'>[]
132 turnCalls: number
133 noted: Noted
134 /** One ask at a time: a second turn end while the dialog waits asks nothing. */
135 asking: boolean
136 /** The session that last had a suggestion put to it at a turn's end (one per session; a /clear is a new one). */
137 askedIn: string | undefined
138 /** Whether presses are bound (a person is at the prompt): until then a review verb is a command to copy. */
139 pressBound: boolean
140 /** Answers kept for the next session start (no press was held): the band says so. */
141 kept: number
142 /** Distinct sequences noticed this session (the band's `1 pattern noticed`). */
143 noticed: number
144 /** Whether the pane is open: the noticed cell shows only while it is closed. */
145 paneOpen: boolean
146 /** The last skill expanded with verified steps (for `/adage why`). */
147 skill: Skill | undefined
148 /** The latest predicted steps the person's permissions held back (step.ts `keepWithheld`). */
149 withheld: Withheld[]
150 /** The session whose band said why a step was held back, and the prompt count it was raised at (`band.withheld`). */
151 withheldSaid: { session: string; submits: number } | undefined
152}
153
154/** The runtime of a fresh load: inert, nothing followed, nothing asked. */
155export function runtime(): Runtime {
156 return {
157 bin: undefined,
158 cli: undefined,
159 exe: '',
160 interactive: false,
161 surface: null,
162 observing: false,
163 feedSession: '',
164 feedGen: 0,
165 paused: false,
166 child: undefined,
167 countdown: undefined,
168 fading: undefined,
169 pulse: undefined,
170 lastRefresh: -Infinity,
171 lastCandidates: -Infinity,
172 failures: 0,
173 routineModel: '',
174 repo: '',
175 settled: false,
176 standingFor: undefined,
177 turnSteps: [],
178 turnCalls: 0,
179 noted: NOTED0,
180 asking: false,
181 askedIn: undefined,
182 pressBound: false,
183 kept: 0,
184 noticed: 0,
185 paneOpen: false,
186 skill: undefined,
187 withheld: [],
188 withheldSaid: undefined,
189 }
190}
191
192/** What every hook function takes first: the capabilities, the runtime, the options. */
193export type Mod = Caps & { rt: Runtime; options: Options }
194
195/**
196 * Lets work run on without the hook waiting for it, failing quietly: a hook never fails because
197 * a drawing's read did (fail open, architecture §6 invariant 2).
198 */
199export function quietly(work: Promise<unknown>): void {
200 work.catch(() => undefined)
201}
202
203/** The hook `on(pattern)` or `on(pattern, matcher)` takes, as the engine types it. */
204type HookAt<P extends Pattern, M> = [M] extends [never] ? HookFor<P> : MatchedHook<P, M>
205
206/** A hook function's event and `next`, as the engine hands them to the hook registered for them. */
207export type EventAt<P extends Pattern, M = never> = Parameters<HookAt<P, M>>[1]
208export type NextAt<P extends Pattern, M = never> = Parameters<HookAt<P, M>>[2]
209
210/** What a hook function resolves to: the hook's answer. */
211export type AnswerAt<P extends Pattern, M = never> = Promise<Awaited<ReturnType<HookAt<P, M>>>>
212hooks/observe.ts 99 lines1// Observers of the command hooks (docs/claude-mod/architecture.md §3.1): the command hooks beneath
2// answer once; each observer returns that answer unchanged and keeps, for drawing, what it
3// delivered: the `<adage-reflex>` blocks (reflex.ts reads their tags), the tool batch the hooks saw,
4// the rewrite and the receipt the PreToolUse hook made of a Bash call; and, for the Graph tab, each
5// prompt, main-loop batch, failed call, reflex and stop as a node of this session's graph (dag.ts).
6// Nothing is kept while the mod is inert.
7import { keepNodes, toolsNode } from './dag'
8import { keepBatch, keepFired, mark } from './marks'
9import type { AnswerAt, EventAt, Mod, NextAt } from './mod'
10import type { Fired } from './plugin'
11import { parseReflexes } from './reflex'
12import { receiptOf, rewriteOf } from './view/lines'
13
14type Answered = { additionalContext?: readonly string[]; block?: string }
15
16/**
17 * The answer, unchanged, with the reflexes it delivered kept (each with `extra`) and, on the main
18 * loop (`graph`), drawn as nodes of the session's graph after the calls they followed.
19 */
20async function kept<A extends Answered>(m: Mod, answered: A, event: string, extra: Partial<Fired> = {}, graph = true): Promise<A> {
21 if (m.rt.bin === undefined) return answered
22 const fired = parseReflexes([...(answered.additionalContext ?? []), answered.block], event).map(f => ({ ...f, ...extra }))
23 if (fired.length === 0) return answered
24 await keepFired(m, fired).catch(() => undefined)
25 if (graph) {
26 const at = await m.clock.now()
27 await keepNodes(m, fired.map(f => ({ kind: 'reflex' as const, at, event, route: f.route, track: f.track, strength: f.strength, ids: f.ids ?? [] }))).catch(() => undefined)
28 }
29 return answered
30}
31
32/**
33 * A prompt the person (or the SDK) submitted on the main loop starts a turn: the turn's counts begin
34 * where the lists stand, and the version line, shown until the first prompt, is cleared for good.
35 */
36export async function onPrompt(m: Mod, e: EventAt<'classic.UserPromptSubmit'>, next: NextAt<'classic.UserPromptSubmit'>): AnswerAt<'classic.UserPromptSubmit'> {
37 const answered = await next(e)
38 if (m.rt.bin === undefined || e.agent_id !== undefined || (e.source !== undefined && e.source !== 'user' && e.source !== 'sdk')) return answered
39 const fired = await m.state.fired.read()
40 const predicted = await m.state.predicted.read()
41 const marks = await m.state.marks.read()
42 const turn = await m.state.turn.update(t => ({ submits: t.submits + 1, fired: fired.length, predicted: predicted.length, marks: marks.length }))
43 await keepNodes(m, [{ kind: 'prompt', at: await m.clock.now(), n: turn.submits, text: e.prompt }]).catch(() => undefined)
44 if ((await m.state.mode.read()).versionLine !== '') {
45 const { version } = await m.session.version()
46 await m.store.harness.set(version).catch(() => undefined)
47 await m.state.mode.update(x => ({ ...x, versionLine: '' }))
48 }
49 return kept(m, answered, 'UserPromptSubmit', { prompt: turn.submits, promptText: e.prompt })
50}
51
52/** A tool batch: its calls and when the hooks beneath ran, for matching a feed judgement to its row. */
53export async function onToolBatch(m: Mod, e: EventAt<'classic.PostToolBatch'>, next: NextAt<'classic.PostToolBatch'>): AnswerAt<'classic.PostToolBatch'> {
54 const from = m.rt.bin === undefined ? 0 : await m.clock.now()
55 const answered = await next(e)
56 if (m.rt.bin === undefined) return answered
57 const to = await m.clock.now()
58 const ids = e.tool_calls.map(c => c.tool_use_id).filter(id => typeof id === 'string' && id !== '')
59 if (ids.length > 0) await keepBatch(m, { ids, from, to }).catch(() => undefined)
60 // A subagent's own calls are its transcript's, not this one's: the graph draws the main loop.
61 const main = e.agent_id === undefined
62 const node = main ? toolsNode(e.tool_calls, from, to) : undefined
63 if (node !== undefined) await keepNodes(m, [node]).catch(() => undefined)
64 return kept(m, answered, 'PostToolBatch', ids.length > 0 ? { ids } : {}, main)
65}
66
67export async function onToolFailure(m: Mod, e: EventAt<'classic.PostToolUseFailure'>, next: NextAt<'classic.PostToolUseFailure'>): AnswerAt<'classic.PostToolUseFailure'> {
68 const answered = await next(e)
69 const main = e.agent_id === undefined
70 if (m.rt.bin !== undefined && main && e.tool_use_id) await keepNodes(m, [{ kind: 'failed', at: await m.clock.now(), id: e.tool_use_id }]).catch(() => undefined)
71 return kept(m, answered, 'PostToolUseFailure', e.tool_use_id ? { ids: [e.tool_use_id] } : {}, main)
72}
73
74/**
75 * The finish line asked the agent to continue (`toast.gate`): said once per gate, not to the model.
76 * The stop is a node of the graph (the turn's end, or the gate), before the reflexes it delivered.
77 */
78export async function onStop(m: Mod, e: EventAt<'classic.Stop'>, next: NextAt<'classic.Stop'>): AnswerAt<'classic.Stop'> {
79 const said = await next(e)
80 if (m.rt.bin !== undefined) await keepNodes(m, [{ kind: 'stop', at: await m.clock.now(), gate: said.block !== undefined }]).catch(() => undefined)
81 const answered = await kept(m, said, 'Stop')
82 if (m.rt.bin !== undefined && m.rt.interactive && answered.block !== undefined) m.ui.toast('adage: finish line · the agent continues once')
83 return answered
84}
85
86/**
87 * What the PreToolUse command hook did to a Bash call (a rewrite to the project's interpreter, a
88 * receipt delivered beside it): kept for the marker under its row. The answer is unchanged.
89 */
90export async function onPreToolUse(m: Mod, e: EventAt<'classic.PreToolUse'>, next: NextAt<'classic.PreToolUse'>): AnswerAt<'classic.PreToolUse'> {
91 const answered = await next(e)
92 if (m.rt.bin === undefined || e.tool !== 'Bash') return answered
93 const rw = rewriteOf(e.command, answered.updatedInput?.command)
94 if (rw !== undefined) await mark(m, { id: e.tool_use_id, kind: 'rewrite', ...rw }).catch(() => undefined)
95 const rc = receiptOf(answered.additionalContext ?? [])
96 if (rc !== undefined) await mark(m, { id: e.tool_use_id, kind: 'receipt', receipt: rc }).catch(() => undefined)
97 return answered
98}
99