An exoskeleton for Claude Code: guards against costly mistakes, a cockpit while working, a recap at the end and a few extras

<a href="https://github.com/pepperonas/exo"><img src="docs/social.png" alt="exo — an exoskeleton for Claude Code: guards, cockpit, recap and extras" width="100%"></a>
An exoskeleton for Claude Code: one mod that stops the expensive mistakes before they happen, keeps a cockpit in view while you work, looks back when you're done — and brings a few extras that are fun and still useful.
<a href="#-install"><img alt="Install in 10 seconds" height="56" src="https://img.shields.io/badge/%E2%AC%87%EF%B8%8F_Install-in_10_seconds-2E9E5B?style=for-the-badge"></a> <a href="#-modules"><img alt="14 modules in one mod" height="56" src="https://img.shields.io/badge/%F0%9F%A7%A9_Modules-14_in_one_mod-7B4DFF?style=for-the-badge"></a>
<h3>👉 <code>/plugin marketplace add pepperonas/exo</code> · <code>/plugin install exo@pepperonas-exo</code> — that's it.</h3>
[!WARNING] exo is a seat belt, not a sandbox. It catches the common, expensive mistakes — a key in a commit,
systemctl restarton production,rm -rfwithout a way back. A deliberately obfuscated command, or an agent that rewrites the mod itself, can get past it. See Limits.
<img src="docs/hero.png" alt="Claude Code after a change: under the prompt, exo's line shows ⛨ exo · ● 5/5 · ⏱ 0:02 today" width="100%">
<sub>Where it lives: one line under the prompt — exo is active, the test light is green with 5 of 5, today's active time. (The bars above it are <a href="https://github.com/pepperonas/usage-bars">usage-bars</a>, another mod.)</sub>
<img src="docs/prod-shield.png" alt="The prod shield stops sqlite3 shop.db 'DROP TABLE orders' and asks: Run or Cancel" width="100%">
<sub><b>Prod shield</b> — a destructive SQL statement is put to you before it runs.</sub>
<img src="docs/secret-guard.png" alt="The secret guard refuses git commit: an Anthropic API key in src/config.js and vendor-key.txt, shown masked" width="100%">
<sub><b>Secret guard</b> — the commit is refused; the key appears only masked (<code>sk-ant-…JDAA</code>).</sub>
<img src="docs/cleanup-brake.png" alt="After rm -rf dist, /undo-list shows the snapshot and /undo-last restores it" width="100%">
<sub><b>Cleanup brake</b> — <code>rm -rf dist</code> ran, but a snapshot was taken first; <code>/undo-last</code> brings it back.</sub>
<img src="docs/testlight-consent.png" alt="Test light asks once per project whether it may run npm test --silent after every change" width="100%">
<sub><b>Test light</b> — it runs commands from your project, so it asks first, once per project.</sub>
<img src="docs/changes.png" alt="/changes opens a side pane with the changed file, its diff and a Revert button" width="100%">
<sub><b>Changes sidebar</b> — every file changed in the session, its diff, and revert.</sub>
<img src="docs/spinner-cinema.png" alt="The spinner shows a test-tube film and the running commands while tests run" width="100%">
<sub><b>Spinner cinema</b> — a small film per activity, here while tests run.</sub>
<img src="docs/recap.png" alt="/recap card: duration, turns, cost, files, tests before and after, open points" width="100%">
<img src="docs/exo-status.png" alt="/exo status table: every module with state, calls and timing, prod hosts, house rules, kill switch" width="100%">
<img src="docs/achievements.png" alt="/achievements card with unlocked and pending badges" width="100%">
<sub>Every picture is a real Claude Code 2.1.289 session with exo loaded, recorded in tmux and rendered by <code>npm run screens</code> — only whole lines above the relevant prompt are cropped; see <a href="docs/SCREENSHOTS.md">docs/SCREENSHOTS.md</a>.</sub>
rm -rf, git reset --hard or git clean, exo takes a snapshot; /undo-last brings it back.$(…), backticks, arithmetic, bash -c, ssh host '…', find -exec, sudo/env/xargs wrappers and heredocs, so a destructive command can't hide in a substitution.CLAUDE.md that are only written when you tick them.gh for the CI light and one model call when you ask for /recap.| Group | Module | What it does | Command | |
|---|---|---|---|---|
| 🛡 Guards | Secret guard (secrets) | Checks Write/Edit, Bash writes (>, >>, tee, heredocs), git commit (message and diff) and git push for API keys, tokens, private keys, passwords and high-entropy strings. Messages only ever show the masked form (sk-ant-…a1b2). | – | |
| 🛡 Guards | Prod shield (prodShield) | Asks before ssh/scp/rsync/sftp to production hosts (also via ~/.ssh/config aliases, -J, -o HostName), before systemctl restart/stop/reload there, before DROP/TRUNCATE/DELETE/UPDATE without WHERE and before a force push to main/master. Dialog: run / cancel / dry run. Checks the house rules first. | – | |
| 🛡 Guards | Cleanup brake (brake) | Snapshot before rm -rf, git reset --hard, git checkout -- ., git restore, git clean -f… — tracked changes as a stash commit (kept alive by a ref), untracked files as a tarball. | /undo-last [id], /undo-list | |
| 🛡 Guards | Context diet (diet) | Large, long or generated files (lockfiles, *.min.js, *.map, logs) are read as head + tail instead of whole, with a note and the estimated saving. | – | |
| 🧭 Cockpit | Test light (testLight) | After changes, the affected tests run in the background (vitest, jest, node --test, pytest, cargo, gradle, go). Shows ● 48/48 or ● 2 red; red tests go into your next prompt. Asks for permission per project. | – | |
| 🧭 Cockpit | Done check (doneCheck) | When an answer claims "done" or "works" although files changed and no green test ran afterwards: a quiet note under the answer. | – | |
| 🧭 Cockpit | Changes sidebar (sidebar) | Every file changed in the session with +/−, its diff, and revert (with confirmation and a snapshot). | /changes | |
| 🧭 Cockpit | CI light (ci) | State of the branch's GitHub Actions runs (needs gh). On red: a toast and a button that hands the log to Claude. | – | |
| 🔁 Review | Recap (recap) | A card with duration, active time, turns, files, tests before/after, cost and open points. A "last session …" hint the next time you start in that project. | `/recap [md\ | copy]` |
| 🔁 Review | Lessons (lessons) | Suggests 1–3 lessons from the session for the project's CLAUDE.md. Nothing is written without a tick. | (in /recap) | |
| 🔁 Review | Time tracking (hours) | Active time per project (a gap over 5 min is a break), today's total in the hint line. | `/hours [export csv\ | json]` |
| 🎉 Extras | Achievements (achievements) | 15 badges — ten green turns in a row, early bird, Pac-Man never saw the ghost, … | /achievements | |
| 🎉 Extras | Spinner cinema (cinema) | Small films in the spinner per activity: an excavator during npm install, a rocket on deploy, a detective during grep, coffee on long turns. | – | |
| 🎉 Extras | Rubber duck (duck) | Five debugging questions, turned into a clean prompt you can edit. | /duck |
And the core: /exo shows state, time spent and last errors of every module, and switches them.
gh (logged in) for the CI light; git for the cleanup brake's stash snapshots; macOS for sound.The repository is its own plugin marketplace. In Claude Code:
/plugin marketplace add pepperonas/exo
/plugin install exo@pepperonas-exo
or from the shell:
claude plugin marketplace add pepperonas/exo
claude plugin install exo@pepperonas-exo
Update with /plugin marketplace update pepperonas-exo, then claude plugin update exo@pepperonas-exo. Settings: /plugin configure exo@pepperonas-exo — every option has a default, so you can skip it.
Claude Code loads a plugin it finds in ~/.claude/skills/<name> by itself, in every session:
git clone https://github.com/pepperonas/exo ~/.claude/skills/exo
Update with git -C ~/.claude/skills/exo pull. If you also install it from the marketplace, the marketplace copy wins.
git clone https://github.com/pepperonas/exo
claude --plugin-dir ./exo
Where you can't pass a flag, name the folder in CLAUDE_CODE_PLUGIN_DIRS — in your shell environment or in the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/src/exo" } }
exo works without any setup. Without production hosts, the prod shield only covers SQL and force pushes.
In ~/.claude/settings.json — never in a repository:
{
"pluginConfigs": {
"exo": {
"options": {
"prodHosts": ["web=203.0.113.10", "shop=shop.example.com"]
}
}
}
}
The prod shield recognises a host by its name, its address and every Host entry in ~/.ssh/config whose HostName points to it. You can also change the list in /config.
| Command | What it does | |
|---|---|---|
/exo | Status of every module: on/off, time spent, last error, kill switch, rules | |
/exo on · /exo off | Everything on / off (stored) | |
| `/exo on\ | off <module>` | One module, e.g. /exo off cinema |
/exo reset <module> | Clears a module's broken mark | |
/exo rules | The house rules in force | |
/undo-last [id] · /undo-list | Restore a cleanup-brake snapshot · list them | |
/changes | The changes sidebar | |
| `/recap [md\ | copy]` | Recap of this session; md saves it as .exo/recap-<date>.md in the project, copy puts it on the clipboard |
| `/hours [export csv\ | json]` | Active time per project this week; export for your own use |
/achievements | Your badges | |
/duck | Rubber-duck debugging |
While you type /exo the line under the prompt lists what may follow and narrows as you type — status · on · off · reset · rules · help, then the module ids after on, off or reset; with one match left it says what it does. It is a list to read, not tab completion: Claude Code completes command names, but gives mods no way to complete arguments.
If another plugin already owns a name, exo registers its command as /exo-<name> instead.
All options are in /config under exo, or in pluginConfigs.exo.options in ~/.claude/settings.json.
| Option | Type | Default | Meaning |
|---|---|---|---|
secrets | switch | on | Secret guard |
prodShield | switch | on | Prod shield |
brake | switch | on | Cleanup brake |
diet | switch | on | Context diet |
testLight | switch | on | Test light |
doneCheck | switch | on | Done check |
sidebar | switch | on | Changes sidebar |
ci | switch | on | CI light |
recap | switch | on | Recap |
lessons | switch | on | Lessons |
hours | switch | on | Time tracking |
achievements | switch | on | Achievements |
cinema | switch | on | Spinner cinema |
duck | switch | on | Rubber duck |
prodHosts | list of name=address | empty | Production hosts |
secretAllowPaths | list of globs | /test/fixtures/, **/*.example, **/.env.example | Paths where secrets are allowed |
snapshotMaxMb | number | 500 | Above this the cleanup brake asks instead of saving silently |
dietMaxKb | number | 256 | From this size Claude reads head and tail only |
dietMaxLines | number | 2000 | Likewise from this many lines |
testCommand | text | empty | Overrides runner detection; {files} becomes the changed files |
quietHours | HH-HH | 22-07 | No achievement toasts or sounds during these hours |
sound | switch | off | Sound on new badges (macOS only) |
reducedMotion | switch | off | Spinner cinema as a still image |
/exo on|off <module> overrides the switches for you, persistently (kept in exo's store).
~/.claude/exo/rules.json)Rules the prod shield checks before a command. If the file is missing, exo creates it with this default rule:
{
"version": 1,
"rules": [
{
"id": "nginx-certbot",
"hosts": ["*"],
"match": "\\bnginx\\b",
"check": ["ssh", "{host}", "pgrep", "-x", "certbot"],
"blockWhen": "exit0",
"text": "Never change nginx while certbot is running (certbot is running on this host right now)."
}
]
}
| Field | Meaning |
|---|---|
id | Lower-case letters, digits, - |
hosts | Names from prodHosts, or * |
match | Regular expression on the command |
check | Check command as argv; {host} becomes the address |
blockWhen | exit0 (block when the check exits 0) or exitNonZero |
text | Reason shown when the command is refused |
A broken or invalid file never crashes exo: the built-in rules apply, and /exo plus a toast tell you what's wrong.
Three ways to switch exo off completely:
touch ~/.claude/exo/DISABLED — works immediately, from a second terminal too, even if exo is blocking Bash in this session.EXO_DISABLE=1 claude … — for the whole session./exo off — stored; /exo on lifts it.Claude itself can't switch exo off: changes to ~/.claude/exo/, to exo's entries in the settings and to its stored switches need your allow in a dialog. If a call changes them anyway (say, through an obfuscated command), exo asks afterwards whether the change should stay, and reverts it without your consent.
tool.call ─► kill switch? ─► parse Bash once ─► self-protection (text check)
│
┌────────────────────────────────────────────────────┘
▼
secrets ─► prodShield ─► diet ─► brake ─► testLight · sidebar · doneCheck · ci · recap · …
(fail closed) (fail open) (comfort modules, fail open)
│
▼
next() ─► journal file.changed ─► effect check (control state before/after) ─► after-steps
hooks/register.tsx is the only file that talks to Claude Code's engine ($). Core and modules work against a small Host interface — which is why over 500 tests run on plain Node.gh (CI light) and one model call through Claude Code's own session when /recap lists open points.~/.claude/exo/: rules.json, snapshots (snapshots/, newest 20 or 7 days), the sidebar's originals (originals/), hour exports./exo shows the state; if ⛨ exo is missing under the prompt, exo is not active.claude -p the prod shield refuses, the test light starts nothing, and control changes are reverted.cds between calls. After cd - or cd $VAR it no longer knows the place for sure.sed -i, scripts).~/.ssh/config: Include and Match are not evaluated.node --test/npm test the whole suite runs (there is no reliable file → test mapping). It runs commands from your project — so it asks per project, and again when the test configuration changes.CLAUDE.md, recap files) check that the resolved path lies inside the project. Between check and write there is a short window ($.fs has no atomic rename).afplay).| Path | Role |
|---|---|
hooks/register.tsx | The mod: registration, the hostOf($) adapter, $.state atoms, render hooks, commands |
core/adapter/host.ts | The Host interface everything else works against |
core/dispatcher/ | Fixed step order, error policy, budgets |
core/shell/ | Bash parser, command words, git calls |
core/selfprotect.ts · core/integrity.ts | Text check and effect check for exo's own control state |
core/journal/ · core/store/ | Session journal; store with budgets and migrations |
core/statusline/ | Status line slots and banners |
modules/waechter/ | Guards: secret guard, prod shield, cleanup brake, context diet |
modules/cockpit/ | Test light, done check, changes sidebar, CI light |
modules/rueckblick/ | Review: recap, lessons, time tracking |
modules/extras/ | Achievements, spinner cinema, rubber duck |
tools/ | Mutation probe, history scan, social card, screenshot renderer |
docs/PLAN.md | The design and its decisions |
Detection logic is pure and engine-free (*-logic.ts); the modules around it do the I/O.
Node suite — tests/*.spec.ts, plain node:test, no Claude Code needed; this is what CI runs. Parser, detection rules, dispatcher order and error policy, self-protection, effect check, snapshots against a real temporary repository, and drift guards that hold this README to the code: every option, module and command, the default house rule, the version, and the test-count badges.
Engine suite — hooks/*.test.tsx, run by claude plugin test . against Claude Code's own engine: registration, the status line, refusals, dialogs, the kill switch.
Every security-relevant test is seen red once. npm run mutate copies the repo, puts a bug back (proven by checksum), and expects the named tests to fail — 174 of 174 mutations are caught. The protocol is in docs/MUTATIONS.md.
npm install # dev tools only; the mod itself has no dependencies
npm thooks/register.tsx 566 lines1/**
2 * exo – the one registration. Every hook checks the kill switch first and
3 * hands the work to the core; nothing here decides anything itself.
4 */
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { Banner, ChangeRow, DuckState, Slot } from '../types'
9import type { Host, Timer } from '../core/adapter/host'
10import { complete, isOurs } from '../core/complete'
11import { exoCommand } from '../core/exo-command'
12import { errorText } from '../core/health/health'
13import { L } from '../core/i18n'
14import { catchDecision, createRuntime, endModules, killReason, log, moduleEnv, promptContexts, refreshLiveness, saveJournalLater, startModules, toolCall, turnTexts } from '../core/runtime'
15import type { Runtime } from '../core/runtime'
16import { KillSwitch } from '../core/killswitch'
17import { isMissingError } from '../core/safepath'
18import { DISMISS, pressBanner, pressPane } from '../core/statusline/banners'
19import { refreshChanges } from '../modules/cockpit/sidebar'
20import { hoursCommand } from '../modules/rueckblick/hours'
21import { recapCommand } from '../modules/rueckblick/recap'
22import { achievementsCard } from '../modules/extras/achievements'
23import { spinnerMessage } from '../modules/extras/cinema'
24import { DUCK, QUESTIONS, answer as answerDuck, buildPrompt, done, freshDuck } from '../modules/extras/duck-logic'
25import { pruneSnapshots } from '../modules/waechter/brake'
26import { undoLast, undoList } from '../modules/waechter/undo'
27import { layout } from '../core/statusline/statusline'
28
29const COMMAND = 'exo'
30const CHANGES = 'changes'
31const CHANGES_PANE = 'exo-changes'
32const DUCK_PANE = 'exo-duck'
33
34function shortPath(p: string, max: number): string {
35 return p.length <= max ? p : '…' + p.slice(p.length - Math.max(8, max - 1))
36}
37/** Commands besides /exo: name, description, argument hint. */
38const COMMANDS: [string, string, string][] = [
39 ['undo-last', "exo: restore the cleanup brake's last snapshot", '[id]'],
40 ['undo-list', "exo: list the cleanup brake's snapshots", ''],
41 ['recap', 'exo: recap of this session, open items, lessons', '[md|copy]'],
42 ['hours', 'exo: active time per project this week', '[export csv|json]'],
43 ['duck', 'exo: rubber duck – debugging with five questions', ''],
44 ['achievements', 'exo: badges earned', ''],
45]
46/** Registered name → command it stands for (exo-undo-last when undo-last is taken). */
47const commandNames = new Map<string, string>()
48const TICK_MS = 2_000
49
50/** Whether the line under the prompt is listing `/exo` arguments right now. */
51let listing = false
52
53/** Redraw for a draft that is `/exo …`, or that just stopped being one. */
54function redrawIfOurs($: EngineInterface, text: string): void {
55 const ours = isOurs(text)
56 if (ours || listing) $.ui.invalidate('ui.render')
57 listing = ours
58}
59
60// exo's `$.state` values; the engine reads their refs from this file.
61const slotsA = atom({ plugin: 'exo', key: 'slots' } as const, {} as Record<string, Slot>)
62const bannersA = atom({ plugin: 'exo', key: 'banners' } as const, [] as Banner[])
63const killedA = atom({ plugin: 'exo', key: 'killed' } as const, null as string | null)
64const changesA = atom({ plugin: 'exo', key: 'changes' } as const, [] as ChangeRow[])
65const selectedA = atom({ plugin: 'exo', key: 'selected' } as const, null as string | null)
66const diffA = atom({ plugin: 'exo', key: 'diff' } as const, '')
67const duckA = atom({ plugin: 'exo', key: 'duck' } as const, { step: 0, answers: [] } as DuckState)
68
69/**
70 * The adapter: the one place that speaks to `$`. Everything else works
71 * against `Host`; a change in the engine API is fixed here alone.
72 */
73function hostOf($: EngineInterface): Host {
74 return {
75 now: () => $.clock.now(),
76 env: name => (name === 'HOME' ? $.env.get('HOME') : name === 'EXO_DISABLE' ? $.env.get('EXO_DISABLE') : $.env.get('NO_COLOR')),
77 readFile: path => $.fs.read(path),
78 writeFile: (path, text) => $.fs.write(path, text),
79 exists: path => $.fs.exists(path),
80 stat: async path => {
81 const s = await $.fs.stat(path)
82 return { kind: s.kind, size: s.size, mtimeMs: s.mtimeMs }
83 },
84 list: async path => (await $.fs.list(path)).map(e => e.name),
85 isLink: async path => {
86 try {
87 return (await $.fs.stat(path)).isLink
88 } catch (err) {
89 // only "not there" means not a link; any other failure is passed on,
90 // so a guard asking this denies instead of guessing
91 if (isMissingError(err)) return false
92 throw err
93 }
94 },
95 realPath: async path => {
96 const s = await $.fs.stat(path, { resolve: true })
97 // No answer is no resolution: never fall back to the unresolved path.
98 if (!s.realPath) throw new Error('realPath not available')
99 return s.realPath
100 },
101 run: async (argv, o) => {
102 const r = await $.process.run(argv, o)
103 return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
104 },
105 storeGet: key => $.store.get(key),
106 storeSet: (key, value) => $.store.set(key, value),
107 storeDelete: key => $.store.delete(key),
108 storeKeys: () => $.store.keys(),
109 after: (ms, fn) => $.clock.after(ms, fn),
110 every: (ms, fn) => $.clock.every(ms, fn),
111 toast: (text, timeoutMs) => $.ui.toast(text, timeoutMs ? { timeoutMs } : undefined),
112 log: text => $.ui.log(text),
113 ask: (question, options) => $.ui.ask(question, options),
114 sessionId: () => $.session.id(),
115 cwd: () => $.session.cwd(),
116 repoRoot: async () => (await $.session.repo())?.root ?? null,
117 setSlot: async (id, slot) => {
118 await update($, slotsA, all => {
119 const next = { ...all }
120 if (slot) next[id] = slot
121 else delete next[id]
122 return next
123 })
124 },
125 setBanner: async (id, banner) => {
126 await update($, bannersA, list => {
127 const rest = list.filter(b => b.id !== id)
128 return banner ? [...rest, banner] : rest
129 })
130 },
131 setKilled: async reason => {
132 await update($, killedA, () => reason)
133 },
134 spawn: (argv, o) => {
135 const s = $.process.spawn({ argv: [...argv], cwd: o?.cwd })
136 return {
137 chunks: s,
138 stop: () => void s.return({ code: null, signal: 'stop' }).catch(() => undefined),
139 done: s.result.then(r => ({ code: r.code })),
140 }
141 },
142 fillPrompt: async text => {
143 const r = await $.prompt.fill({ text, mode: 'append' })
144 return r.isFilled
145 },
146 openPane: async (id, title) => (await $.ui.open({ id, title })).isPlaced,
147 redraw: () => $.ui.invalidate('ui.render'),
148 playSound: async asset => {
149 await $.audio.play({ asset }).catch(() => undefined)
150 },
151 usage: async () => {
152 const u = await $.session.usage()
153 return { contextPercent: u.context.percent, fiveHour: u.rateLimits.find(l => l.kind === 'five_hour')?.percentUsed }
154 },
155 usageBars: async () => (await $.config.list()).some(r => r.key.startsWith('usage-bars.')),
156 openDialog: async (id, title) => (await $.ui.open({ id, title, focus: true, closeOnEscape: true })).isPlaced,
157 closePane: async id => {
158 await $.ui.close({ id })
159 },
160 messages: async () => (await $.session.messages()).map(m => ({ role: m.role, text: m.text })),
161 complete: async (prompt, system) => {
162 const r = await $.model.complete({ model: 'haiku', prompt, system, maxTokens: 800, timeoutMs: 20_000 })
163 return r.isAnswered ? r.text : null
164 },
165 copy: async text => (await $.ui.copy({ text })).isCopied,
166 sessionCost: async () => (await $.session.usage()).cost?.usd ?? null,
167 askMany: async (question, options) => {
168 const answer = await $.ui.ask(question, { options, multiSelect: true })
169 return options.filter(o => answer.split(',').map(x => x.trim()).includes(o))
170 },
171 setChanges: async (changes, selected, diff) => {
172 await update($, changesA, () => changes)
173 await update($, selectedA, () => selected)
174 await update($, diffA, () => diff)
175 },
176 }
177}
178
179let runtime: Promise<Runtime> | undefined
180let interactive = true
181let tick: Timer | undefined
182let opts: Readonly<Record<string, unknown>> = {}
183
184/** The runtime; a failed build is forgotten, so the next call tries again. */
185function ensure($: EngineInterface): Promise<Runtime> {
186 runtime ??= createRuntime(hostOf($), opts, interactive).catch(err => {
187 runtime = undefined
188 throw err
189 })
190 return runtime
191}
192
193/**
194 * The kill switch without the runtime: the DISABLED file and EXO_DISABLE
195 * work even when exo's own state cannot be built.
196 */
197const rawKill = new KillSwitch()
198async function killedWithoutRuntime($: EngineInterface): Promise<boolean> {
199 try {
200 const home = await $.env.get('HOME')
201 return (await rawKill.reason(hostOf($), home, false)) !== null
202 } catch {
203 return false
204 }
205}
206
207/** The runtime, or null when exo is switched off by its kill switch. */
208async function live($: EngineInterface): Promise<{ rt: Runtime; host: Host } | null> {
209 const rt = await ensure($)
210 const host = hostOf($)
211 return (await killReason(rt, host)) ? null : { rt, host }
212}
213
214/**
215 * Everything that starts a conversation: modules, journal, liveness, the
216 * ticker. Runs on `session.start` and again after a `/clear` or a resume,
217 * for which the engine fires no `session.start` (the process goes on under a
218 * new session id, with fresh `$.state`).
219 */
220async function begin($: EngineInterface, rt: Runtime, host: Host, cwd: string): Promise<void> {
221 if (rt.home) void pruneSnapshots(host, rt.store, rt.home, await $.clock.now()).catch(() => undefined)
222 await startModules(rt, host)
223 if (rt.journal.size() === 0 || rt.journal.last('session.start')?.sessionId !== rt.journal.sessionId)
224 await log(rt, host, { type: 'session.start', sessionId: rt.journal.sessionId, project: (await host.repoRoot().catch(() => null)) ?? cwd })
225 if (rt.rules.errors.length && !rt.rulesWarned) {
226 rt.rulesWarned = true
227 host.toast(L.rulesWarning, 8000)
228 }
229 rt.shownLiveness = undefined
230 await refreshLiveness(rt, host)
231 tick?.cancel()
232 tick = $.clock.every(TICK_MS, () => {
233 void (async () => {
234 await afterClear($)
235 await refreshLiveness(await ensure($), hostOf($))
236 })().catch(() => undefined)
237 })
238}
239
240/** Set by `session.end` when the conversation ends but the process goes on. */
241let cleared = false
242
243/** After a `/clear` or a resume: a fresh runtime for the new session, then `begin`. */
244async function afterClear($: EngineInterface): Promise<void> {
245 if (!cleared) return
246 cleared = false
247 runtime = undefined
248 const rt = await ensure($)
249 await begin($, rt, hostOf($), await $.session.cwd())
250}
251
252export const register: Register = (on, options) => {
253 opts = options
254 runtime = undefined
255
256 on('session.start', async ($, e, next) => {
257 interactive = e.isInteractive
258 const rt = await ensure($)
259 rt.interactive = e.isInteractive
260 const host = hostOf($)
261 await $.command.register({
262 name: COMMAND,
263 description: 'exo: module state, switches, kill switch (/exo help)',
264 argumentHint: '[on|off [module]|reset <module>|rules|help]',
265 immediate: true,
266 })
267 const taken = new Set((await $.command.list()).filter(c => c.plugin !== 'exo').map(c => c.name))
268 for (const [name, description, argumentHint] of COMMANDS) {
269 const final = taken.has(name) ? `exo-${name}` : name
270 commandNames.set(final, name)
271 await $.command.register({ name: final, description, argumentHint, immediate: true })
272 }
273 await $.command.register({ name: CHANGES, description: 'exo: files changed in this session, with diff', immediate: true })
274 await begin($, rt, host, e.cwd)
275 return next(e)
276 })
277
278 on('tool.call', async ($, e, next) => {
279 if (await killedWithoutRuntime($)) return next(e)
280 const rt = await ensure($)
281 const host = hostOf($)
282 const call = { tool: String(e.tool), input: e as unknown as Record<string, unknown>, id: e.tool_use_id }
283 const result = await toolCall(rt, host, call, input => next({ ...e, ...input } as typeof e) as Promise<never>)
284 return result as Awaited<ReturnType<typeof next>>
285 }).catch(async ($, e, next) => {
286 let killed = await killedWithoutRuntime($)
287 if (!killed) {
288 try {
289 const rt = await ensure($)
290 killed = (await killReason(rt, hostOf($))) !== null
291 } catch {
292 // no runtime: only the raw kill switch counts
293 }
294 }
295 const d = catchDecision(String(e.tool), killed, next.called, errorText(next.error))
296 if (d === 'leave') return undefined
297 if (d === 'pass') return next(e)
298 return d
299 })
300
301 on('prompt.submit', async ($, e, next) => {
302 await afterClear($).catch(() => undefined)
303 const l = await live($).catch(() => null)
304 if (!l) {
305 const r = await next(e)
306 redrawIfOurs($, '')
307 return r
308 }
309 await log(l.rt, l.host, { type: 'prompt.submit', chars: e.text.length }).catch(() => undefined)
310 const extra = await promptContexts(l.rt, l.host).catch(() => [])
311 const r = await next(extra.length ? { ...e, context: [...(e.context ?? []), ...extra] } : e)
312 redrawIfOurs($, '')
313 return r
314 })
315
316 // Claude Code completes the command's name, not its arguments, so the line
317 // under the prompt lists what may follow while the draft is `/exo …`.
318 // Only such drafts redraw: typing a normal prompt costs nothing.
319 on('prompt.edit', async ($, e, next) => {
320 const r = await next(e)
321 redrawIfOurs($, r.text)
322 return r
323 })
324
325 on('turn.start', async ($, e, next) => {
326 await afterClear($).catch(() => undefined)
327 const l = await live($).catch(() => null)
328 if (l) {
329 l.rt.journal.turnId = e.turnId
330 await log(l.rt, l.host, { type: 'turn.start', turnId: e.turnId }).catch(() => undefined)
331 }
332 return next(e)
333 })
334
335 on('turn.complete', async ($, e, next) => {
336 const l = await live($).catch(() => null)
337 if (!l || e.agentId) return next(e)
338 await log(l.rt, l.host, { type: 'turn.complete', turnId: e.turnId, ms: e.durationMs, claims: [], reason: e.reason }).catch(() => undefined)
339 const texts = await turnTexts(l.rt, l.host, { turnId: e.turnId, answer: e.answer, reason: e.reason }).catch(() => [])
340 l.rt.journal.turnId = undefined
341 const r = await next(e)
342 // a text other than the answer is shown beneath it
343 return texts.length ? { ...r, text: texts.join('\n') } : r
344 })
345
346 on('session.end', async ($, e, next) => {
347 try {
348 const rt = await ensure($)
349 rt.journal.push({ type: 'session.end', reason: e.reason }, await $.clock.now())
350 await endModules(rt, hostOf($), e.reason)
351 saveJournalLater(rt)
352 await rt.store.flush()
353 } catch {
354 // ending fast matters more than the last journal line
355 }
356 // after /clear or a resume the process goes on without a session.start
357 if (e.reason === 'clear' || e.reason === 'resume') cleared = true
358 else tick?.cancel()
359 return next(e)
360 })
361
362 for (const name of ['recap', 'exo-recap'] as const) {
363 on('command.run', { command: name }, async ($, e) => {
364 const rt = await ensure($)
365 return { text: await recapCommand(moduleEnv(rt, hostOf($)), e.args, rt.config.enabled.lessons) }
366 })
367 }
368 for (const name of ['duck', 'exo-duck'] as const) {
369 on('command.run', { command: name }, async $ => {
370 const rt = await ensure($)
371 if (!rt.config.enabled.duck) return { text: 'The rubber duck is switched off (/exo on duck).' }
372 await update($, duckA, () => freshDuck())
373 const placed = await $.ui.open({ id: DUCK_PANE, title: 'Rubber duck', focus: true, closeOnEscape: true })
374 return { text: placed.isPlaced ? 'The duck is listening.' : `The duck has no room (${placed.reason}).` }
375 })
376 }
377 for (const name of ['achievements', 'exo-achievements'] as const) {
378 on('command.run', { command: name }, async $ => {
379 const rt = await ensure($)
380 const noColor = !!(await $.env.get('NO_COLOR'))
381 // plain text: a fenced block loses its line breaks in command output
382 return { text: await achievementsCard(moduleEnv(rt, hostOf($)), noColor) }
383 })
384 }
385
386 on('ui.render', { component: 'Pane', requestId: DUCK_PANE }, async ($, e) => {
387 if (e.surface === 'mobile') {
388 const { Text } = $.ui.resolve(e)
389 return <Text>The rubber duck needs an input field – there is none on the phone.</Text>
390 }
391 const { Box, Text, Button, Input, Code } = $.ui.resolve(e)
392 const s = await read($, duckA)
393 const duck = DUCK.map((l, i) => <Text key={`d${i}`} color="#f2cc60">{l}</Text>)
394 if (done(s)) {
395 const prompt = buildPrompt(s.answers)
396 return (
397 <Box flexDirection="column">
398 {duck}
399 <Text key="t">Quack. This becomes the prompt – edit it in the input field and send it yourself:</Text>
400 <Code key="p" source={prompt} language="markdown" />
401 <Box key="b" flexDirection="row">
402 <Button
403 key="take"
404 variant="primary"
405 label="Put into prompt"
406 onPress={async () => {
407 await $.prompt.fill({ text: prompt, mode: 'replace' })
408 const rt = await ensure($)
409 rt.journal.push({ type: 'duck' }, await $.clock.now())
410 await $.ui.close({ id: DUCK_PANE })
411 }}
412 />
413 <Button key="again" label="Start over" onPress={() => void update($, duckA, () => freshDuck())} />
414 </Box>
415 </Box>
416 )
417 }
418 const q = QUESTIONS[s.step]!
419 return (
420 <Box flexDirection="column">
421 {duck}
422 <Text key="q" bold>{`Question ${s.step + 1}/${QUESTIONS.length}: ${q}`}</Text>
423 <Input key={`in${s.step}`} placeholder="Answer, Enter = next" autoFocus onSubmit={(value: string) => void update($, duckA, cur => answerDuck(cur, value))} />
424 <Box key="b" flexDirection="row">
425 <Button key="skip" label="Skip" onPress={() => void update($, duckA, cur => answerDuck(cur, null))} />
426 </Box>
427 </Box>
428 )
429 })
430
431 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
432 try {
433 const l = await live($)
434 if (!l || !l.rt.config.enabled.cinema) return next(e)
435 const msg = spinnerMessage(await $.clock.now(), e.viewport?.columns ?? 80)
436 return msg ? next({ ...e, props: { ...e.props, message: msg, suffix: '' } }) : next(e)
437 } catch {
438 return next(e)
439 }
440 })
441
442 for (const name of ['hours', 'exo-hours'] as const) {
443 on('command.run', { command: name }, async ($, e) => {
444 const rt = await ensure($)
445 return { text: await hoursCommand(moduleEnv(rt, hostOf($)), e.args) }
446 })
447 }
448
449 for (const name of ['undo-last', 'undo-list', 'exo-undo-last', 'exo-undo-list'] as const) {
450 on('command.run', { command: name }, async ($, e) => {
451 const rt = await ensure($)
452 const host = hostOf($)
453 const base = commandNames.get(name) ?? name.replace(/^exo-/, '')
454 if (base === 'undo-list') return { text: await undoList(rt.store, await $.clock.now()) }
455 return { text: await undoLast(host, rt.store, e.args.trim() || undefined) }
456 })
457 }
458
459 on('command.run', { command: CHANGES }, async $ => {
460 const rt = await ensure($)
461 const placed = await $.ui.open({ id: CHANGES_PANE, title: 'Changes' })
462 await refreshChanges(moduleEnv(rt, hostOf($))).catch(() => undefined)
463 return { text: placed.isPlaced ? 'Changes opened.' : `Changes: no room (${placed.reason}).` }
464 })
465
466 on('ui.render', { component: 'Pane', requestId: CHANGES_PANE }, async ($, e) => {
467 const { Box, Text, Button, Code } = $.ui.resolve(e)
468 const rows = await read($, changesA)
469 const selected = await read($, selectedA)
470 const diff = await read($, diffA)
471 if (!rows.length) return <Text dimColor>No file has been changed via Write/Edit in this session yet.</Text>
472 const width = e.props.bodyColumns ?? 60
473 return (
474 <Box flexDirection="column">
475 {rows.map(r => (
476 <Box key={`row:${r.path}`} flexDirection="row">
477 <Button key={`sel:${r.path}`} plain label={`${r.path === selected ? '▸' : ' '} +${r.added} −${r.removed} ${shortPath(r.path, width - 14)}${r.isNew ? ' (new)' : ''}`} onPress={() => void pressPane(hostOf($), `sel:${r.path}`)} />
478 </Box>
479 ))}
480 {selected ? (
481 <Box key="detail" flexDirection="column" marginTop={1}>
482 {diff ? <Code key="diff" source={diff} format="diff" path={selected} /> : <Text dimColor>No differences left from the state before the session.</Text>}
483 <Box key="actions" flexDirection="row">
484 <Button key="revert" label="Revert" onPress={() => void pressPane(hostOf($), `revert:${selected}`)} />
485 </Box>
486 </Box>
487 ) : (
488 <Text dimColor>Select a file for its diff.</Text>
489 )}
490 </Box>
491 )
492 })
493
494 on('command.run', { command: COMMAND }, async ($, e) => {
495 const rt = await ensure($)
496 return { text: await exoCommand(rt, hostOf($), e.args) }
497 })
498
499 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
500 // A surface without a prompt box (or a read that fails) just shows the line.
501 const draft = (await $.prompt.read().catch(() => undefined))?.text ?? ''
502 const options = complete(draft)
503 if (options) {
504 const { Box, Text } = $.ui.resolve(e)
505 const one = options.length === 1 ? options[0] : undefined
506 return (
507 <Box flexDirection="column">
508 {await next(e)}
509 <Box key="exo-complete" flexDirection="row">
510 <Text dimColor>{'⌨ '}</Text>
511 {options.map((o, i) => (
512 <Text key={o.word} wrap="truncate-end">
513 {i > 0 ? <Text dimColor>{' · '}</Text> : null}
514 <Text bold color="#d2a8ff">{o.word.slice(0, o.typed)}</Text>
515 <Text>{o.word.slice(o.typed)}</Text>
516 </Text>
517 ))}
518 {one ? <Text dimColor wrap="truncate-end">{` — ${one.hint}`}</Text> : null}
519 </Box>
520 </Box>
521 )
522 }
523 const slots = Object.values(await read($, slotsA))
524 if (!slots.length) return next(e)
525 const columns = Math.max(10, (e.viewport?.columns ?? 100) - 2)
526 const shown = layout(slots, columns)
527 const { Box, Text } = $.ui.resolve(e)
528 return (
529 <Box flexDirection="column">
530 {await next(e)}
531 <Box key="exo" flexDirection="row">
532 {shown.map((s, i) => (
533 <Text key={s.id} color={s.color} dimColor={s.dim} bold={s.bold} wrap="truncate-end">
534 {i > 0 ? ' · ' : ''}
535 {s.text}
536 </Text>
537 ))}
538 </Box>
539 </Box>
540 )
541 })
542
543 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
544 if (e.props.hasSurvey) return next(e)
545 const banners = await read($, bannersA)
546 const killed = await read($, killedA)
547 if (!banners.length || killed) return next(e)
548 const { Box, Text, Button } = $.ui.resolve(e)
549 return (
550 <Box flexDirection="column">
551 {banners.map(b => (
552 <Box key={b.id} flexDirection="row">
553 <Text color={b.tone === 'warn' ? '#d29922' : undefined} wrap="truncate-end">
554 {b.text}{' '}
555 </Text>
556 {b.buttons.map(btn => (
557 <Button key={`${b.id}:${btn.key}`} label={btn.label} onPress={() => void pressBanner(hostOf($), b.id, btn.key)} />
558 ))}
559 <Button key={`${b.id}:${DISMISS}`} label={L.dismiss} role="dismiss" onPress={() => void pressBanner(hostOf($), b.id, DISMISS)} />
560 </Box>
561 ))}
562 </Box>
563 )
564 })
565}
566core/adapter/host.ts 107 lines1/**
2 * Everything exo needs from the engine, as one interface. Modules and the
3 * core work against `Host`; only `engine.ts` speaks to `$`. Tests hand the
4 * same code a fake host.
5 */
6import type { Banner, Slot } from '../statusline/statusline'
7
8export interface RunResult {
9 exitCode: number
10 stdout: string
11 stderr: string
12}
13
14export interface RunOptions {
15 cwd?: string
16 timeoutMs?: number
17 stdin?: string
18}
19
20export interface FileStat {
21 kind: 'file' | 'dir' | 'other'
22 size: number
23 mtimeMs: number
24}
25
26export interface Timer {
27 cancel(): void
28}
29
30/** A child process running in the background; `stop()` kills it. */
31export interface Spawned {
32 chunks: AsyncIterable<{ stream: 'stdout' | 'stderr'; text: string }>
33 stop(): void
34 done: Promise<{ code: number | null }>
35}
36
37/** The environment variables exo reads; the engine wants them listed. */
38export type EnvName = 'HOME' | 'EXO_DISABLE' | 'NO_COLOR'
39
40export interface Host {
41 now(): Promise<number>
42 env(name: EnvName): Promise<string | undefined>
43 readFile(path: string): Promise<string>
44 writeFile(path: string, text: string): Promise<void>
45 exists(path: string): Promise<boolean>
46 stat(path: string): Promise<FileStat>
47 /** Names in a directory. */
48 list(path: string): Promise<string[]>
49 /** Whether the path itself is a symlink (also one whose target is gone). */
50 isLink(path: string): Promise<boolean>
51 /** The path with every symlink and `..` resolved; rejects when it does not exist. */
52 realPath(path: string): Promise<string>
53 run(argv: readonly string[], options?: RunOptions): Promise<RunResult>
54 storeGet(key: string): Promise<unknown>
55 storeSet(key: string, value: unknown): Promise<void>
56 storeDelete(key: string): Promise<void>
57 storeKeys(): Promise<string[]>
58 after(ms: number, fn: () => void): Timer
59 every(ms: number, fn: () => void): Timer
60 toast(text: string, timeoutMs?: number): void
61 log(text: string): void
62 /** Native dialog; resolves the chosen label, rejects on Esc or without UI. */
63 ask(question: string, options: readonly string[]): Promise<string>
64 sessionId(): Promise<string>
65 cwd(): Promise<string>
66 repoRoot(): Promise<string | null>
67 /** Status line: replaces the slot with this id; `null` removes it. */
68 setSlot(id: string, slot: Slot | null): Promise<void>
69 /** AbovePrompt band: replaces the banner with this id; `null` removes it. */
70 setBanner(id: string, banner: Banner | null): Promise<void>
71 /** Whether exo is switched off by its kill switch, for drawings. */
72 setKilled(reason: string | null): Promise<void>
73 spawn(argv: readonly string[], options?: { cwd?: string }): Spawned
74 /** Puts text into the prompt box as a draft; the person sends it. */
75 fillPrompt(text: string): Promise<boolean>
76 openPane(id: string, title: string): Promise<boolean>
77 /** The conversation so far: role and text of each message. */
78 messages(): Promise<{ role: 'user' | 'assistant'; text: string }[]>
79 /** One completion with a small model; null when there is no answer (timeout, error). */
80 complete(prompt: string, system?: string): Promise<string | null>
81 copy(text: string): Promise<boolean>
82 /** What the session has cost so far, when the host keeps a ledger. */
83 sessionCost(): Promise<number | null>
84 /** Dialog with several choices; the chosen labels. Rejects on Esc or without UI. */
85 askMany(question: string, options: readonly string[]): Promise<string[]>
86 /** Asks the engine to draw exo's sites again (animations). */
87 redraw(): void
88 /** A sound file of exo's own (`sounds/badge.wav`); never throws. */
89 playSound(asset: string): Promise<void>
90 /** Context fill and the 5-hour window, when the host knows them. */
91 usage(): Promise<{ contextPercent?: number; fiveHour?: number }>
92 /** Whether the usage-bars mod is loaded (its config rows are listed). */
93 usageBars(): Promise<boolean>
94 /** A pane that takes the keys (Esc closes it). */
95 openDialog(id: string, title: string): Promise<boolean>
96 closePane(id: string): Promise<void>
97 /** Change sidebar: the list, the selected file and its diff. */
98 setChanges(changes: ChangeRow[], selected: string | null, diff: string): Promise<void>
99}
100
101export interface ChangeRow {
102 path: string
103 added: number
104 removed: number
105 isNew: boolean
106}
107core/complete.ts 66 lines1/**
2 * What `/exo …` can continue with, for the line under the prompt.
3 *
4 * Claude Code completes a slash command's name but not its arguments, and a
5 * mod cannot hand it an argument list, so exo shows the candidates itself
6 * while the person types. A list to read, not tab completion. Pure: the draft
7 * in, the candidates out.
8 */
9import { MODULES } from './config/config'
10
11export const COMMAND = '/exo'
12
13/** One candidate: the word, how much of it is already typed, what it does. */
14export type Candidate = { word: string; typed: number; hint: string }
15
16type Entry = { word: string; hint: string }
17
18/** The subcommands `exoCommand` answers, in the order of `/exo help`. */
19export const SUBCOMMANDS: readonly Entry[] = [
20 { word: 'status', hint: 'state of all modules' },
21 { word: 'on', hint: 'everything on, or one module' },
22 { word: 'off', hint: 'everything off, or one module' },
23 { word: 'reset', hint: "clear a module's broken mark" },
24 { word: 'rules', hint: 'house rules in force' },
25 { word: 'help', hint: 'all commands' },
26]
27
28const moduleEntries = (verb: string): Entry[] => MODULES.map(m => ({ word: m.id, hint: `${verb} ${m.label}` }))
29
30/** What may follow a subcommand; a subcommand without an entry takes nothing. */
31export const SECOND: Readonly<Record<string, readonly Entry[]>> = {
32 on: moduleEntries('switch on:'),
33 off: moduleEntries('switch off:'),
34 reset: [...moduleEntries('clear the broken mark of'), { word: 'core', hint: "clear the core's broken mark" }],
35}
36
37/** Whether a draft is about this command at all (cheap; checked on every key). */
38export function isOurs(draft: string): boolean {
39 const t = draft.trimStart()
40 return t === COMMAND || t.startsWith(`${COMMAND} `)
41}
42
43/**
44 * The candidates for `draft`, or `null` when there is nothing to offer: the
45 * draft is not `/exo ` plus arguments, the argument has no follow-up, there
46 * are too many words, or nothing matches what is typed.
47 */
48export function complete(draft: string): Candidate[] | null {
49 const text = draft.trimStart()
50 if (!text.startsWith(`${COMMAND} `)) return null
51 const rest = text.slice(COMMAND.length + 1).toLowerCase()
52 const words = rest.split(/\s+/).filter(Boolean)
53 const open = rest === '' || /\s$/.test(rest)
54 const done = open ? words : words.slice(0, -1)
55 const current = open ? '' : words[words.length - 1]!
56
57 let pool: readonly Entry[] | undefined
58 if (done.length === 0) pool = SUBCOMMANDS
59 else if (done.length === 1) pool = SECOND[done[0]!]
60 if (!pool) return null
61
62 const hits = pool.filter(e => e.word.toLowerCase().startsWith(current))
63 if (hits.length === 0) return null
64 return hits.map(e => ({ word: e.word, typed: current.length, hint: e.hint }))
65}
66core/exo-command.ts 115 lines1/**
2 * `/exo`: status of every module, switches, kill switch, rules.
3 *
4 * /exo status table
5 * /exo on | off everything on / off
6 * /exo on|off <module> one module
7 * /exo reset <module> clear the broken mark
8 * /exo rules house rules in force
9 * /exo help
10 */
11import type { Host } from './adapter/host'
12import { MODULES, isModuleId } from './config/config'
13import type { ModuleId } from './config/config'
14import type { HealthKey } from './health/health'
15import { L } from './i18n'
16import { KILL_HINT, exoDir } from './killswitch'
17import { killReason, refreshLiveness, rulesPath, setPrefs } from './runtime'
18import type { Runtime } from './runtime'
19
20/** Pads to `n`, and always leaves at least one space before the next column. */
21const pad = (s: string, n: number) => s.padEnd(Math.max(n, s.length + 1))
22const ms = (n: number) => (n < 10 ? n.toFixed(1) : String(Math.round(n)))
23
24function clock(at: number): string {
25 const d = new Date(at)
26 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
27}
28
29export async function statusText(rt: Runtime, host: Host): Promise<string> {
30 const killed = await killReason(rt, host)
31 const snap = rt.health.snapshot()
32 const lines: string[] = []
33 lines.push(killed ? `exo is OFF (${killed}).` : 'exo is on.')
34 lines.push('')
35 const width = Math.max(...MODULES.map(m => `${m.label} (${m.id})`.length)) + 2
36 lines.push(`${pad('Module', width)}${pad('State', 10)}${pad('Calls', 9)}${pad('avg ms', 7)}${pad('max ms', 8)}last error`)
37 for (const m of MODULES) {
38 const h = snap[m.id]
39 const state = h?.broken ? L.brokenState : rt.config.enabled[m.id] && !killed ? L.on : L.off
40 const avg = h && h.calls ? ms(h.totalMs / h.calls) : '–'
41 const err = h?.lastError ? `${clock(h.lastError.at)} ${h.lastError.message}` : ''
42 lines.push(`${pad(`${m.label} (${m.id})`, width)}${pad(state, 10)}${pad(String(h?.calls ?? 0), 9)}${pad(avg, 7)}${pad(h ? ms(h.maxMs) : '–', 8)}${err}`)
43 }
44 const core = snap.core
45 if (core?.budgetHits) lines.push(`Time budget (50 ms) exceeded: ${core.budgetHits}×`)
46 lines.push('')
47 lines.push(`Journal: ${rt.journal.size()} event${rt.journal.size() === 1 ? '' : 's'} · session ${rt.journal.sessionId || '–'}`)
48 lines.push(`Configuration: ${rt.home ? exoDir(rt.home) : '~/.claude/exo'} · options in /config (exo)`)
49 lines.push(`Prod hosts: ${rt.config.prodHosts.length ? rt.config.prodHosts.map(h => h.name).join(', ') : 'none (shield only for SQL and force push)'}`)
50 lines.push(`House rules: ${rt.rules.rules.length} active (${rt.rules.source === 'file' ? 'rules.json' : rt.rules.source === 'default' ? 'built-in' : 'built-in, rules.json has errors'})`)
51 const warnings = [...rt.rules.errors, ...rt.config.errors, ...rt.store.warnings]
52 if (warnings.length) {
53 lines.push('')
54 lines.push('Warnings:')
55 for (const w of warnings) lines.push(` ⚠ ${w}`)
56 }
57 lines.push('')
58 lines.push(KILL_HINT)
59 return lines.join('\n')
60}
61
62export function helpText(): string {
63 return [
64 'Commands:',
65 ' /exo state of all modules',
66 ' /exo on | off everything on / off',
67 ' /exo on|off <module> switch one module',
68 ' /exo reset <module> clear the broken mark',
69 ' /exo rules house rules in force',
70 `Modules: ${MODULES.map(m => m.id).join(', ')}`,
71 KILL_HINT,
72 ].join('\n')
73}
74
75export async function exoCommand(rt: Runtime, host: Host, args: string): Promise<string> {
76 const [cmd = '', arg = ''] = args.trim().split(/\s+/)
77 const sub = cmd.toLowerCase()
78
79 if (sub === '' || sub === 'status') return statusText(rt, host)
80 if (sub === 'help' || sub === 'hilfe') return helpText()
81
82 if (sub === 'on' || sub === 'off' || sub === 'an' || sub === 'aus') {
83 const value = sub === 'on' || sub === 'an'
84 if (!arg) {
85 await setPrefs(rt, { allOff: !value, modules: value ? {} : rt.prefs.modules })
86 await refreshLiveness(rt, host)
87 return value ? 'exo: all modules on.' : `exo: all modules off. Back on with /exo on.`
88 }
89 if (!isModuleId(arg)) return L.unknownModule(arg)
90 await setPrefs(rt, { ...rt.prefs, modules: { ...rt.prefs.modules, [arg]: value } })
91 await refreshLiveness(rt, host)
92 const info = MODULES.find(m => m.id === arg)!
93 return `exo: ${info.label} ${value ? L.on : L.off}.${rt.prefs.allOff && value ? ' (note: /exo off is active – run /exo on first)' : ''}`
94 }
95
96 if (sub === 'reset') {
97 if (!isModuleId(arg) && arg !== 'core') return L.unknownModule(arg)
98 rt.health.reset(arg as HealthKey)
99 await refreshLiveness(rt, host)
100 return `exo: broken mark of ${arg} cleared.`
101 }
102
103 if (sub === 'rules' || sub === 'regeln') {
104 const lines = [`House rules (${rt.home ? rulesPath(rt.home) : 'rules.json'}):`]
105 for (const r of rt.rules.rules) lines.push(` ${r.id}: ${r.match.source} on ${r.hosts.join(', ')} → checks "${r.check.join(' ')}" · ${r.text}`)
106 if (!rt.rules.rules.length) lines.push(' none')
107 for (const e of rt.rules.errors) lines.push(` ⚠ ${e}`)
108 return lines.join('\n')
109 }
110
111 return helpText()
112}
113
114export type { ModuleId }
115core/health/health.ts 88 lines1/**
2 * Per-module health: on, off or broken, time spent, budget overruns and the
3 * last error. In memory; `/exo` reads it, the store keeps the last errors.
4 */
5import type { ModuleId } from '../config/config'
6
7export type HealthKey = ModuleId | 'core'
8
9export interface ModuleHealth {
10 broken: boolean
11 calls: number
12 totalMs: number
13 maxMs: number
14 budgetHits: number
15 lastError?: { at: number; message: string }
16}
17
18export const MESSAGE_MAX = 200
19
20export class Health {
21 private readonly m = new Map<HealthKey, ModuleHealth>()
22
23 private get(id: HealthKey): ModuleHealth {
24 let h = this.m.get(id)
25 if (!h) {
26 h = { broken: false, calls: 0, totalMs: 0, maxMs: 0, budgetHits: 0 }
27 this.m.set(id, h)
28 }
29 return h
30 }
31
32 record(id: HealthKey, ms: number): void {
33 const h = this.get(id)
34 h.calls++
35 h.totalMs += ms
36 h.maxMs = Math.max(h.maxMs, ms)
37 }
38
39 budgetHit(id: HealthKey): void {
40 this.get(id).budgetHits++
41 }
42
43 /** Marks the module broken; `message` must already be free of secrets. */
44 fail(id: HealthKey, message: string, at: number): void {
45 const h = this.get(id)
46 h.broken = true
47 h.lastError = { at, message: message.slice(0, MESSAGE_MAX) }
48 }
49
50 reset(id: HealthKey): void {
51 const h = this.get(id)
52 h.broken = false
53 }
54
55 isBroken(id: HealthKey): boolean {
56 return this.m.get(id)?.broken ?? false
57 }
58
59 broken(): HealthKey[] {
60 return [...this.m].filter(([, h]) => h.broken).map(([id]) => id)
61 }
62
63 snapshot(): Record<string, ModuleHealth> {
64 return Object.fromEntries([...this.m].map(([id, h]) => [id, { ...h }]))
65 }
66
67 /** Restores last errors from the store (not the broken flag). */
68 restoreErrors(saved: unknown): void {
69 if (!saved || typeof saved !== 'object') return
70 for (const [id, v] of Object.entries(saved as Record<string, unknown>)) {
71 const e = v as { at?: unknown; message?: unknown }
72 if (typeof e?.at === 'number' && typeof e.message === 'string') this.get(id as HealthKey).lastError = { at: e.at, message: e.message.slice(0, MESSAGE_MAX) }
73 }
74 }
75
76 errorsForStore(): Record<string, { at: number; message: string }> {
77 const out: Record<string, { at: number; message: string }> = {}
78 for (const [id, h] of this.m) if (h.lastError) out[id] = h.lastError
79 return out
80 }
81}
82
83/** A short, single-line text of a thrown value. */
84export function errorText(err: unknown): string {
85 const raw = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
86 return raw.replace(/\s+/g, ' ').slice(0, MESSAGE_MAX)
87}
88core/i18n.ts 22 lines1/**
2 * Texts of the interface. English only for now; another language can be
3 * added as a second key of `T` with the same entries.
4 */
5export type Lang = 'en'
6
7export const T = {
8 en: {
9 liveness: '⛨ exo',
10 broken: (n: number) => `⚠ ${n} broken`,
11 killed: (why: string) => `⛨ exo off (${why})`,
12 on: 'on',
13 off: 'off',
14 brokenState: 'broken',
15 rulesWarning: 'exo: rules.json has errors – details with /exo',
16 unknownModule: (m: string) => `Unknown module: ${m}. /exo lists them all.`,
17 dismiss: 'Close',
18 },
19} as const
20
21export const L = T.en
22core/runtime.ts 195 lines1/**
2 * exo's state for one loaded module: configuration, rules, journal, health,
3 * store and kill switch. Built once per load (a hot reload builds it anew and
4 * restores the journal from the store).
5 */
6import type { Host } from './adapter/host'
7import { emptyPrefs, readPrefs, resolveConfig } from './config/config'
8import type { Config, Prefs } from './config/config'
9import { DEFAULT_RULES_JSON, loadRules } from './config/rules'
10import type { RulesLoad } from './config/rules'
11import { catchDecision, dispatch } from './dispatcher/dispatcher'
12import type { ModuleEnv, Step, ToolCall, ToolResult, TurnEnd } from './dispatcher/dispatcher'
13import { Health, errorText } from './health/health'
14import { L } from './i18n'
15import { Journal } from './journal/journal'
16import type { JournalSnapshot, NewEvent } from './journal/journal'
17import { KillSwitch, exoDir } from './killswitch'
18import { cwdAfter } from './cwd'
19import type { CwdGuess } from './cwd'
20import { parse } from './shell/parse'
21import { RESERVED } from './statusline/statusline'
22import { StoreBox } from './store/store'
23import { createSteps } from '../modules'
24
25export interface Runtime {
26 home: string | undefined
27 options: Readonly<Record<string, unknown>>
28 prefs: Prefs
29 config: Config
30 rules: RulesLoad
31 journal: Journal
32 health: Health
33 store: StoreBox
34 kill: KillSwitch
35 interactive: boolean
36 steps: Step[]
37 /** The Bash tool's working directory as exo follows it. */
38 bashCwd: CwdGuess
39 /** Git root of the session, else its working directory. */
40 project: string
41 /** Last liveness text drawn, to skip redundant state writes. */
42 shownLiveness?: string
43 rulesWarned: boolean
44}
45
46export const rulesPath = (home: string) => `${exoDir(home)}/rules.json`
47
48
49export async function createRuntime(host: Host, options: Readonly<Record<string, unknown>>, interactive = true): Promise<Runtime> {
50 const home = await host.env('HOME').catch(() => undefined)
51 const store = new StoreBox(host)
52 await store.init().catch(err => store.warnings.push(`Store: ${errorText(err)}`))
53 const prefs = readPrefs(await store.get('prefs').catch(() => undefined))
54 const config = resolveConfig(options, prefs)
55
56 let rules: RulesLoad = loadRules(null)
57 if (home) {
58 const path = rulesPath(home)
59 try {
60 if (await host.exists(path)) rules = loadRules(await host.readFile(path))
61 else await host.writeFile(path, JSON.stringify(DEFAULT_RULES_JSON, null, 2) + '\n')
62 } catch (err) {
63 rules = { ...loadRules(null), errors: [`rules.json not readable: ${errorText(err)}`, 'The built-in rules apply.'], source: 'default-after-error' }
64 }
65 }
66
67 const health = new Health()
68 health.restoreErrors(await store.get('health').catch(() => undefined))
69
70 const journal = new Journal()
71 const sessionId = await host.sessionId().catch(() => '')
72 journal.sessionId = sessionId
73 journal.restore(await store.get('journal:current').catch(() => undefined), sessionId)
74
75 const cwd = await host.cwd().catch(() => '/')
76 const project = (await host.repoRoot().catch(() => null)) ?? cwd
77 return {
78 project, home, options, prefs, config, rules, journal, health, store, kill: new KillSwitch(), interactive, steps: createSteps(), rulesWarned: false, bashCwd: { cwd, known: true } }
79}
80
81/** Applies new prefs: recomputes the config and stores them. */
82export async function setPrefs(rt: Runtime, prefs: Prefs): Promise<void> {
83 rt.prefs = prefs
84 rt.config = resolveConfig(rt.options, prefs)
85 rt.kill.invalidate()
86 await rt.store.set('prefs', prefs)
87}
88
89export function killReason(rt: Runtime, host: Host): Promise<string | null> {
90 return rt.kill.reason(host, rt.home, rt.prefs.allOff)
91}
92
93/** Logs an event and schedules the throttled store write. */
94export async function log(rt: Runtime, host: Host, e: NewEvent): Promise<void> {
95 rt.journal.push(e, await host.now())
96 saveJournalLater(rt)
97}
98
99export function saveJournalLater(rt: Runtime): void {
100 rt.store.later('journal:current', () => rt.journal.snapshot(), v => Journal.shrink(v as JournalSnapshot))
101 rt.store.later('health', () => rt.health.errorsForStore())
102}
103
104export async function toolCall(rt: Runtime, host: Host, call: ToolCall, next: (input: Record<string, unknown>) => Promise<ToolResult>): Promise<ToolResult> {
105 const result = await dispatch(
106 {
107 host,
108 config: rt.config,
109 rules: rt.rules.rules,
110 journal: rt.journal,
111 health: rt.health,
112 steps: rt.steps,
113 interactive: rt.interactive,
114 killed: () => killReason(rt, host),
115 home: rt.home,
116 cwd: rt.bashCwd,
117 store: rt.store,
118 },
119 call,
120 next,
121 )
122 if (call.tool === 'Bash' && result.deny === undefined) {
123 const r = parse(String(call.input.command ?? ''))
124 rt.bashCwd = r.ok ? cwdAfter(r.script, rt.bashCwd, rt.home) : { ...rt.bashCwd, known: false }
125 }
126 saveJournalLater(rt)
127 return result
128}
129
130export { catchDecision }
131
132/** The liveness slot: `⛨ exo`, with broken modules counted, or off. */
133export function livenessText(rt: Runtime, killed: string | null): string {
134 if (killed) return L.killed(killed)
135 const broken = rt.health.broken().length
136 return broken ? `${L.liveness} ${L.broken(broken)}` : L.liveness
137}
138
139export async function refreshLiveness(rt: Runtime, host: Host): Promise<void> {
140 const killed = await killReason(rt, host)
141 const text = livenessText(rt, killed)
142 if (text === rt.shownLiveness) return
143 rt.shownLiveness = text
144 await host.setKilled(killed)
145 await host.setSlot(RESERVED.liveness, {
146 id: RESERVED.liveness,
147 order: 0,
148 priority: 100,
149 text,
150 dim: !killed && !rt.health.broken().length,
151 color: killed || rt.health.broken().length ? '#d29922' : undefined,
152 })
153}
154
155export function freshPrefs(): Prefs {
156 return emptyPrefs()
157}
158
159export function moduleEnv(rt: Runtime, host: Host): ModuleEnv {
160 return { host, journal: rt.journal, config: rt.config, store: rt.store, home: rt.home, project: rt.project, cwd: rt.bashCwd, interactive: rt.interactive, sessionId: rt.journal.sessionId }
161}
162
163/** Runs a lifecycle hook of every enabled module, each guarded on its own. */
164async function each<T>(rt: Runtime, host: Host, call: (step: Step, env: ModuleEnv) => T | Promise<T> | undefined): Promise<T[]> {
165 if (await killReason(rt, host)) return []
166 const env = moduleEnv(rt, host)
167 const out: T[] = []
168 for (const step of rt.steps) {
169 if (!rt.config.enabled[step.id]) continue
170 try {
171 const v = await call(step, env)
172 if (v !== undefined) out.push(v)
173 } catch (err) {
174 rt.health.fail(step.id, errorText(err), await host.now())
175 }
176 }
177 return out
178}
179
180export async function startModules(rt: Runtime, host: Host): Promise<void> {
181 await each(rt, host, (s, env) => s.start?.(env))
182}
183
184export async function promptContexts(rt: Runtime, host: Host): Promise<string[]> {
185 return (await each(rt, host, (s, env) => s.promptContext?.(env))).flat().filter(Boolean)
186}
187
188export async function turnTexts(rt: Runtime, host: Host, t: TurnEnd): Promise<string[]> {
189 return (await each(rt, host, (s, env) => s.turnComplete?.(env, t))).filter((x): x is string => typeof x === 'string' && x.length > 0)
190}
191
192export async function endModules(rt: Runtime, host: Host, reason: string): Promise<void> {
193 await each(rt, host, (s, env) => s.end?.(env, reason))
194}
195core/killswitch.ts 67 lines1/**
2 * The kill switch. Checked first in every hook, so it works even when a
3 * guard misbehaves:
4 *
5 * - `~/.claude/exo/DISABLED` exists: live, no restart, can be set from a
6 * second terminal while exo blocks Bash in this one.
7 * - `EXO_DISABLE=1` in the environment the session started with.
8 * - `/exo off` (stored prefs).
9 *
10 * If the module does not load at all, the engine already lets everything
11 * through: there is nothing left to switch off.
12 */
13import type { Host } from './adapter/host'
14
15export const EXO_DIR = '.claude/exo'
16export const DISABLED_FILE = 'DISABLED'
17export const CACHE_MS = 1_000
18
19export const exoDir = (home: string) => `${home.replace(/\/+$/, '')}/${EXO_DIR}`
20export const disabledPath = (home: string) => `${exoDir(home)}/${DISABLED_FILE}`
21
22/** Pure decision from the three sources; the reason, or null when on. */
23export function killReason(s: { env: string | undefined; fileExists: boolean; allOff: boolean }): string | null {
24 if (s.fileExists) return 'file ~/.claude/exo/DISABLED'
25 if (s.env !== undefined && s.env !== '' && s.env !== '0' && s.env.toLowerCase() !== 'false') return 'EXO_DISABLE'
26 if (s.allOff) return '/exo off'
27 return null
28}
29
30export const KILL_HINT = 'Kill switch: touch ~/.claude/exo/DISABLED (or /exo off)'
31
32/** Cached checks of the environment and the file; `allOff` is read live. */
33export class KillSwitch {
34 private cached: { at: number; env: string | undefined; file: boolean } | undefined
35
36 constructor(
37 private readonly clock: () => number = Date.now,
38 private readonly cacheMs = CACHE_MS,
39 ) {}
40
41 invalidate(): void {
42 this.cached = undefined
43 }
44
45 async reason(host: Host, home: string | undefined, allOff: boolean): Promise<string | null> {
46 const t = this.clock()
47 if (!this.cached || t - this.cached.at >= this.cacheMs) {
48 let env: string | undefined
49 let file = false
50 try {
51 env = await host.env('EXO_DISABLE')
52 } catch {
53 env = undefined
54 }
55 if (home) {
56 try {
57 file = await host.exists(disabledPath(home))
58 } catch {
59 file = false
60 }
61 }
62 this.cached = { at: t, env, file }
63 }
64 return killReason({ env: this.cached.env, fileExists: this.cached.file, allOff })
65 }
66}
67core/safepath.ts 50 lines1/**
2 * Writes exo does on its own (not through a tool call) must stay where they
3 * belong: a symlink named CLAUDE.md or .exo could otherwise point exo's
4 * write at ~/.bashrc or the settings.
5 */
6import type { Host } from './adapter/host'
7
8/**
9 * Whether a file system error says "not there". Only then is a path treated
10 * as missing; every other or unknown failure makes a guard refuse.
11 */
12export function isMissingError(err: unknown): boolean {
13 const text = String((err as { code?: unknown })?.code ?? '') + ' ' + String((err as Error)?.message ?? err)
14 return /\bENOENT\b|\bENOTDIR\b|no such file or directory/i.test(text)
15}
16
17/**
18 * Whether `path`, every symlink resolved, lies under `root` (also resolved).
19 * A path that does not exist yet is judged by its nearest existing parent;
20 * a dangling symlink on the way, or `.`/`..` in the path, is refused.
21 * Not closed: the moment between this check and the write ($.fs has no
22 * atomic rename to write through).
23 */
24export async function insideRoot(host: Host, path: string, root: string): Promise<boolean> {
25 // `.` and `..` are spelled out by nobody who means well here
26 if (/(^|\/)\.\.?(\/|$)/.test(path)) return false
27 let rootReal: string
28 try {
29 rootReal = (await host.realPath(root)).replace(/\/+$/, '')
30 } catch {
31 return false
32 }
33 let cur = path
34 let tail = ''
35 for (;;) {
36 try {
37 const real = (await host.realPath(cur)).replace(/\/+$/, '') + tail
38 return real === rootReal || real.startsWith(rootReal + '/')
39 } catch {
40 // does not resolve: a missing part is fine, a symlink whose target is
41 // gone is not (the write would follow it, wherever it points)
42 if (await host.isLink(cur).catch(() => true)) return false
43 const cut = cur.lastIndexOf('/')
44 if (cut <= 0) return false
45 tail = cur.slice(cut) + tail
46 cur = cur.slice(0, cut)
47 }
48 }
49}
50core/statusline/banners.ts 42 lines1/**
2 * Actions behind the buttons of AbovePrompt banners. A banner is plain data
3 * in `$.state`; what a button does lives here, keyed by banner and button.
4 * After a hot reload the map is empty until modules register again, so an
5 * unknown press just removes its banner.
6 */
7import type { Host } from '../adapter/host'
8
9export type BannerAction = (host: Host) => Promise<void> | void
10
11const actions = new Map<string, BannerAction>()
12
13export const DISMISS = 'dismiss'
14
15const key = (banner: string, button: string) => `${banner}:${button}`
16
17export function onBanner(banner: string, button: string, action: BannerAction): void {
18 actions.set(key(banner, button), action)
19}
20
21export async function pressBanner(host: Host, banner: string, button: string): Promise<void> {
22 const action = actions.get(key(banner, button))
23 if (button === DISMISS || !action) {
24 await host.setBanner(banner, null)
25 return
26 }
27 await action(host)
28}
29
30/** Buttons in exo's panes, by key prefix (`sel:`, `revert:`): the rest of the key is the argument. */
31const paneActions = new Map<string, (host: Host, arg: string) => Promise<void> | void>()
32
33export function onPane(prefix: string, action: (host: Host, arg: string) => Promise<void> | void): void {
34 paneActions.set(prefix, action)
35}
36
37export async function pressPane(host: Host, key: string): Promise<void> {
38 const cut = key.indexOf(':')
39 const action = paneActions.get(key.slice(0, cut + 1))
40 if (action) await action(host, key.slice(cut + 1))
41}
42modules/cockpit/sidebar.ts 154 lines1/**
2 * #7 Changes sidebar: every file changed in this session through
3 * Write/Edit, with +/− against its state before the first change, a diff,
4 * and a reset (asked first, snapshot first).
5 *
6 * The originals are kept on disk under ~/.claude/exo/originals/<session>/,
7 * so a reload of the mod does not lose them. Changes made through Bash are
8 * not seen (a known limit).
9 */
10import type { Host } from '../../core/adapter/host'
11import type { ModuleEnv, Step } from '../../core/dispatcher/dispatcher'
12import { counts, unified } from '../../core/diff'
13import { exoDir } from '../../core/killswitch'
14import { onPane } from '../../core/statusline/banners'
15import { snapshotId } from '../waechter/brake-logic'
16import type { SnapshotMeta } from '../waechter/brake-logic'
17import { readSnapshots, snapshotsDir, writeSnapshots } from '../waechter/brake'
18
19interface Original {
20 /** Text before the first change; null: the file did not exist. */
21 text: string | null
22}
23
24const originals = new Map<string, Original>()
25let selected: string | null = null
26let envRef: ModuleEnv | null = null
27let loadedFor = ''
28
29const dirFor = (env: ModuleEnv) => (env.home ? `${exoDir(env.home)}/originals/${env.sessionId || 'ohne-id'}` : null)
30
31async function persist(env: ModuleEnv): Promise<void> {
32 const dir = dirFor(env)
33 if (!dir) return
34 const index: Record<string, string | null> = {}
35 let i = 0
36 for (const [path, o] of originals) {
37 if (o.text === null) index[path] = null
38 else {
39 const file = `${dir}/${i++}.orig`
40 await env.host.writeFile(file, o.text)
41 index[path] = file
42 }
43 }
44 await env.host.writeFile(`${dir}/index.json`, JSON.stringify(index, null, 2) + '\n')
45}
46
47async function load(env: ModuleEnv): Promise<void> {
48 const dir = dirFor(env)
49 if (!dir || loadedFor === dir) return
50 loadedFor = dir
51 originals.clear()
52 try {
53 const index = JSON.parse(await env.host.readFile(`${dir}/index.json`)) as Record<string, string | null>
54 for (const [path, file] of Object.entries(index)) originals.set(path, { text: file === null ? null : await env.host.readFile(file) })
55 } catch {
56 // a new session: nothing to restore
57 }
58}
59
60async function current(host: Host, path: string): Promise<string | null> {
61 return (await host.exists(path).catch(() => false)) ? await host.readFile(path).catch(() => null) : null
62}
63
64/** Recomputes the list (and the diff of the selected file) and draws it. */
65export async function refreshChanges(env: ModuleEnv): Promise<void> {
66 envRef = env
67 await load(env)
68 const rows = []
69 for (const [path, o] of originals) {
70 const now = (await current(env.host, path)) ?? ''
71 const c = counts(o.text ?? '', now)
72 if (c.added || c.removed || (o.text === null && now !== '')) rows.push({ path, added: c.added, removed: c.removed, isNew: o.text === null })
73 }
74 rows.sort((a, b) => a.path.localeCompare(b.path))
75 if (selected && !rows.some(r => r.path === selected)) selected = null
76 let diff = ''
77 if (selected) {
78 const o = originals.get(selected)!
79 const rel = env.project && selected.startsWith(env.project + '/') ? selected.slice(env.project.length + 1) : selected.replace(/^\//, '')
80 diff = unified(rel, o.text ?? '', (await current(env.host, selected)) ?? '')
81 const lines = diff.split('\n')
82 if (lines.length > 2000) diff = lines.slice(0, 2000).join('\n') + '\n'
83 }
84 await env.host.setChanges(rows, selected, diff)
85}
86
87/** Resets a file to its state before the session, after asking; a snapshot first. */
88export async function revert(env: ModuleEnv, path: string): Promise<string> {
89 const o = originals.get(path)
90 if (!o) return 'No original version known.'
91 const what = o.text === null ? `${path} was created in this session – delete it?` : `Revert ${path} to its state before this session?`
92 let ok = false
93 try {
94 ok = (await env.host.ask(what, ['Revert', 'Cancel'])) === 'Revert'
95 } catch {
96 ok = false
97 }
98 if (!ok) return 'Cancelled.'
99 if (env.home && (await env.host.exists(path).catch(() => false))) {
100 const now = await env.host.now()
101 const id = snapshotId(now, Math.random().toString(36).slice(2, 6))
102 const dir = `${snapshotsDir(env.home)}/${id}`
103 await env.host.run(['mkdir', '-p', dir])
104 const tar = await env.host.run(['tar', '-czPf', `${dir}/files.tgz`, '--', path])
105 if (tar.exitCode !== 0) return `Snapshot failed, nothing changed: ${tar.stderr.trim().slice(0, 200)}`
106 const meta: SnapshotMeta = { id, at: now, kind: 'revert', cwd: env.project, summary: 'Revert', tar: `${dir}/files.tgz`, files: [path], bytes: 0 }
107 await env.host.writeFile(`${dir}/meta.json`, JSON.stringify(meta, null, 2) + '\n')
108 await writeSnapshots(env.store, [meta, ...(await readSnapshots(env.store))])
109 }
110 if (o.text === null) await env.host.run(['rm', '-f', '--', path])
111 else await env.host.writeFile(path, o.text)
112 await refreshChanges(env)
113 return `${path} reset (saved first, /undo-last brings it back).`
114}
115
116export function sidebarStep(): Step {
117 return {
118 id: 'sidebar',
119 async start(env) {
120 envRef = env
121 await load(env)
122 onPane('sel:', async (_host, path) => {
123 if (!envRef) return
124 selected = selected === path ? null : path
125 await refreshChanges(envRef)
126 })
127 onPane('revert:', async (host, path) => {
128 if (!envRef) return
129 host.toast(await revert(envRef, path), 6000)
130 })
131 await refreshChanges(env)
132 },
133 async after(ctx, result) {
134 const path = String(ctx.call.input.file_path ?? ctx.call.input.notebook_path ?? '')
135 if (!path || ctx.fileBefore === undefined || result.deny !== undefined || result.isError) return
136 const env: ModuleEnv = { host: ctx.host, journal: ctx.journal, config: ctx.config, store: ctx.store, home: ctx.home, project: envRef?.project ?? ctx.cmdCwd.cwd, cwd: ctx.cwd, interactive: ctx.interactive, sessionId: ctx.journal.sessionId }
137 await load(env)
138 if (!originals.has(path)) {
139 originals.set(path, { text: ctx.fileBefore })
140 await persist(env)
141 }
142 await refreshChanges(env)
143 },
144 }
145}
146
147/** For tests: forget everything. */
148export function resetSidebar(): void {
149 originals.clear()
150 selected = null
151 envRef = null
152 loadedFor = ''
153}
154modules/rueckblick/hours.ts 73 lines1/**
2 * #17 Project time tracking: active time per project from the journal's
3 * activity, today's total in the hint line, the week with `/hours`, an export
4 * as CSV or JSON for personal use.
5 */
6import type { ModuleEnv, Step } from '../../core/dispatcher/dispatcher'
7import type { JournalEvent } from '../../core/journal/journal'
8import { exoDir } from '../../core/killswitch'
9import { dayKey, emptyHours, hm, prune, readHours, record, toCsv, toJson, weekTable } from './hours-logic'
10import type { Hours } from './hours-logic'
11
12const SLOT = 'hours'
13const ACTIVITY = new Set<JournalEvent['type']>(['prompt.submit', 'tool.start', 'tool.end', 'turn.complete'])
14
15const st: { hours: Hours; env: ModuleEnv | null; shown: string; unsubscribe: (() => void) | null } = { hours: emptyHours(), env: null, shown: '', unsubscribe: null }
16
17async function showToday(env: ModuleEnv, now: number): Promise<void> {
18 const s = st.hours.days[dayKey(now)]?.[env.project] ?? 0
19 const text = s >= 60 ? `⏱ ${hm(s)} today` : ''
20 if (text === st.shown) return
21 st.shown = text
22 await env.host.setSlot(SLOT, text ? { id: SLOT, order: 30, priority: 20, text, dim: true } : null)
23}
24
25function onEvent(e: JournalEvent): void {
26 const env = st.env
27 if (!env || !ACTIVITY.has(e.type)) return
28 st.hours = record(st.hours, env.project, e.at)
29 env.store?.later('hours', () => st.hours)
30 void showToday(env, e.at).catch(() => undefined)
31}
32
33export async function hoursCommand(env: ModuleEnv, args: string): Promise<string> {
34 const [sub = '', fmt = 'csv'] = args.trim().split(/\s+/)
35 const now = await env.host.now()
36 await env.store?.flush()
37 if (sub === 'export') {
38 if (!env.home) return 'No home directory known.'
39 const json = fmt === 'json'
40 const path = `${exoDir(env.home)}/hours-${dayKey(now)}.${json ? 'json' : 'csv'}`
41 await env.host.writeFile(path, json ? toJson(st.hours) : toCsv(st.hours))
42 return `Exported: ${path}`
43 }
44 return weekTable(st.hours, now)
45}
46
47export function hoursStep(): Step {
48 return {
49 id: 'hours',
50 async start(env) {
51 st.env = env
52 st.shown = ''
53 const stored = readHours(await env.store?.get('hours').catch(() => null))
54 st.hours = prune(stored, await env.host.now())
55 st.unsubscribe?.()
56 st.unsubscribe = env.journal.subscribe(onEvent)
57 await showToday(env, await env.host.now())
58 },
59 async end(env) {
60 await env.store?.set('hours', st.hours)
61 },
62 }
63}
64
65/** For tests. */
66export function hoursState() {
67 return st
68}
69export function resetHours(): void {
70 st.unsubscribe?.()
71 Object.assign(st, { hours: emptyHours(), env: null, shown: '', unsubscribe: null })
72}
73