SLOPSHOPPER

adage

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

panebandspinnerrowsguardcommand
★ 5v0.1.1Apache-2.0updated 2026-10-17stevenvaughanbooth/adage/plugin/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · adage
│ ┃ adage ✕ › fix the failing auth test and add an audit log call │ ┃ g: Graph l: Live r: Reflexes t: Health │ ┃ ◆ this session · adage has fired no reflex y ⏺ Read(src/auth.ts) │ ┃ Nothing yet. This graph grows as the agent ⎿ Read 6 lines │ ┃ works: each prompt, each step, and every ⏺ Update(src/auth.ts) │ ┃ reflex Adage fires. ⎿ Added 2 lines, removed 1 line │ ┃ Explore in browser ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · adage
g: Graph l: Live r: Reflexes t: Health esc: close ◆ this session · adage has fired no reflex yet Nothing yet. This graph grows as the agent works: each prompt, each step, and every reflex Adage fires. Explore in browser
README

The adage plugin for Claude Code

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).

Installing and updating

  • From a terminal: install with 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.
  • In Claude Code: /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.
  • To develop it: 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.
  • To remove it: 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.

Where it sits

  • One plugin ships three binaries for each release target (plan A8, onboarding plan O1): 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.
  • The mod reaches the binary only by a short list of exact argument vectors (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.

What's inside

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:

ConcernModules
The bridge to the binarybridge.ts, binary.ts, press.ts, reads.ts, refresh.ts
The session and the feedsession.ts, feed.ts, follower.ts, notify.ts
Observing and deciding at the hooksobserve.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 decisionsdialog.ts, ask.ts, token.ts, kept.ts, allow.ts, grant.ts, commands.ts, actions.ts
What is drawnview/{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)
Sharedmod.ts (the Mod), plugin.ts (constants and the tables), shape.ts (reading JSON)

The capability object

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.

The state keys

$.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).

How it connects

  • In: Claude Code raises an event; register.ts's hook builds the Mod and calls <module>.on<Name>(mod($, options), e, next).
  • To the binary: 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).
  • Out: the hook's answer, unchanged unless the binary said otherwise; what to draw goes to $.state, and each site draws from it (draw/sites.ts).

Changing it

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.

Source 50 files
hooks/register.ts 170 lines
1// 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}
170
hooks/actions.ts 114 lines
1// 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}
114
hooks/attach.ts 176 lines
1// 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}
176
hooks/bridge.ts 351 lines
1// 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}
351
hooks/call.ts 289 lines
1// 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}
289
hooks/commands.ts 154 lines
1// `/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}
154
hooks/dialog.ts 284 lines
1// 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}
284
hooks/draw/els.ts 8 lines
1// 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
8
hooks/draw/sites.ts 251 lines
1// 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}
251
hooks/ledger.ts 142 lines
1// 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}
142
hooks/mod.ts 212 lines
1// 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>>>>
212
hooks/observe.ts 99 lines
1// 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