SLOPSHOPPER

exo

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

newpanebandspinnerguardcommand
★ 1v0.2.1MITupdated 2026-10-06pepperonas/exo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · exo
│ ┃ Rubber duck ✕ › fix the failing auth test and add an audit log call │ ┃ __ │ ┃ <(o )___ ⏺ Read(src/auth.ts) │ ┃ ( ._> / ⎿ Read 6 lines │ ┃ `---' ⏺ Update(src/auth.ts) │ ┃ Question 1/5: What do you expect? ⎿ Added 2 lines, removed 1 line │ ┃ Answer, Enter = next ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ [ Skip ] ⎿ Denied by exo: exo/prod shield: cancelled by the human (f │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ ⚠ No test ran in this turn. │ │ › /exo │ ⎿ exo: exo is on. │ ⎿ exo: │ ⎿ exo: Module State Calls avg ms max │ ⎿ exo: Secret guard (secrets) on 9 0.2 1.0 │ ⎿ exo: Prod shield (prodShield) on 9 0.1 1.0 │ ⎿ exo: Cleanup brake (brake) on 8 0.0 0.0 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ⛨ exo

Draws

Pane · Rubber duck
__ <(o )___ ( ._> / `---' Question 1/5: What do you expect? Answer, Enter = next [ Skip ]
Pane · Changes
No file has been changed via Write/Edit in this session yet.
Prompt hint
⟨Claude Code's own drawing⟩ ⛨ exo
README

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

🛡️ exo

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> &nbsp; <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>

version node tests engine tests mutations lines of code

CI Claude Code mod tested with TypeScript Node runtime deps modules telemetry kill switch Keep a Changelog SemVer last commit open issues repo size stars forks PRs welcome License: MIT

Donate with PayPal Rate celox.io on Google


[!WARNING] exo is a seat belt, not a sandbox. It catches the common, expensive mistakes — a key in a commit, systemctl restart on production, rm -rf without a way back. A deliberately obfuscated command, or an agent that rewrites the mod itself, can get past it. See Limits.

📸 Screenshots

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

🛡 Guards

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

🧭 Cockpit

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

🔁 Review and extras

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

✨ Features

  • Fourteen modules, one mod — guards, cockpit, review and extras share one dispatcher with a fixed order, one journal and one status line. Every module switches on and off on its own.
  • Guards fail closed — if a guard crashes, only the call it was checking is refused. Comfort modules fail open and never get in your way.
  • Risky steps are reversible — before rm -rf, git reset --hard or git clean, exo takes a snapshot; /undo-last brings it back.
  • Asks instead of just blocking — production commands open a dialog with run, cancel and dry run, and your own house rules are checked first.
  • Sees inside shell commands — a real Bash parser follows $(…), backticks, arithmetic, bash -c, ssh host '…', find -exec, sudo/env/xargs wrappers and heredocs, so a destructive command can't hide in a substitution.
  • Protects itself — Claude can't switch exo off. Changes to exo's own settings need your allow; if a call changes them anyway, exo notices the effect afterwards and puts them back.
  • A cockpit while you work — tests run in the background after each change, the CI state sits under the prompt, every changed file is one click from its diff.
  • A recap at the end — duration, active time, files, tests before and after, cost, open points, and lessons for your CLAUDE.md that are only written when you tick them.
  • No telemetry, no runtime dependencies — the only network use is gh for the CI light and one model call when you ask for /recap.

🧩 Modules

GroupModuleWhat it doesCommand
🛡 GuardsSecret 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).–
🛡 GuardsProd 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.–
🛡 GuardsCleanup 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
🛡 GuardsContext 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.–
🧭 CockpitTest 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.–
🧭 CockpitDone check (doneCheck)When an answer claims "done" or "works" although files changed and no green test ran afterwards: a quiet note under the answer.–
🧭 CockpitChanges sidebar (sidebar)Every file changed in the session with +/−, its diff, and revert (with confirmation and a snapshot)./changes
🧭 CockpitCI 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.–
🔁 ReviewRecap (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]`
🔁 ReviewLessons (lessons)Suggests 1–3 lessons from the session for the project's CLAUDE.md. Nothing is written without a tick.(in /recap)
🔁 ReviewTime 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]`
🎉 ExtrasAchievements (achievements)15 badges — ten green turns in a row, early bird, Pac-Man never saw the ghost, …/achievements
🎉 ExtrasSpinner 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.–
🎉 ExtrasRubber 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.

📥 Install

Requirements

  • Claude Code with mods — exo is tested with 2.1.289.
  • Optional: gh (logged in) for the CI light; git for the cleanup brake's stash snapshots; macOS for sound.

Option 1 — marketplace (recommended)

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.

Option 2 — skills folder

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.

Option 3 — one session

git clone https://github.com/pepperonas/exo
claude --plugin-dir ./exo

Option 4 — desktop app and SDK hosts

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.

Production hosts

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.

🕹️ Usage

CommandWhat it does
/exoStatus of every module: on/off, time spent, last error, kill switch, rules
/exo on · /exo offEverything on / off (stored)
`/exo on\off <module>`One module, e.g. /exo off cinema
/exo reset <module>Clears a module's broken mark
/exo rulesThe house rules in force
/undo-last [id] · /undo-listRestore a cleanup-brake snapshot · list them
/changesThe 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
/achievementsYour badges
/duckRubber-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.

⚙️ Configuration

All options are in /config under exo, or in pluginConfigs.exo.options in ~/.claude/settings.json.

OptionTypeDefaultMeaning
secretsswitchonSecret guard
prodShieldswitchonProd shield
brakeswitchonCleanup brake
dietswitchonContext diet
testLightswitchonTest light
doneCheckswitchonDone check
sidebarswitchonChanges sidebar
ciswitchonCI light
recapswitchonRecap
lessonsswitchonLessons
hoursswitchonTime tracking
achievementsswitchonAchievements
cinemaswitchonSpinner cinema
duckswitchonRubber duck
prodHostslist of name=addressemptyProduction hosts
secretAllowPathslist of globs/test/fixtures/, **/*.example, **/.env.examplePaths where secrets are allowed
snapshotMaxMbnumber500Above this the cleanup brake asks instead of saving silently
dietMaxKbnumber256From this size Claude reads head and tail only
dietMaxLinesnumber2000Likewise from this many lines
testCommandtextemptyOverrides runner detection; {files} becomes the changed files
quietHoursHH-HH22-07No achievement toasts or sounds during these hours
soundswitchoffSound on new badges (macOS only)
reducedMotionswitchoffSpinner cinema as a still image

/exo on|off <module> overrides the switches for you, persistently (kept in exo's store).

House rules (~/.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)."
    }
  ]
}
FieldMeaning
idLower-case letters, digits, -
hostsNames from prodHosts, or *
matchRegular expression on the command
checkCheck command as argv; {host} becomes the address
blockWhenexit0 (block when the check exits 0) or exitNonZero
textReason 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.

🚨 Kill switch

Three ways to switch exo off completely:

  1. touch ~/.claude/exo/DISABLED — works immediately, from a second terminal too, even if exo is blocking Bash in this session.
  2. EXO_DISABLE=1 claude … — for the whole session.
  3. /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.

🧠 How it works

 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
  • One adapter. 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.
  • A fixed order with an error policy. Guards fail closed, comfort modules fail open; every step has a 50 ms budget.
  • **Text check and effect check.** The text check warns before a call; the effect check compares exo's control state before and after, and that is what actually holds — four review rounds kept finding new spellings that slip past any text check.
  • One journal. Every module reads the same session journal (commands without arguments, never prompt or answer text); recap, hours and achievements are built on it.
  • A real shell parser. Words carry quoting, expansions, globs and nested substitutions; parsing never throws and is bounded in depth and size.

🔒 Privacy

  • No telemetry. The only network use is gh (CI light) and one model call through Claude Code's own session when /recap lists open points.
  • Stored in exo's plugin store (at most ~3 MiB): switches, the journal of the current session (commands without arguments, no prompt or answer text), session facts, hours, achievements, test-light consents.
  • On disk under ~/.claude/exo/: rules.json, snapshots (snapshots/, newest 20 or 7 days), the sidebar's originals (originals/), hour exports.
  • Secrets never appear in plain text in messages, the journal or the store.

⚠️ Limits

  • A seat belt, not a sandbox. exo catches what is recognisable in commands and file contents. Deliberately obfuscated commands (in scripts Claude wrote beforehand, for example), tools outside Claude Code and an agent that rewrites exo itself are not covered.
  • If the module doesn't load, it doesn't protect. Claude Code lets everything through when a mod fails to load. /exo shows the state; if ⛨ exo is missing under the prompt, exo is not active.
  • A crashing guard refuses only the call it was checking (fail closed); comfort modules keep running on errors (fail open). If exo's core fails, only read-only tools get through.
  • Dialogs need a surface. Under claude -p the prod shield refuses, the test light starts nothing, and control changes are reverted.
  • Bash working directory: exo follows the cds between calls. After cd - or cd $VAR it no longer knows the place for sure.
  • The sidebar and the done check see changes made through Write/Edit, not through Bash (sed -i, scripts).
  • ~/.ssh/config: Include and Match are not evaluated.
  • Test light: with 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.
  • exo's own writes (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).
  • Sound on macOS only (afplay).

🏛️ Architecture

PathRole
hooks/register.tsxThe mod: registration, the hostOf($) adapter, $.state atoms, render hooks, commands
core/adapter/host.tsThe 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.tsText 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.mdThe design and its decisions

Detection logic is pure and engine-free (*-logic.ts); the modules around it do the I/O.

🧪 Testing

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 t
Source 50 files
hooks/register.tsx 566 lines
1/**
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}
566
core/adapter/host.ts 107 lines
1/**
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}
107
core/complete.ts 66 lines
1/**
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}
66
core/exo-command.ts 115 lines
1/**
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 }
115
core/health/health.ts 88 lines
1/**
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}
88
core/i18n.ts 22 lines
1/**
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
22
core/runtime.ts 195 lines
1/**
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}
195
core/killswitch.ts 67 lines
1/**
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}
67
core/safepath.ts 50 lines
1/**
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}
50
core/statusline/banners.ts 42 lines
1/**
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}
42
modules/cockpit/sidebar.ts 154 lines
1/**
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}
154
modules/rueckblick/hours.ts 73 lines
1/**
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