SLOPSHOPPER

Oh My ClaudeAgent

Claude Code, with receipts. Plans with you, hands each task to a specialist agent, and keeps a session from calling work done until a verification is logged.

newpanebandspinnerguardcommand
★ 1v2.21.0MITupdated 2026-10-08UtsavBalar1231/oh-my-claudeagent
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · oh-my-claudeagent
│ ┃ OMCA ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Agents 2: P 3: E 4: N 5: S 6: D │ ┃ ──────────────────────────────────────────── ● oh-my-claudeagent: cannot read the plugin version: ENOENT: no such │ ┃ No subagent has run in this session yet. ● oh-my-claudeagent: the omca server is not connected (undefined), so │ ┃ Each subagent gets a lane here. Enter or a c ⏺ Read(src/auth.ts) │ ┃ opens its page. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ 42s · 97.4k in 1.5k out │ │ › /omca │ ● oh-my-claudeagent: cannot read the plugin version: ENOENT: no such │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · OMCA
1: Agents 2: P 3: E 4: N 5: S 6: D ───────────────────────────────────────────────────── No subagent has run in this session yet. Each subagent gets a lane here. Enter or a click opens its page.
README

oh-my-claudeagent

Claude Code, with receipts.

<img src=".github/assets/hero.png" width="1000" alt="A Claude Code session on the left hands task 7 of the checkout plan to the executor, while the OMCA pane on the right shows the plan board at 6 of 14 tasks, with proof on the done tasks, the executor on task 7 and task 7's detail beside the list.">

oh-my-claudeagent (OMCA) is a Claude Code plugin that plans work with you, hands each task to a specialist agent, and keeps a session from calling work done before a verification has been logged.

Requirements

  • Claude Code 2.1.292 or later. Tested with 2.1.294.
  • bun 1.4.2 or later, on the PATH Claude Code starts with. The omca server, its hooks and the status line run on bun.
  • ast-grep (or sg), optional. Only the structural code search tools need it.

CI runs on Linux, macOS and Windows.

Install

claude plugin install oh-my-claudeagent --marketplace UtsavBalar1231/oh-my-claudeagent

The command adds the omca marketplace when it is not added yet, then installs from it. The marketplace installs from the plugin branch, a packaged tree with no package.json or lockfile, so installing fetches no npm dependencies.

Inside a session, the same steps are /plugin marketplace add UtsavBalar1231/oh-my-claudeagent and /plugin install oh-my-claudeagent@omca. Then run /oh-my-claudeagent:omca-setup, which checks the requirements above and offers to point your status line at OMCA's renderer.

Your first plan

  1. Run /oh-my-claudeagent:plan add rate limiting to the login endpoint. The planner asks what it needs to know, has the plan reviewed, and writes it to your plans directory.
  2. Run /oh-my-claudeagent:start-work. The session hands each task to an executor, records a verification after each one, and checks the plan's boxes as tasks finish.
  3. Open /omca to watch the agents, the plan, the evidence and the notepad while it runs.

When the session tries to stop with tasks unchecked, or with every task checked and no final verification logged, OMCA sends it back to work with the reason.

What you see

The band above the prompt shows the bound plan's progress, the next open task, how many tasks are proven, unproven and failed, a verification whose evidence was not logged, and how many agents are running. The numbered buttons fill the prompt with the next step.

<img src=".github/assets/band.png" width="680" alt="The band above the prompt reads 6/14, next 7 Wire the order summary panel, 6 proven and 1 unproven, warns that the evidence for just test was not logged, and counts 1 running agent, with the button 1: Log evidence below it.">

/omca plan opens the plan board: the tasks grouped by milestone, each with its state, its proof and the agent working on it. A wide pane shows the focused task's detail beside the list.

<img src=".github/assets/plan.png" width="680" alt="The Plan tab of the OMCA pane shows the checkout-redesign board at 6 of 14 tasks, with 6 proven, 1 unproven and 4 blocked. Tasks 1 to 6 are done and proven, the executor is on task 7, tasks 8, 9 and 11 are open, and the rest wait on other tasks. Beside the list, task 6, Build the payment step, shows its steps, its done-when command, its dependencies, its file changed 3h ago and the passing runs since then.">

The Evidence tab is the proof ledger: a verdict on the plan's final verification, then every logged run grouped by day.

<img src=".github/assets/evidence.png" width="680" alt="The Evidence tab opens on one line: the final verification for checkout-redesign is MISSING, and build, test, lint and manual each passed on their newest runs. Below it, fourteen runs are grouped under three days, each with its outcome, time, type and command. The focused run, a manual payment smoke test, is open in a card beside the list titled Manual run passed, with its exit code, time and agent, one secret masked, and its command and output wrapped under their own labels.">

The Agents tab gives each subagent a lane led by its mascot, with its task, current tool call, model, effort and tokens. The last wave's finished agents keep their lanes with their results, earlier ones shrink to a line, and in a short pane each lane keeps to two rows.

<img src=".github/assets/agents.png" width="680" alt="The Agents tab shows two running executors, each led by its mascot in a hard hat with a hammer: one on Wire the order summary panel and one on Persist the draft order, each beside its watch command, its count of tool calls, and its model, effort and tokens, with a blank row between them. Under a Finished label, an explorer from an earlier wave is one line with its task and the first line of its result.">

When the pane has room, each lane gets four rows of its own. The agent page opens a subagent's brief, tool calls, reply and cost, and toasts report finished waves, failed verifications and a completed plan.

<img src=".github/assets/mascots.png" width="680" alt="The Agents tab with three agents running at once, each lane led by its pixel mascot: a green executor in a yellow hard hat with a hammer, a blue explorer in a safari hat with a spyglass, and a purple architect with a lit bulb beside its head. Beside each mascot, four rows give the agent's name with its running time, its task, its running Bash sleep with one call, and its model, effort and tokens, with a blank row between agents.">

The guard holds a destructive shell command for your review and shows what it would touch.

<img src=".github/assets/guard.png" width="680" alt="A dialog headed OMCA guard says OMCA held rm -rf build for review, that it would remove the build directory with 4 entries, and offers Refuse or Run it.">

/omca doctor checks the client, bun, the server, ast-grep, the options and the settings that change how OMCA runs. A check with a known fix offers a key that fills the prompt with it.

<img src=".github/assets/doctor.png" width="680" alt="The Doctor tab counts 1 warning, 1 info row and 11 passing checks. The warning says maxEffortLevel high holds architect below the xhigh it declares, the info row offers a: Use /advisor fable, and the passing rows cover OMCA, Claude Code, bun, the omca server, ast-grep, the options, the agent models, the mod policy, the hooks, the output style and the status line.">

The status line shows the session (model, agent and the plan's next task), the workspace (context window and git state) and usage (duration, usage limits, and cost for accounts billed by the token) on rows of their own, and fits itself to the terminal's width. The subagent status line gives each running agent a row.

<img src=".github/assets/statusline.png" width="900" alt="The OMCA status line under the prompt shows three rows: the model, effort and plan progress with the next task; a context bar, the git branch with change counts and the project; and the session cost and duration. Below it, one row for each of two running subagents shows its type, model, state, effort, context and task. Above, the OMCA pane has opened by itself on the two executors' lanes.">

In motion: /omca plan opens the board, Down moves the focus, Enter opens the task as a page, and the number keys switch between the pane's tabs.

<img src=".github/assets/pane-tour.gif" width="900" alt="An animation: a prompt hands task 7 to the executor and the OMCA pane opens by itself beside the session, its Agents tab showing the executor's lane led by its mascot, then /omca plan switches the pane to the board with task 7 expanded, Down moves to task 8 and Enter opens it as a page, 1 switches to the Agents tab with the executor's lane, 3 to the Evidence tab, and 2 returns to task 8 on the Plan tab.">

The guard dialog arrives the moment the model asks for a destructive command.

<img src=".github/assets/guard-dialog.gif" width="680" alt="An animation: the prompt Clean the build is typed and sent, the model asks to run rm -rf build, and the OMCA guard dialog appears holding the command, listing the build directory it would remove, with Refuse selected.">

Documentation

  • Usage: setup, planning, the band and pane, the guard, the doctor, the status line and troubleshooting.
  • Reference: agents, skills, MCP tools, hooks and kill switches, configuration, state files, where each feature works, and the comparison with similar plugins.
  • File formats: plan files, the evidence ledger, notepads and the plan registry, with JSON Schemas for the ledger and the registry.
  • Contributing: development setup, adding agents, skills and hooks, and the prose and comment policy.
  • Changelog.

Acknowledgments

Based on oh-my-openagent by @code-yeongyu.

License

MIT

Source 60 files
hooks/register.ts 197 lines
1import type { EngineInterface, EventResult, PluginOptions, Register } from "claude-code";
2import { agentsTracker } from "./agents-tracker.ts";
3import { band } from "./band.ts";
4import { bashGuard } from "./bash-guard.ts";
5import { compact } from "./compact.ts";
6import { completion } from "./completion.ts";
7import { dispatch, dispatchStream, featuresFor } from "./dispatch.ts";
8import { footer } from "./footer.ts";
9import type { Host, Options } from "./host.ts";
10import { metrics } from "./metrics.ts";
11import { modMarker } from "./mod-marker.ts";
12import { router } from "./omca-router.ts";
13import { pane } from "./pane.ts";
14import { serverCheck } from "./server-check.ts";
15import { spinner } from "./spinner.ts";
16
17function bindHost($: EngineInterface, options: Options): Host {
18  return {
19    options,
20    plugin: { name: $.plugin.name, root: $.plugin.root },
21    log: (text) => $.ui.log(text, { to: "debug" }),
22    env: {
23      HOME: () => $.env.get("HOME"),
24      USERPROFILE: () => $.env.get("USERPROFILE"),
25      HOMEDRIVE: () => $.env.get("HOMEDRIVE"),
26      HOMEPATH: () => $.env.get("HOMEPATH"),
27      CLAUDE_CONFIG_DIR: () => $.env.get("CLAUDE_CONFIG_DIR"),
28      OMCA_GLYPHS: () => $.env.get("OMCA_GLYPHS"),
29      OMCA_DISABLED_HOOKS: () => $.env.get("OMCA_DISABLED_HOOKS"),
30      CLAUDE_CODE_SUBAGENT_MODEL_FORCE: () => $.env.get("CLAUDE_CODE_SUBAGENT_MODEL_FORCE"),
31      CLAUDE_CODE_DISABLE_ADVISOR_TOOL: () => $.env.get("CLAUDE_CODE_DISABLE_ADVISOR_TOOL"),
32      DISABLE_TELEMETRY: () => $.env.get("DISABLE_TELEMETRY"),
33      DO_NOT_TRACK: () => $.env.get("DO_NOT_TRACK"),
34      DISABLE_GROWTHBOOK: () => $.env.get("DISABLE_GROWTHBOOK"),
35      CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: () => $.env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"),
36    },
37    fs: {
38      read: (path) => $.fs.read(path),
39      readBytes: (path) => $.fs.read(path, { as: "bytes" }),
40      write: (path, text) => $.fs.write(path, text),
41      stat: (path, options) => $.fs.stat(path, options),
42      list: (path) => $.fs.list(path),
43      exists: (path) => $.fs.exists(path),
44    },
45    process: { run: (argv, init) => $.process.run(argv, init) },
46    mcp: { connect: (server) => $.mcp.connect(server) },
47    ui: {
48      say: (text) => $.ui.log(text),
49      open: (args) => $.ui.open(args),
50      panes: () => $.ui.panes(),
51      ask: (question, choices) => $.ui.ask(question, choices),
52      invalidate: () => $.ui.invalidate("ui.render"),
53      focus: (args) => $.ui.focus(args),
54      resolve: (e) => $.ui.resolve(e),
55      copy: (args) => $.ui.copy(args),
56      toast: (text, toastOptions) => $.ui.toast(text, toastOptions),
57      blit: (args) => $.ui.blit(args),
58    },
59    session: {
60      id: () => $.session.id(),
61      root: () => $.session.root(),
62      cwd: () => $.session.cwd(),
63      surfaces: () => $.session.surfaces(),
64      usage: (args) => $.session.usage(args),
65      version: () => $.session.version(),
66      transcript: (args) => $.session.messages(args),
67    },
68    settings: { read: (args) => $.settings.read(args) },
69    agent: { list: () => $.agent.list() },
70    command: { register: (spec) => $.command.register(spec) },
71    clock: {
72      now: () => $.clock.now(),
73      after: (ms, fn) => $.clock.after(ms, fn),
74      every: (ms, fn) => $.clock.every(ms, fn),
75    },
76    prompt: { fill: (args) => $.prompt.fill(args) },
77    state: {
78      agents: {
79        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "agents" }),
80        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "agents" }, value, setOptions),
81      },
82      lanes: {
83        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "lanes" }),
84        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "lanes" }, value, setOptions),
85      },
86      nextActions: {
87        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "nextActions" }),
88        set: (value, setOptions) =>
89          $.state.set({ plugin: "oh-my-claudeagent", key: "nextActions" }, value, setOptions),
90      },
91      band: {
92        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "band" }),
93        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "band" }, value, setOptions),
94      },
95      pane: {
96        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "pane" }),
97        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "pane" }, value, setOptions),
98      },
99      ledger: {
100        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "ledger" }),
101        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "ledger" }, value, setOptions),
102      },
103      plan: {
104        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "plan" }),
105        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "plan" }, value, setOptions),
106      },
107      stats: {
108        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "stats" }),
109        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "stats" }, value, setOptions),
110      },
111      costSample: {
112        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "costSample" }),
113        set: (value, setOptions) =>
114          $.state.set({ plugin: "oh-my-claudeagent", key: "costSample" }, value, setOptions),
115      },
116      doctor: {
117        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "doctor" }),
118        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "doctor" }, value, setOptions),
119      },
120      agentPage: {
121        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "agentPage" }),
122        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "agentPage" }, value, setOptions),
123      },
124      pages: {
125        get: () => $.state.get({ plugin: "oh-my-claudeagent", key: "pages" }),
126        set: (value, setOptions) => $.state.set({ plugin: "oh-my-claudeagent", key: "pages" }, value, setOptions),
127      },
128    },
129  };
130}
131
132export function readOptions(options: PluginOptions): Options {
133  return {
134    showBand: options["showBand"] !== false,
135    guardMode: options["guardMode"] === "deny" ? "deny" : "dialog",
136    enableKeywordTriggers: options["enableKeywordTriggers"] === true,
137  };
138}
139
140const guardFailed = (reason: string): EventResult<"tool.check"> => ({
141  decision: "deny",
142  reason: `OMCA's Bash guard failed, so the command was refused: ${reason}`,
143});
144
145export const register: Register = (on, pluginOptions) => {
146  const options = readOptions(pluginOptions);
147  const bash = featuresFor("tool.check", { bashGuard });
148  const toolCall = featuresFor("tool.call", { agentsTracker });
149  const sessionStart = featuresFor("session.start", { pane, modMarker, band, serverCheck });
150  const turnStart = featuresFor("turn.start", { footer, modMarker });
151  const turnStep = featuresFor("turn.step", { metrics, agentsTracker });
152  const turnComplete = featuresFor("turn.complete", { band, agentsTracker, metrics, pane, footer });
153  const agentSpawn = featuresFor("agent.spawn", { agentsTracker, pane, metrics });
154  const promptEdit = featuresFor("prompt.edit", { band });
155  const promptAutocomplete = featuresFor("prompt.autocomplete", { completion });
156  const paneClose = featuresFor("ui.close", { pane });
157  const paneFocus = featuresFor("ui.focus", { pane });
158  const paneScroll = featuresFor("ui.scroll", { pane });
159  const omca = featuresFor("command.run", { router });
160  const bandRender = featuresFor("ui.render AbovePrompt", { band });
161  const paneRender = featuresFor("ui.render Pane", { pane });
162  const spinnerRender = featuresFor("ui.render Spinner", { spinner });
163  const sessionCompact = featuresFor("session.compact", { compact });
164
165  on("tool.check", { tool: /^(?:Bash|PowerShell)$/ }, ($, e, next) =>
166    dispatch(bindHost($, options), "tool.check", bash, e, next, guardFailed),
167  ).catch((_$, _e, next) => guardFailed(next.error.message ?? next.error.kind));
168  on("tool.call", ($, e, next) => dispatch(bindHost($, options), "tool.call", toolCall, e, next));
169  on("session.start", ($, e, next) => dispatch(bindHost($, options), "session.start", sessionStart, e, next));
170  on("turn.start", ($, e, next) => dispatch(bindHost($, options), "turn.start", turnStart, e, next));
171  on("turn.step", async function* ($, e, next) {
172    return yield* dispatchStream(bindHost($, options), "turn.step", turnStep, e, next);
173  });
174  on("turn.complete", ($, e, next) => dispatch(bindHost($, options), "turn.complete", turnComplete, e, next));
175  on("agent.spawn", ($, e, next) => dispatch(bindHost($, options), "agent.spawn", agentSpawn, e, next));
176  on("prompt.edit", ($, e, next) => dispatch(bindHost($, options), "prompt.edit", promptEdit, e, next));
177  on("prompt.autocomplete", ($, e, next) => dispatch(bindHost($, options), "prompt.autocomplete", promptAutocomplete, e, next));
178  on("ui.close", ($, e, next) => dispatch(bindHost($, options), "ui.close", paneClose, e, next));
179  on("ui.focus", ($, e, next) => dispatch(bindHost($, options), "ui.focus", paneFocus, e, next));
180  on("ui.scroll", { component: "Pane" }, ($, e, next) =>
181    dispatch(bindHost($, options), "ui.scroll", paneScroll, e, next),
182  );
183  on("command.run", { command: "omca" }, ($, e, next) =>
184    dispatch(bindHost($, options), "command.run omca", omca, e, next),
185  );
186  on("ui.render", { component: "AbovePrompt" }, ($, e, next) =>
187    dispatch(bindHost($, options), "ui.render AbovePrompt", bandRender, e, next),
188  );
189  on("ui.render", { component: "Pane" }, ($, e, next) =>
190    dispatch(bindHost($, options), "ui.render Pane", paneRender, e, next),
191  );
192  on("ui.render", { component: "Spinner" }, ($, e, next) =>
193    dispatch(bindHost($, options), "ui.render Spinner", spinnerRender, e, next),
194  );
195  on("session.compact", ($, e, next) => dispatch(bindHost($, options), "session.compact", sessionCompact, e, next));
196};
197
hooks/agents-tracker.ts 263 lines
1import type { AgentInfo, TurnUsage } from "claude-code";
2import { firstLine, lastLine, STATUS_WORDS, toolDetail } from "../src/core/mission.ts";
3import { taskReference } from "../src/core/plan-reader.ts";
4import { estimateCostUsd } from "../src/core/pricing.ts";
5import { fitEnd, glyphs, oneLine } from "../src/core/ui-kit.ts";
6import { redact } from "../src/core/visual.ts";
7import type { Features } from "./dispatch.ts";
8import { type Host, sessionOf, type State, update } from "./host.ts";
9
10type Row = State["agents"][string];
11type Lane = State["lanes"][string];
12type Page = State["pages"][string];
13type Call = Page["calls"][number];
14type Status = Row["status"];
15
16const PROMPT_CELLS = 400;
17const LINE_CELLS = 200;
18const MAX_BRIEF = 8 * 1024;
19const MAX_REPLY = 4 * 1024;
20const MAX_CALLS = 120;
21const MAX_PAGES = 20;
22const MAX_PENDING = 500;
23
24// A subagent's tool calls as `tool.call` saw them, the most recently calling agent last; the page atom is filled from it.
25const calls = new Map<string, Call[]>();
26// A call's start, dropped when its `post` runs; one whose `next` rejected never reaches `post`, so
27// the agent's `turn.complete` and the cap bound what such calls leave behind.
28const startedAt = new Map<string, { agentId: string; at: number }>();
29
30// Secrets are masked before a lane is stored, and the excerpt is cut only after that, so a
31// cut can never leave half a key the masks no longer match.
32async function masked(host: Host, text: string, cells: number): Promise<string> {
33  const g = glyphs((await sessionOf(host)).glyphTier);
34  return fitEnd(redact(oneLine(text), "", g.mask).text, cells, g.ellipsis);
35}
36
37async function maskedText(host: Host, text: string, max: number): Promise<string> {
38  const g = glyphs((await sessionOf(host)).glyphTier);
39  return redact(text, "", g.mask).text.slice(0, max).replace(/[\uD800-\uDBFF]$/, "");
40}
41
42const WAVE_TOAST_MS = 5000;
43// The subagents that ended since the last wave closed; a wave closes when none is left running.
44const finished: { name: string; status: Status }[] = [];
45
46async function closeWave(host: Host): Promise<void> {
47  const { value: agents = {} } = await host.state.agents.get();
48  if (finished.length === 0 || Object.values(agents).some((row) => !row.teammate && row.status === "running")) return;
49  const { dot } = glyphs((await sessionOf(host)).glyphTier);
50  const [only] = finished;
51  const counts = new Map<Status, number>();
52  for (const { status } of finished) if (status !== "answer") counts.set(status, (counts.get(status) ?? 0) + 1);
53  const unlike = [...counts].map(([status, count]) => `${count} ${STATUS_WORDS[status] ?? "ended"}`).join(` ${dot} `);
54  const text =
55    only !== undefined && finished.length === 1
56      ? `${only.name} ${only.status === "answer" ? "finished" : (STATUS_WORDS[only.status] ?? "ended")}`
57      : `${finished.length} agents finished${unlike === "" ? "" : ` ${dot} ${unlike}`}`;
58  finished.length = 0;
59  host.ui.toast(text, { timeoutMs: WAVE_TOAST_MS });
60}
61
62// Deleting first moves the agent to the newest end, so the cap drops the agent idle longest.
63function record(id: string, call: Call): void {
64  const kept = calls.get(id) ?? [];
65  calls.delete(id);
66  calls.set(id, [...kept, call].slice(-MAX_CALLS));
67  for (const oldest of calls.keys()) {
68    if (calls.size <= MAX_PAGES) break;
69    calls.delete(oldest);
70  }
71}
72
73/** Reads the agent's transcript and keeps its page: the brief, the recorded calls and the latest reply. */
74export async function loadPage(host: Host, id: string): Promise<void> {
75  const [found, { value: lanes }, { value: agents = {} }] = await Promise.all([
76    host.session.transcript({ agentId: id }),
77    host.state.lanes.get(),
78    host.state.agents.get(),
79  ]);
80  const messages = Array.isArray(found) ? found : [];
81  const brief = messages.find((message) => message.role === "user" && message.text !== "")?.text;
82  const reply = messages.findLast((message) => message.role === "assistant" && message.text !== "")?.text;
83  const lane = lanes?.[id];
84  const page: Page = {
85    brief: await maskedText(host, brief ?? lane?.prompt ?? "", MAX_BRIEF),
86    source: brief === undefined ? "prompt" : "messages",
87    calls: [...(calls.get(id) ?? [])],
88    reply: await maskedText(host, reply ?? (lane === undefined ? "" : lane.result || lane.output), MAX_REPLY),
89  };
90  const newest = (key: string) => agents[key]?.startedAt ?? 0;
91  await update(host.state.pages, (pages) => {
92    const kept = Object.entries(pages ?? {})
93      .filter(([key]) => key !== id)
94      .sort(([a], [b]) => newest(b) - newest(a))
95      .slice(0, MAX_PAGES - 1);
96    return Object.fromEntries([...kept, [id, page]]);
97  });
98}
99
100function editLane(host: Host, id: string, change: (lane: Lane) => Lane): Promise<void> {
101  return update(host.state.lanes, (lanes) => {
102    const lane = lanes?.[id];
103    if (lanes === undefined || lane === undefined) return lanes;
104    const next = change(lane);
105    return next === lane ? lanes : { ...lanes, [id]: next };
106  });
107}
108
109const inputTokens = (usage: TurnUsage) =>
110  usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens;
111
112function stepCost(total: number | null, usage: TurnUsage | null): number | null {
113  if (total === null || usage === null) return total;
114  const cost = estimateCostUsd(usage.model, usage);
115  return cost === null ? null : total + cost;
116}
117
118function edit(host: Host, id: string, change: (row: Row) => Row): Promise<void> {
119  return update(host.state.agents, (agents) => {
120    const row = agents?.[id];
121    if (agents === undefined || row === undefined || row.endedAt !== null) return agents;
122    return { ...agents, [id]: change(row) };
123  });
124}
125
126function endAgent(host: Host, id: string, status: Status, usage: TurnUsage | undefined, at: number): Promise<void> {
127  return edit(host, id, (row) => ({
128    ...row,
129    ...(row.teammate ? { status: "idle" as const } : { status, endedAt: at }),
130    ...(usage === undefined ? {} : { inputTokens: inputTokens(usage), outputTokens: usage.output_tokens }),
131  }));
132}
133
134const LISTED_STATUS: Readonly<Record<string, Status>> = { completed: "answer", failed: "error", killed: "aborted" };
135const LISTED_OPEN: Readonly<Record<string, Status>> = { running: "running", idle: "idle", waiting: "waiting", pending: "pending" };
136
137export async function reconcile(host: Host, listed: readonly AgentInfo[], at: number): Promise<void> {
138  let ended: { name: string; status: Status }[] = [];
139  await update(host.state.agents, (agents) => {
140    ended = [];
141    if (agents === undefined) return agents;
142    const changed = Object.entries(agents).flatMap(([id, row]) => {
143      if (row.endedAt !== null) return [];
144      const info = listed.find((agent) => agent.id === id);
145      const open = info === undefined ? undefined : LISTED_OPEN[info.status];
146      if (open !== undefined) return open === row.status ? [] : [[id, { ...row, status: open }] as const];
147      const status = info === undefined ? "gone" : (LISTED_STATUS[info.status] ?? "gone");
148      if (!row.teammate) ended.push({ name: row.type.replace(/^.*:/, ""), status });
149      return [[id, { ...row, status, endedAt: at }] as const];
150    });
151    return changed.length === 0 ? agents : { ...agents, ...Object.fromEntries(changed) };
152  });
153  if (ended.length === 0) return;
154  finished.push(...ended);
155  await closeWave(host);
156}
157
158export const agentsTracker: Features = {
159  "agent.spawn": {
160    async post(host, e, result) {
161      if (result.deny !== undefined || result.agentId === undefined) return undefined;
162      const { agentId, model } = result;
163      const startedAt = await host.clock.now();
164      const task = taskReference(e.prompt, e.description);
165      await update(host.state.agents, (agents) => ({
166        ...agents,
167        [agentId]: {
168          type: e.subagentType,
169          description: e.description,
170          model,
171          effort: null,
172          startedAt,
173          endedAt: null,
174          inputTokens: 0,
175          outputTokens: 0,
176          costUsd: 0,
177          status: "running",
178          teammate: e.isTeammate === true,
179          ...(task === undefined ? {} : { task }),
180        },
181      }));
182      const prompt = await masked(host, e.prompt, PROMPT_CELLS);
183      await update(host.state.lanes, (lanes) => ({
184        ...lanes,
185        [agentId]: { prompt, calls: 0, tool: null, output: "", result: "" },
186      }));
187      return undefined;
188    },
189  },
190  "turn.complete": {
191    async post(host, e) {
192      if (e.agentId === undefined) return undefined;
193      const { value: agents } = await host.state.agents.get();
194      const before = agents?.[e.agentId];
195      await endAgent(host, e.agentId, e.reason, e.usage, await host.clock.now());
196      if (before !== undefined && before.endedAt === null && !before.teammate) {
197        finished.push({ name: before.type.replace(/^.*:/, ""), status: e.reason });
198        await closeWave(host);
199      }
200      const result = await masked(host, firstLine(e.answer), LINE_CELLS);
201      await editLane(host, e.agentId, (lane) => ({ ...lane, tool: null, result }));
202      for (const [callId, call] of startedAt) if (call.agentId === e.agentId) startedAt.delete(callId);
203      await loadPage(host, e.agentId);
204      return undefined;
205    },
206  },
207  "tool.call": {
208    async pre(host, e) {
209      if (e.agentId === undefined) return undefined;
210      startedAt.set(e.tool_use_id, { agentId: e.agentId, at: await host.clock.now() });
211      for (const oldest of startedAt.keys()) {
212        if (startedAt.size <= MAX_PENDING) break;
213        startedAt.delete(oldest);
214      }
215      return undefined;
216    },
217    async post(host, e, result) {
218      const { agentId } = e;
219      if (agentId === undefined) return undefined;
220      const [now, root] = await Promise.all([host.clock.now(), host.session.root()]);
221      const began = startedAt.get(e.tool_use_id)?.at;
222      startedAt.delete(e.tool_use_id);
223      record(agentId, {
224        tool: e.tool,
225        summary: await masked(host, toolDetail(e.tool, e, root), LINE_CELLS),
226        ok: result.deny === undefined && result.isError !== true,
227        durationMs: began === undefined ? null : now - began,
228      });
229      return undefined;
230    },
231  },
232  "turn.step": {
233    async post(host, e, result) {
234      if (e.agentId === undefined) return undefined;
235      const { usage } = result;
236      await edit(host, e.agentId, (row) => ({
237        ...row,
238        status: "running",
239        effort: e.effort ?? row.effort,
240        inputTokens: row.inputTokens + (usage === null ? 0 : inputTokens(usage)),
241        outputTokens: row.outputTokens + (usage?.output_tokens ?? 0),
242        costUsd: stepCost(row.costUsd, usage),
243      }));
244      const last = result.toolUses.at(-1);
245      const [detail, output] = await Promise.all([
246        last === undefined ? undefined : masked(host, toolDetail(last.name, last.input, await host.session.root()), LINE_CELLS),
247        masked(host, lastLine(result.answer), LINE_CELLS),
248      ]);
249      const names = result.toolUses.map((use) => use.name);
250      await editLane(host, e.agentId, (lane) => {
251        if (names.length === 0 && output === "" && lane.tool === null) return lane;
252        return {
253          ...lane,
254          calls: lane.calls + names.length,
255          tool: last === undefined || detail === undefined ? null : { name: last.name, detail },
256          output: output === "" ? lane.output : output,
257        };
258      });
259      return undefined;
260    },
261  },
262};
263
hooks/band.ts 248 lines
1import type { RenderElement } from "claude-code";
2import { type Band, bandView, BUTTON_GAP, planTally, type Proof, type Span, type Tone } from "../src/core/band-model.ts";
3import { allTasksDone } from "../src/core/checkboxes.ts";
4import { ledgerCoversSlot, readLedger } from "../src/core/evidence.ts";
5import { hasPassingFinalVerification, type NextAction, nextActions } from "../src/core/next-actions.ts";
6import { BOULDER, exitCodeOf, LEDGER, statusPath, verificationOf } from "../src/core/omca-paths.ts";
7import { boardOf, parsePlan } from "../src/core/plan-reader.ts";
8import { proofSummary } from "../src/core/proof.ts";
9import { sha256Hex } from "../src/core/sha256.ts";
10import { fitEnd, type GlyphTier, glyphs, oneLine } from "../src/core/ui-kit.ts";
11import { TONE_KEYS } from "../src/core/visual.ts";
12import type { Features } from "./dispatch.ts";
13import { boundPlanOf, bytesOf, fillPrompt, type Host, ledgerWrittenAt, proofFacts, reason, sessionOf, stampOf, verdictFor } from "./host.ts";
14import { type Kit, kitOf, type TextStyle } from "./ui.ts";
15
16type Snapshot = { band: Band; hasFinalVerification: boolean; exitCode: number | null; planPath: string | undefined };
17
18let glyphTier: GlyphTier = "nerd";
19let shownActions = 0;
20let toastedVerificationAt = 0;
21let toastedPlan: { path: string; isDone: boolean } | undefined;
22let isBaselined = false;
23let planPath: string | undefined;
24let readStamps: string | undefined;
25
26const TOAST_MS = 6000;
27const COMMAND_CELLS = 30;
28
29// A bare digit in an empty composer reaches prompt.edit before the engine resolves it as the
30// band Button's hotkey, so clearing on that edit would take the Button away from its own press.
31const isShownHotkey = (text: string): boolean => {
32  const key = text.trim();
33  return /^[1-9]$/.test(key) && Number(key) <= shownActions;
34};
35
36async function readJson(host: Host, path: string): Promise<unknown> {
37  return (await host.fs.exists(path)) ? JSON.parse(await host.fs.read(path)) : undefined;
38}
39
40async function ledgerDocument(host: Host, path: string): Promise<unknown> {
41  if (!(await host.fs.exists(path))) return undefined;
42  const read = readLedger(await host.fs.read(path));
43  if (read.kind === "refused") throw new Error(read.reason);
44  return read.document;
45}
46
47async function readSnapshot(host: Host, within: object): Promise<Snapshot> {
48  const [root, sessionId, readAt] = await Promise.all([host.session.root(), host.session.id(), host.clock.now()]);
49  const ledgerPath = `${root}/${LEDGER}`;
50  const errors: string[] = [];
51  async function attempt<T>(what: string, read: () => Promise<T>): Promise<T | null> {
52    try {
53      return await read();
54    } catch (error) {
55      errors.push(`Cannot read ${what}: ${oneLine(reason(error))}`);
56      return null;
57    }
58  }
59
60  const bound = await attempt(BOULDER, async () => (await boundPlanOf(host)) ?? null);
61  const planBytes = bound === null ? null : await attempt(bound.path, () => bytesOf(host, bound.path));
62  const planText = planBytes === null ? null : new TextDecoder().decode(planBytes);
63  const plan = bound === null || planText === null ? null : { ...bound, ...planTally(planText) };
64
65  const statusFile = statusPath(root, sessionId);
66  let exitCode: number | null = null;
67  const verification =
68    statusFile === undefined
69      ? null
70      : await attempt(`the session status file`, async () => {
71          const status = await readJson(host, statusFile);
72          exitCode = exitCodeOf(status);
73          const slot = verificationOf(status);
74          if (slot === null) return null;
75          return { ...slot, isLogged: ledgerCoversSlot(await ledgerWrittenAt(host, root), slot.at) };
76        });
77
78  const hasFinalVerification =
79    plan !== null &&
80    planBytes !== null &&
81    allTasksDone(plan) &&
82    (await attempt(LEDGER, async () =>
83      hasPassingFinalVerification(await ledgerDocument(host, ledgerPath), sha256Hex(planBytes)),
84    )) === true;
85
86  const proof = planText === null ? null : await attempt(LEDGER, () => proofOfPlan(host, planText, within));
87  const band: Band = { plan, verification, ...(proof === null ? {} : { proof }), error: errors[0] ?? null, readAt };
88  return { band, hasFinalVerification, exitCode, planPath: bound?.path };
89}
90
91// The plan board's counts: each task's verdict is the newest test, build or lint run since its
92// listed files last changed. Null when no task lists a file that exists.
93async function proofOfPlan(host: Host, planText: string, within: object): Promise<Proof | null> {
94  const { cards } = boardOf(parsePlan(planText));
95  const facts = await proofFacts(host, cards.flatMap((card) => card.files), within);
96  if (facts.ledgerError !== null) throw new Error(facts.ledgerError);
97  const counts = proofSummary(cards.map((card) => verdictFor(facts, card.files)?.proof));
98  return counts.proven + counts.unproven + counts.failed === 0 ? null : counts;
99}
100
101// What a snapshot is read from, besides the plan's listed files. A same-size edit, such as a
102// `[ ]` to `[x]` flip, inside one mtime tick of a coarse file system (2 s on exFAT) keeps the
103// plan's stamp, so a subagent's turn misses it until the next change; the main turn reads anyway.
104async function stampsOf(host: Host): Promise<string> {
105  const [root, sessionId] = await Promise.all([host.session.root(), host.session.id()]);
106  const status = statusPath(root, sessionId);
107  const paths = [`${root}/${BOULDER}`, `${root}/${LEDGER}`, planPath ?? "", status ?? ""];
108  return JSON.stringify(await Promise.all(paths.map((path) => (path === "" ? "none" : stampOf(host, path)))));
109}
110
111async function snapshotOf(host: Host, within: object): Promise<Snapshot> {
112  const stamps = await stampsOf(host);
113  const snapshot = await readSnapshot(host, within);
114  planPath = snapshot.planPath;
115  readStamps = stamps;
116  return snapshot;
117}
118
119// The first read after session.start only records what already happened; later reads toast what changed.
120async function toastChanges(host: Host, { band, exitCode }: Snapshot): Promise<void> {
121  const isBaseline = !isBaselined;
122  isBaselined = true;
123  const { plan, verification } = band;
124  const verifiedAt = verification?.at ?? 0;
125  if (!isBaseline && verification !== null && verifiedAt > toastedVerificationAt && exitCode !== null && exitCode !== 0) {
126    const { ellipsis } = glyphs(glyphTier);
127    host.ui.toast(`Verification failed: ${fitEnd(oneLine(verification.command), COMMAND_CELLS, ellipsis)} (exit ${exitCode})`, {
128      timeoutMs: TOAST_MS,
129    });
130  }
131  toastedVerificationAt = Math.max(toastedVerificationAt, verifiedAt);
132  const isDone = plan !== null && plan.total > 0 && plan.done === plan.total;
133  if (!isBaseline && plan !== null && isDone && toastedPlan?.path === plan.path && !toastedPlan.isDone) {
134    host.ui.toast(`Plan complete: ${plan.name} ${plan.done}/${plan.total}`, { timeoutMs: TOAST_MS });
135  }
136  toastedPlan = plan === null ? undefined : { path: plan.path, isDone };
137}
138
139async function actionsFor(host: Host, { band, hasFinalVerification }: Snapshot): Promise<NextAction[]> {
140  const agents = (await host.state.agents.get()).value ?? {};
141  return nextActions({
142    plan: band.plan,
143    verification: band.verification,
144    isAgentRunning: Object.values(agents).some((agent) => agent.status === "running"),
145    hasFinalVerification,
146  });
147}
148
149const withoutReadAt = (band: Band | undefined): string => JSON.stringify({ ...band, readAt: 0 });
150
151async function writeBand(host: Host, band: Band): Promise<void> {
152  const { value } = await host.state.band.get();
153  if (withoutReadAt(value) !== withoutReadAt(band)) await host.state.band.set(band);
154}
155
156async function writeActions(host: Host, actions: readonly NextAction[]): Promise<void> {
157  const { value = [] } = await host.state.nextActions.get();
158  shownActions = actions.length;
159  if (JSON.stringify(value) !== JSON.stringify(actions)) await host.state.nextActions.set(actions);
160}
161
162const TONES: Record<Tone, TextStyle> = {
163  title: { bold: true },
164  plain: {},
165  muted: { color: TONE_KEYS.muted },
166  ok: { color: TONE_KEYS.ok },
167  warn: { color: TONE_KEYS.warn },
168  fail: { color: TONE_KEYS.fail },
169  active: { color: TONE_KEYS.active },
170};
171
172const row = ({ Text }: Kit, spans: readonly Span[]): RenderElement =>
173  Text({ wrap: "truncate-end", children: spans.map(({ text, tone, ...keys }) => Text({ ...TONES[tone], ...keys, children: [text] })) });
174
175export const band: Features = {
176  "session.start": {
177    post: async (host, e) => {
178      glyphTier = (await sessionOf(host)).glyphTier;
179      shownActions = ((await host.state.nextActions.get()).value ?? []).length;
180      const snapshot = await snapshotOf(host, e);
181      toastedVerificationAt = 0;
182      toastedPlan = undefined;
183      isBaselined = false;
184      await toastChanges(host, snapshot);
185      await writeBand(host, snapshot.band);
186      return undefined;
187    },
188  },
189  "turn.complete": {
190    post: async (host, e) => {
191      if (e.agentId !== undefined && (await stampsOf(host)) === readStamps) return undefined;
192      const snapshot = await snapshotOf(host, e);
193      await toastChanges(host, snapshot);
194      await writeBand(host, snapshot.band);
195      if (e.agentId === undefined) await writeActions(host, await actionsFor(host, snapshot));
196      return undefined;
197    },
198  },
199  "prompt.edit": {
200    post: async (host, _e, result) => {
201      if (shownActions === 0 || result.text === "" || isShownHotkey(result.text)) return undefined;
202      shownActions = 0;
203      await host.state.nextActions.set([]);
204      return undefined;
205    },
206  },
207  "ui.render AbovePrompt": {
208    pre: async (host, e) => {
209      if (!host.options.showBand || e.props.hasSurvey) return undefined;
210      const [{ value: snapshot }, { value: actions = [] }, { value: agents = {} }] = await Promise.all([
211        host.state.band.get(),
212        host.state.nextActions.get(),
213        host.state.agents.get(),
214      ]);
215      const running = Object.values(agents).filter((agent) => agent.status === "running").length;
216      const view = bandView(snapshot, actions, e.props.bodyColumns, glyphTier, running);
217      if (view === undefined) return undefined;
218      const kit = kitOf(host.ui.resolve(e), e.surface);
219      const { Box, Button } = kit;
220      const rows = [Box({ key: "status", children: [row(kit, view.status)] })];
221      if (view.buttons.length > 0) {
222        rows.push(
223          Box({
224            key: "actions",
225            gap: BUTTON_GAP,
226            children: view.buttons.map((button) =>
227              Button({
228                key: button.key,
229                label: button.label,
230                hotkey: button.hotkey,
231                plain: true,
232                onPress: async () => {
233                  try {
234                    await fillPrompt(host, button.prompt);
235                  } catch (error) {
236                    host.log(`band: filling the prompt failed: ${reason(error)}`);
237                  }
238                },
239              }),
240            ),
241          }),
242        );
243      }
244      return { answer: Box({ key: "omca-band", flexDirection: "column", children: rows }) };
245    },
246  },
247};
248
hooks/bash-guard.ts 259 lines
1import type { EventResult } from "claude-code";
2import { type Context, classify, type GitFinding, type Reviewable, reasonFor } from "../src/core/destructive.ts";
3import { isHookDisabled } from "../src/core/kill-switch.ts";
4import { joinPath, toPosix } from "../src/core/path.ts";
5import { homeRest, platformOf } from "../src/core/targets.ts";
6import { cells, displayWidth, fitEnd, fitMiddle, type Glyphs, glyphs } from "../src/core/ui-kit.ts";
7import type { Features } from "./dispatch.ts";
8import { type Host, reason, sessionOf } from "./host.ts";
9
10const RUN = "Run it";
11const REFUSE = "Refuse";
12// The AskUserQuestion dialog's text column on an 80-column terminal, the narrowest supported:
13// its left rule takes 2 of the 80 (measured).
14const WIDTH = 78;
15const COMMAND_ROWS = 4;
16// The dialog's text rows on a 24-row terminal: the engine's frame and the Refuse and Run options take the other 12 (measured).
17const BODY_ROWS = 12;
18const GIT_TIMEOUT_MS = 5000;
19
20const deny = (reason: string): { answer: EventResult<"tool.check"> } => ({ answer: { decision: "deny", reason } });
21
22const commandOf = (input: unknown): string =>
23  typeof input === "object" && input !== null && "command" in input && typeof input.command === "string"
24    ? input.command
25    : "";
26
27const indent = (lines: readonly string[]): string[] => lines.map((line) => `  ${line}`);
28
29async function git(host: Host, argv: readonly string[]): Promise<string[]> {
30  const { exitCode, stdout, stderr } = await host.process.run(["git", ...argv], { timeoutMs: GIT_TIMEOUT_MS });
31  if (exitCode !== 0) throw new Error(stderr.split("\n")[0]?.trim() || `git ${argv[0]} exited ${exitCode}`);
32  return stdout.split("\n").filter((line) => line.trim() !== "");
33}
34
35function resolved(target: string, ctx: Context): string | undefined {
36  const platform = platformOf(ctx);
37  const rest = homeRest(target);
38  if (rest !== undefined) return ctx.home === undefined ? undefined : joinPath(platform, ctx.home, rest);
39  if (/[$`*?[~%]/.test(target)) return undefined;
40  return toPosix(platform, target);
41}
42
43async function kindOf(host: Host, target: string, ctx: Context): Promise<string> {
44  const path = resolved(target, ctx);
45  if (path === undefined) return "not expanded";
46  if (!(await host.fs.exists(path))) return "not found";
47  const stat = await host.fs.stat(path);
48  if (stat.isLink) return "link";
49  if (stat.kind === "dir") {
50    const count = (await host.fs.list(path)).length;
51    return `dir, ${count} ${count === 1 ? "entry" : "entries"}`;
52  }
53  return stat.kind;
54}
55
56// Breaks at the last space that fits, or mid-word when a row has none, so every character up to the row cap is kept.
57function commandRows(command: string, g: Glyphs): string[] {
58  const rows: string[] = [];
59  let rest = [...command.trim()];
60  while (rest.length > 0 && rows.length < COMMAND_ROWS) {
61    let end = 0;
62    let used = 0;
63    let space = 0;
64    for (const char of rest) {
65      used += cells(char.codePointAt(0) ?? 0);
66      if (char === "\n" || used > WIDTH - 2) break;
67      if (char === " ") space = end;
68      end += 1;
69    }
70    const cut = end < rest.length && rest[end] !== "\n" && space > 0 ? space : end;
71    const row = rest.slice(0, cut).join("").trimEnd();
72    if (row !== "") rows.push(row);
73    rest = rest.slice(cut);
74    if (rest[0] === "\n" || rest[0] === " ") rest = rest.slice(1);
75  }
76  const left = rest.length;
77  return left === 0 ? rows : [...rows, `${g.ellipsis} ${left} more ${left === 1 ? "character" : "characters"}`];
78}
79
80type Section = {
81  head: string[];
82  total: number;
83  items: (count: number) => string[];
84  foot: string[];
85  noun: string;
86};
87
88const note = (...head: string[]): Section => ({ head, total: 0, items: () => [], foot: [], noun: "" });
89
90async function removalSection(host: Host, targets: readonly string[], g: Glyphs, ctx: Context): Promise<Section> {
91  if (targets.length === 0) return note("It names no target.");
92  const pool = targets.slice(0, BODY_ROWS);
93  const kinds = await Promise.all(pool.map((target) => kindOf(host, target, ctx)));
94  const items = (count: number): string[] => {
95    const shown = pool.slice(0, count);
96    const kindWidth = Math.max(...kinds.slice(0, count).map(displayWidth));
97    const nameWidth = Math.min(Math.max(...shown.map(displayWidth)), WIDTH - 4 - kindWidth);
98    return shown.map((target, i) => {
99      const name = fitMiddle(target, nameWidth, g.ellipsis);
100      return `${name}${" ".repeat(nameWidth - displayWidth(name))}  ${kinds[i]}`;
101    });
102  };
103  return { head: ["It would remove:"], total: targets.length, items, foot: [], noun: "" };
104}
105
106const TREE_EFFECT: Record<Exclude<GitFinding["operation"], "reset --hard" | "push --force">, string> = {
107  stash: "git stash moves uncommitted changes out of the working tree.",
108  clean: "git clean deletes untracked files.",
109  restore: "git restore discards changes to the files it names.",
110  "rm -r": "git rm -r deletes tracked files from the working tree.",
111  "checkout --": "git checkout -- discards changes to the paths it names.",
112  "commit --no-verify": "git commit --no-verify skips the repository's pre-commit and commit-msg hooks.",
113};
114
115async function resetSection(host: Host): Promise<Section> {
116  const changed = (await git(host, ["status", "--porcelain"])).filter((line) => !line.startsWith("??"));
117  if (changed.length === 0) return note("git reset --hard: no uncommitted changes to tracked files.");
118  const stat = await git(host, ["diff", `--stat=${WIDTH - 2}`, "HEAD"]);
119  return {
120    head: [`git reset --hard discards ${changed.length} uncommitted ${changed.length === 1 ? "change" : "changes"}:`],
121    total: Math.max(0, stat.length - 1),
122    items: (count) => stat.slice(0, count),
123    foot: stat.slice(-1),
124    noun: "files",
125  };
126}
127
128async function pushSection(host: Host, push: GitFinding): Promise<Section> {
129  const ref =
130    push.remote === undefined
131      ? "@{push}"
132      : `${push.remote}/${push.branch ?? (await git(host, ["rev-parse", "--abbrev-ref", "HEAD"]))[0]}`;
133  const lost = await git(host, ["log", "--oneline", ref, "--not", "HEAD"]);
134  if (lost.length === 0) return note(`git push --force: ${ref} has no commits missing from HEAD.`);
135  const noun = lost.length === 1 ? "commit" : "commits";
136  return {
137    head: [`git push --force drops ${lost.length} ${noun} from ${ref}:`],
138    total: lost.length,
139    items: (count) => lost.slice(0, count),
140    foot: [],
141    noun: "commits",
142  };
143}
144
145const gitSection = (host: Host, finding: GitFinding): Promise<Section> | Section => {
146  switch (finding.operation) {
147    case "reset --hard":
148      return resetSection(host);
149    case "push --force":
150      return pushSection(host, finding);
151    default:
152      return note(TREE_EFFECT[finding.operation]);
153  }
154};
155
156async function gathered(gather: () => Promise<Section> | Section, g: Glyphs): Promise<Section> {
157  try {
158    return await gather();
159  } catch (error) {
160    return note(`${g.warn} Could not check what it would touch: ${reason(error)}`);
161  }
162}
163
164// A list that does not fit keeps its first entries and ends in `and N more`; each cut list keeps at least one entry.
165function shareRows(sections: readonly Section[], rows: number): number[] {
166  const wanted = sections.reduce((sum, section) => sum + section.total, 0);
167  return sections.map((section) => {
168    const share = Math.floor((rows * section.total) / Math.max(1, wanted));
169    return wanted <= rows || section.total <= share ? section.total : Math.max(2, share);
170  });
171}
172
173function drawn(section: Section, rows: number): string[] {
174  const shown = section.total <= rows ? section.total : rows - 1;
175  const more = section.total > shown ? [["and", section.total - shown, "more", section.noun].filter(Boolean).join(" ")] : [];
176  return [...section.head, ...indent([...section.items(shown), ...more, ...section.foot])];
177}
178
179async function question(host: Host, command: string, finding: Reviewable, g: Glyphs, ctx: Context): Promise<string> {
180  const shownCommand = commandRows(command, g);
181  const targets = finding.removals.flatMap((removal) => removal.targets);
182  const sections = await Promise.all([
183    ...(finding.removals.length > 0 ? [gathered(() => removalSection(host, targets, g, ctx), g)] : []),
184    ...finding.git.map((operation) => gathered(() => gitSection(host, operation), g)),
185  ]);
186  const fixed = sections.reduce((sum, section) => sum + section.head.length + section.foot.length, 0);
187  const rows = shareRows(sections, BODY_ROWS - 2 - shownCommand.length - fixed);
188  const lines = [
189    "OMCA held this command for your review:",
190    ...indent(shownCommand),
191    ...sections.flatMap((section, i) => drawn(section, rows[i] ?? 0)),
192    "Run it?",
193  ];
194  return lines.map((line) => fitEnd(line, WIDTH, g.ellipsis)).join("\n");
195}
196
197const shellOf = (tool: string): Context["shell"] => (tool === "PowerShell" ? "powershell" : "bash");
198
199const symbolicRef = (host: Host, name: string): Promise<string | undefined> =>
200  git(host, ["symbolic-ref", "--quiet", "--short", name]).then(
201    ([line]) => line,
202    () => undefined,
203  );
204
205// Read from local refs only, so deciding never waits on the network.
206async function branchesOf(host: Host): Promise<Pick<Context, "branch" | "defaultBranch">> {
207  const [branch, originHead] = await Promise.all([symbolicRef(host, "HEAD"), symbolicRef(host, "refs/remotes/origin/HEAD")]);
208  return {
209    ...(branch !== undefined && { branch }),
210    ...(originHead !== undefined && { defaultBranch: originHead.replace(/^origin\//, "") }),
211  };
212}
213
214async function contextOf(host: Host, shell: Context["shell"], isPush: boolean): Promise<Context> {
215  const [home, cwd, root, branches] = await Promise.all([
216    sessionOf(host).then(
217      (session) => session.home,
218      () => "",
219    ),
220    host.session.cwd().catch(() => undefined),
221    host.session.root().catch(() => undefined),
222    isPush ? branchesOf(host) : {},
223  ]);
224  return {
225    shell,
226    ...(home !== "" && { home }),
227    ...(cwd !== undefined && { cwd }),
228    ...(root !== undefined && { root }),
229    ...branches,
230  };
231}
232
233export const bashGuard: Features = {
234  "tool.check": {
235    pre: async (host, e) => {
236      const command = commandOf(e.input);
237      const shell = shellOf(e.tool);
238      const first = classify(command, { shell });
239      if (first === undefined) return undefined;
240      if (first.kind === "catastrophic") return deny(reasonFor(first));
241      // A disabled guard still classifies with the context, because the catastrophic deny has no switch.
242      const isDisabled = isHookDisabled(await host.env.OMCA_DISABLED_HOOKS(), "bash-guard");
243      const isPush = !isDisabled && first.git.some((finding) => finding.operation === "push --force");
244      const ctx = await contextOf(host, shell, isPush);
245      const finding = classify(command, ctx);
246      if (finding === undefined) return undefined;
247      if (finding.kind === "catastrophic") return deny(reasonFor(finding));
248      if (isDisabled) return undefined;
249      const refusal = reasonFor(finding);
250      const canAsk = host.options.guardMode === "dialog" && (await host.session.surfaces()).length > 0;
251      if (!canAsk) return finding.kind === "blocking" ? deny(refusal) : undefined;
252      const g = glyphs((await sessionOf(host)).glyphTier);
253      const text = await question(host, command, finding, g, ctx);
254      const answer = await host.ui.ask(text, { header: "OMCA guard", options: [REFUSE, RUN] }).catch(() => undefined);
255      return answer === RUN ? undefined : deny(refusal);
256    },
257  },
258};
259
hooks/compact.ts 32 lines
1import { planTasks } from "../src/core/checkboxes.ts";
2import { isHookDisabled } from "../src/core/kill-switch.ts";
3import type { Features } from "./dispatch.ts";
4import { boundPlanOf, type Host } from "./host.ts";
5
6// The summarizer needs the next steps, not the whole plan; the file keeps the rest.
7const MAX_OPEN_TASKS = 10;
8
9async function planInstructions(host: Host): Promise<string | undefined> {
10  const plan = await boundPlanOf(host);
11  if (plan === undefined || !(await host.fs.exists(plan.path))) return undefined;
12  const open = planTasks(await host.fs.read(plan.path)).flatMap((task) => (task.checked ? [] : [`- ${task.number}. ${task.label}`]));
13  const more = open.length - MAX_OPEN_TASKS;
14  return [
15    `Keep OMCA's active plan in the summary: ${plan.name} (${plan.path}).`,
16    open.length === 0 ? "Every numbered task is checked." : "Open tasks:",
17    ...open.slice(0, MAX_OPEN_TASKS),
18    ...(more > 0 ? [`- and ${more} more in the plan file`] : []),
19  ].join("\n");
20}
21
22export const compact: Features = {
23  "session.compact": {
24    pre: async (host, e) => {
25      if (e.agentId !== undefined || isHookDisabled(await host.env.OMCA_DISABLED_HOOKS(), "compact")) return undefined;
26      const plan = await planInstructions(host);
27      if (plan === undefined) return undefined;
28      return { event: { ...e, instructions: e.instructions ? `${e.instructions}\n\n${plan}` : plan } };
29    },
30  },
31};
32
hooks/completion.ts 21 lines
1import { matching, slotAt, SUBCOMMAND_WORDS, type Word } from "../src/core/completion.ts";
2import type { Features } from "./dispatch.ts";
3import type { Host } from "./host.ts";
4import { planNames } from "./tabs/plan.ts";
5
6async function wordsFor(host: Host, slot: NonNullable<ReturnType<typeof slotAt>>): Promise<readonly Word[]> {
7  if (slot === "subcommand") return SUBCOMMAND_WORDS;
8  return (await planNames(host)).map((name) => ({ text: name }));
9}
10
11export const completion: Features = {
12  "prompt.autocomplete": {
13    async post(host, e, result) {
14      const slot = slotAt(e.text, e.start);
15      if (slot === undefined) return undefined;
16      const mine = matching(await wordsFor(host, slot), e.token);
17      return mine.length === 0 ? undefined : { suggestions: [...result.suggestions, ...mine] };
18    },
19  },
20};
21
hooks/dispatch.ts 112 lines
1import type { Args, EventResult, Frozen, RenderElement, RenderInput } from "claude-code";
2import { type Host, reason } from "./host.ts";
3
4type Sites = {
5  "tool.check": [Args<"tool.check">, EventResult<"tool.check">];
6  "tool.call": [Args<"tool.call">, EventResult<"tool.call">];
7  "session.start": [Args<"session.start">, EventResult<"session.start">];
8  "turn.start": [Args<"turn.start">, EventResult<"turn.start">];
9  "turn.step": [Args<"turn.step">, EventResult<"turn.step">];
10  "turn.complete": [Args<"turn.complete">, EventResult<"turn.complete">];
11  "agent.spawn": [Args<"agent.spawn">, EventResult<"agent.spawn">];
12  "prompt.edit": [Args<"prompt.edit">, EventResult<"prompt.edit">];
13  "prompt.autocomplete": [Args<"prompt.autocomplete">, EventResult<"prompt.autocomplete">];
14  "ui.close": [Args<"ui.close">, EventResult<"ui.close">];
15  "ui.focus": [Args<"ui.focus">, EventResult<"ui.focus">];
16  "ui.scroll": [Args<"ui.scroll">, EventResult<"ui.scroll">];
17  "command.run": [Args<"command.run">, EventResult<"command.run">];
18  "ui.render AbovePrompt": [RenderInput<"AbovePrompt">, RenderElement];
19  "ui.render Pane": [RenderInput<"Pane">, RenderElement];
20  "ui.render Spinner": [RenderInput<"Spinner">, RenderElement];
21  "session.compact": [Args<"session.compact">, EventResult<"session.compact">];
22};
23
24export type Site = keyof Sites;
25
26type Awaitable<T> = T | Promise<T>;
27
28export type Phase<E, A> = { event: E } | { answer: A };
29
30export type Feature<E, R, A = R, H = Host> = {
31  pre?: (host: H, e: E) => Awaitable<Phase<E, A> | undefined>;
32  post?: (host: H, e: E, result: R) => Awaitable<A | undefined>;
33};
34
35type Logs = { log: (text: string) => void };
36
37export type Input<S extends Site> = Frozen<Sites[S][0]>;
38
39export type Features = {
40  [S in Site]?: Feature<Input<S>, Sites[S][1], S extends "turn.step" ? never : Sites[S][1]>;
41};
42
43export type Entries<S extends Site> = readonly (readonly [string, NonNullable<Features[S]>])[];
44
45export function featuresFor<S extends Site>(site: S, modules: Readonly<Record<string, Features>>): Entries<S> {
46  return Object.entries(modules).flatMap(([name, module]) => {
47    const feature = module[site];
48    return feature === undefined ? [] : [[name, feature] as const];
49  });
50}
51
52async function attempt<T>(host: Logs, where: string, call: () => Awaitable<T>): Promise<{ value: T } | { reason: string }> {
53  try {
54    return { value: await call() };
55  } catch (error) {
56    const failure = reason(error);
57    host.log(`${where} failed: ${failure}`);
58    return { reason: failure };
59  }
60}
61
62export async function dispatch<H extends Logs, E, R>(
63  host: H,
64  where: string,
65  features: readonly (readonly [string, Feature<E, R, R, H>])[],
66  e: E,
67  next: (e: E) => Promise<R>,
68  failed?: (reason: string) => R,
69): Promise<R> {
70  const answers: R[] = [];
71  let event = e;
72  for (const [name, { pre }] of features) {
73    if (pre === undefined) continue;
74    const run = await attempt(host, `${name} pre ${where}`, () => pre(host, event));
75    if (!("value" in run)) {
76      if (failed !== undefined) answers.push(failed(run.reason));
77    } else if (run.value !== undefined && "answer" in run.value) answers.push(run.value.answer);
78    else if (run.value !== undefined) event = run.value.event;
79  }
80  const early = answers[0];
81  if (early !== undefined) return early;
82  const result = await next(event);
83  for (const [name, { post }] of features) {
84    if (post === undefined) continue;
85    const run = await attempt(host, `${name} post ${where}`, () => post(host, event, result));
86    if (!("value" in run)) {
87      if (failed !== undefined) answers.push(failed(run.reason));
88    } else if (run.value !== undefined) answers.push(run.value);
89  }
90  return answers[0] ?? result;
91}
92
93export async function* dispatchStream<H extends Logs, E, C, R>(
94  host: H,
95  where: string,
96  features: readonly (readonly [string, Feature<E, R, never, H>])[],
97  e: E,
98  next: (e: E) => AsyncGenerator<C, R>,
99): AsyncGenerator<C, R> {
100  let event = e;
101  for (const [name, { pre }] of features) {
102    if (pre === undefined) continue;
103    const run = await attempt(host, `${name} pre ${where}`, () => pre(host, event));
104    if ("value" in run && run.value !== undefined && "event" in run.value) event = run.value.event;
105  }
106  const result = yield* next(event);
107  for (const [name, { post }] of features) {
108    if (post !== undefined) await attempt(host, `${name} post ${where}`, () => post(host, event, result));
109  }
110  return result;
111}
112
hooks/footer.ts 64 lines
1import { ledgerCoversSlot } from "../src/core/evidence.ts";
2import { footerLine } from "../src/core/footer.ts";
3import { statusPath, verificationOf } from "../src/core/omca-paths.ts";
4import { glyphs } from "../src/core/ui-kit.ts";
5import type { Features } from "./dispatch.ts";
6import { type Host, ledgerWrittenAt, reason, sessionOf } from "./host.ts";
7
8async function unloggedSince(host: Host, startMs: number): Promise<string | null> {
9  const [root, sessionId] = await Promise.all([host.session.root(), host.session.id()]);
10  const statusFile = statusPath(root, sessionId);
11  if (statusFile === undefined || !(await host.fs.exists(statusFile))) return null;
12  const slot = verificationOf(JSON.parse(await host.fs.read(statusFile)));
13  if (slot === null || slot.at < Math.floor(startMs / 1000)) return null;
14  return ledgerCoversSlot(await ledgerWrittenAt(host, root), slot.at) ? null : slot.command;
15}
16
17export const footer: Features = {
18  "turn.start": {
19    async pre(host, e) {
20      const [usage, at] = await Promise.all([host.session.usage(), host.clock.now()]);
21      await host.state.costSample.set({ turnId: e.turnId, usd: usage.cost?.usd ?? null, at });
22      return undefined;
23    },
24  },
25  "turn.complete": {
26    async post(host, e, result) {
27      if (e.agentId !== undefined) return undefined;
28      const [{ value: sample }, usage, now, { glyphTier }] = await Promise.all([
29        host.state.costSample.get(),
30        host.session.usage(),
31        host.clock.now(),
32        sessionOf(host),
33      ]);
34      const isSampled = sample?.turnId === e.turnId;
35      const before = isSampled ? sample.usd : null;
36      const after = usage.cost?.usd;
37      // A five-hour or seven-day window means a subscription, where the engine's dollar figure is not what the account pays.
38      const isSubscription = usage.rateLimits.some(({ kind }) => kind === "five_hour" || kind === "seven_day");
39      let unlogged: string | null = null;
40      try {
41        unlogged = await unloggedSince(host, isSampled ? sample.at : now - e.durationMs);
42      } catch (error) {
43        host.log(`footer: cannot check this turn's verification: ${reason(error)}`);
44      }
45      const text = footerLine(
46        {
47          durationMs: e.durationMs,
48          tokens:
49            e.usage === undefined
50              ? null
51              : {
52                  input: e.usage.input_tokens + e.usage.cache_read_input_tokens + e.usage.cache_creation_input_tokens,
53                  output: e.usage.output_tokens,
54                },
55          costUsd: isSubscription || before === null || after === undefined ? null : after - before,
56          unlogged,
57        },
58        glyphs(glyphTier),
59      );
60      return { ...result, text };
61    },
62  },
63};
64
hooks/host.ts 261 lines
1import type { EngineInterface, FsBytes, PluginState, SessionMessage, StateRead, StateSetOptions, StateSetResult } from "claude-code";
2import { parseRegistry, resolveBoundPlan } from "../src/core/boulder.ts";
3import { BOULDER, LEDGER } from "../src/core/omca-paths.ts";
4import { configDir, type Env as PathEnv, homeDir, inferPlatform, isAbsolutePath, joinPath, type Platform } from "../src/core/path.ts";
5import { parseRuns, provingRuns, type Run, type Verdict, verdictFrom } from "../src/core/proof.ts";
6import { type GlyphTier, glyphTier } from "../src/core/ui-kit.ts";
7
8export type State = PluginState["oh-my-claudeagent"];
9
10export type AtomHost<K extends keyof State> = {
11  get: () => Promise<StateRead<State[K]>>;
12  set: (value: State[K], options?: StateSetOptions) => Promise<StateSetResult>;
13};
14
15export type Options = {
16  showBand: boolean;
17  guardMode: "dialog" | "deny";
18  enableKeywordTriggers: boolean;
19};
20
21type Env = () => Promise<string | undefined>;
22type Engine = EngineInterface;
23
24export type Host = {
25  options: Options;
26  plugin: { name: string; root: string };
27  log: (text: string) => void;
28  env: {
29    HOME: Env;
30    USERPROFILE: Env;
31    HOMEDRIVE: Env;
32    HOMEPATH: Env;
33    CLAUDE_CONFIG_DIR: Env;
34    OMCA_GLYPHS: Env;
35    OMCA_DISABLED_HOOKS: Env;
36    CLAUDE_CODE_SUBAGENT_MODEL_FORCE: Env;
37    CLAUDE_CODE_DISABLE_ADVISOR_TOOL: Env;
38    DISABLE_TELEMETRY: Env;
39    DO_NOT_TRACK: Env;
40    DISABLE_GROWTHBOOK: Env;
41    CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: Env;
42  };
43  fs: {
44    read: (path: string) => Promise<string>;
45    readBytes: (path: string) => Promise<FsBytes>;
46    write: Engine["fs"]["write"];
47    stat: Engine["fs"]["stat"];
48    list: Engine["fs"]["list"];
49    exists: Engine["fs"]["exists"];
50  };
51  process: { run: Engine["process"]["run"] };
52  mcp: { connect: Engine["mcp"]["connect"] };
53  ui: {
54    say: (text: string) => void;
55    open: Engine["ui"]["open"];
56    panes: Engine["ui"]["panes"];
57    ask: Engine["ui"]["ask"];
58    invalidate: () => void;
59    focus: Engine["ui"]["focus"];
60    resolve: Engine["ui"]["resolve"];
61    copy: Engine["ui"]["copy"];
62    toast: Engine["ui"]["toast"];
63    blit: Engine["ui"]["blit"];
64  };
65  session: {
66    id: Engine["session"]["id"];
67    root: Engine["session"]["root"];
68    cwd: Engine["session"]["cwd"];
69    surfaces: Engine["session"]["surfaces"];
70    usage: Engine["session"]["usage"];
71    version: Engine["session"]["version"];
72    transcript: (args: { agentId: string }) => Promise<SessionMessage[] | { deny: string }>;
73  };
74  settings: { read: Engine["settings"]["read"] };
75  agent: { list: Engine["agent"]["list"] };
76  command: { register: Engine["command"]["register"] };
77  clock: {
78    now: Engine["clock"]["now"];
79    after: Engine["clock"]["after"];
80    every: Engine["clock"]["every"];
81  };
82  prompt: { fill: Engine["prompt"]["fill"] };
83  state: { [K in keyof State]: AtomHost<K> };
84};
85
86// A write that loses its version means another landed, so a burst of n writers settles within n - 1
87// retries. Up to 20 subagents spawn at once, each also stepping; the bound only stops a runaway engine.
88const WRITE_ATTEMPTS = 64;
89
90// Several hooks write one atom between awaits, so each write is read, changed and set at the
91// version it read. A change that returns the value unchanged writes nothing and redraws nothing.
92export async function update<K extends keyof State>(
93  atom: AtomHost<K>,
94  change: (value: State[K] | undefined) => State[K] | undefined,
95): Promise<void> {
96  for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt += 1) {
97    const { value, version } = await atom.get();
98    const next = change(value);
99    if (next === undefined || next === value) return;
100    if ((await atom.set(next, { ifVersion: version })).isSet) return;
101  }
102  throw new Error(`the state changed under ${WRITE_ATTEMPTS} writes in a row`);
103}
104
105// The engine prefixes a refused call's message with the plugin and the call, `<plugin>: $.fs.read: `.
106export const reason = (error: unknown): string =>
107  (error instanceof Error ? error.message : String(error)).replace(/^[\w-]+: \$\.[\w.]+: /, "");
108
109export async function pluginVersion(host: Host): Promise<string | null> {
110  try {
111    const manifest: unknown = JSON.parse(await host.fs.read(`${host.plugin.root}/.claude-plugin/plugin.json`));
112    const version = typeof manifest === "object" && manifest !== null && "version" in manifest ? manifest.version : null;
113    return typeof version === "string" ? version : null;
114  } catch (error) {
115    host.log(`cannot read the plugin version: ${reason(error)}`);
116    return null;
117  }
118}
119
120export type Session = { env: PathEnv; platform: Platform; home: string; config: string | undefined; glyphTier: GlyphTier };
121
122const UNRESOLVED: Session = { env: {}, platform: "linux", home: "", config: undefined, glyphTier: "nerd" };
123let session: Session | undefined;
124
125async function resolveSession(host: Host): Promise<Session> {
126  const [root, HOME, USERPROFILE, HOMEDRIVE, HOMEPATH, CLAUDE_CONFIG_DIR, glyphs] = await Promise.all([
127    host.session.root(),
128    host.env.HOME(),
129    host.env.USERPROFILE(),
130    host.env.HOMEDRIVE(),
131    host.env.HOMEPATH(),
132    host.env.CLAUDE_CONFIG_DIR(),
133    host.env.OMCA_GLYPHS(),
134  ]);
135  const env = { HOME, USERPROFILE, HOMEDRIVE, HOMEPATH, CLAUDE_CONFIG_DIR };
136  const home = homeDir(env) ?? "";
137  const config = configDir(env);
138  return { env, platform: inferPlatform(root, home, config ?? ""), home, config, glyphTier: glyphTier(glyphs) };
139}
140
141// The environment holds for the life of the session, so it is read once, outside any drawing.
142export async function sessionOf(host: Host): Promise<Session> {
143  session ??= await resolveSession(host);
144  return session;
145}
146
147/** What `sessionOf` resolved, for a drawing that must not wait on the engine. */
148export const resolvedSession = (): Session => session ?? UNRESOLVED;
149
150/** The file's raw bytes, so a digest covers exactly what the server hashes. */
151export async function bytesOf(host: Host, path: string): Promise<Uint8Array> {
152  const { base64 } = await host.fs.readBytes(path);
153  return Uint8Array.fromBase64(base64);
154}
155
156/**
157 * Puts `text` in the prompt box. Where the box does not take it, as on a surface that draws its
158 * own composer, a toast carries the text so the person can type it.
159 */
160export async function fillPrompt(host: Host, text: string): Promise<void> {
161  const { isFilled, refusal } = await host.prompt.fill({ text });
162  if (isFilled) return;
163  host.ui.toast(refusal === "dialog" ? `Close the open dialog, then type: ${text}` : `The prompt box cannot be filled here. Type: ${text}`);
164}
165
166/** The plan this session is bound to, undefined when none is; throws when the registry is not JSON. */
167export async function boundPlanOf(host: Host): Promise<{ name: string; path: string } | undefined> {
168  const [root, sessionId] = await Promise.all([host.session.root(), host.session.id()]);
169  const registry = `${root}/${BOULDER}`;
170  if (!(await host.fs.exists(registry))) return undefined;
171  const parsed = parseRegistry(await host.fs.read(registry));
172  if (parsed.kind === "refused" && parsed.code === "unparseable") throw new Error(parsed.reason);
173  const plan = parsed.kind === "ok" ? resolveBoundPlan(parsed.registry, sessionId, true) : undefined;
174  return plan === undefined || plan.active_plan === "" ? undefined : { name: plan.plan_name, path: plan.active_plan };
175}
176
177/** The evidence ledger's modification time in whole seconds, 0 when there is none. */
178export async function ledgerWrittenAt(host: Host, root: string): Promise<number> {
179  const path = `${root}/${LEDGER}`;
180  return (await host.fs.exists(path)) ? Math.floor((await host.fs.stat(path)).mtimeMs / 1000) : 0;
181}
182
183/** `mtimeMs:size`, or `absent` when the file cannot be stated. */
184export const stampOf = (host: Host, path: string): Promise<string> =>
185  host.fs.stat(path).then(
186    ({ mtimeMs, size }) => `${mtimeMs}:${size}`,
187    () => "absent",
188  );
189
190type Ledger = { path: string; seen: string; proving: readonly Run[]; error: string | null };
191
192// The ledger is rewritten whole and only ever appended to, so every write moves its size and a
193// read at an unchanged stamp would parse the same text.
194let ledger: Ledger | undefined;
195
196/** The ledger's test, build and lint runs newest first, read again only when its stamp moves. */
197async function ledgerOf(host: Host, root: string): Promise<Ledger> {
198  const path = `${root}/${LEDGER}`;
199  const seen = await stampOf(host, path);
200  if (ledger?.path === path && ledger.seen === seen) return ledger;
201  let runs: Run[] = [];
202  let error: string | null = null;
203  if (seen !== "absent") {
204    try {
205      runs = parseRuns(await host.fs.read(path));
206    } catch (failure) {
207      error = reason(failure);
208    }
209  }
210  ledger = { path, seen, proving: provingRuns(runs), error };
211  return ledger;
212}
213
214/**
215 * What proves a plan's tasks: each listed file's modification time, null for one that cannot be
216 * stated, and the ledger's test, build and lint runs newest first, or why the ledger could not be
217 * read. `ledgerSeen` changes when the ledger does.
218 */
219export type ProofFacts = { changes: ReadonlyMap<string, number | null>; proving: readonly Run[]; ledgerError: string | null; ledgerSeen: string };
220
221// Keyed on the event a dispatch hands every feature, so features of one dispatch share the files'
222// stats and the entry goes with the event.
223const statted = new WeakMap<object, { files: string; changes: ReadonlyMap<string, number | null> }>();
224
225async function changesOf(host: Host, root: string, paths: readonly string[]): Promise<ReadonlyMap<string, number | null>> {
226  const { platform } = await sessionOf(host);
227  const stamps = await Promise.all(
228    paths.map((path) =>
229      host.fs.stat(isAbsolutePath(platform, path) ? path : joinPath(platform, root, path)).then(
230        (stat) => (stat.kind === "file" ? stat.mtimeMs : null),
231        () => null,
232      ),
233    ),
234  );
235  return new Map(paths.map((path, index) => [path, stamps[index] ?? null]));
236}
237
238/** `within` is the dispatch's event, so a second call in the same dispatch stats no file again. */
239export async function proofFacts(host: Host, files: readonly string[], within?: object): Promise<ProofFacts> {
240  const root = await host.session.root();
241  const paths = [...new Set(files)];
242  const key = JSON.stringify(paths);
243  const shared = within === undefined ? undefined : statted.get(within);
244  const changes = shared?.files === key ? shared.changes : await changesOf(host, root, paths);
245  if (within !== undefined) statted.set(within, { files: key, changes });
246  const { seen, proving, error } = await ledgerOf(host, root);
247  return { changes, proving, ledgerError: error, ledgerSeen: seen };
248}
249
250/** A task's proof from the facts: undefined when the ledger is unreadable or none of its files can be stated. */
251export function verdictFor(facts: ProofFacts, files: readonly string[]): Verdict | undefined {
252  if (facts.ledgerError !== null) return undefined;
253  return verdictFrom(
254    files.flatMap((file) => {
255      const at = facts.changes.get(file);
256      return at === undefined || at === null ? [] : [at];
257    }),
258    facts.proving,
259  );
260}
261
hooks/metrics.ts 89 lines
1import { isEvidenceLogged, type MetricsRecord, outcomeOf, parseRecord, recordPath } from "../src/core/metrics.ts";
2import { LEDGER } from "../src/core/omca-paths.ts";
3import { estimateCostUsd } from "../src/core/pricing.ts";
4import type { Features } from "./dispatch.ts";
5import { type Host, reason, type State } from "./host.ts";
6
7type Row = State["agents"][string];
8
9const NO_USAGE = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
10
11async function locate(host: Host, agentId: string): Promise<{ root: string; sessionId: string; path: string } | undefined> {
12  const [root, sessionId] = await Promise.all([host.session.root(), host.session.id()]);
13  const path = recordPath(root, sessionId, agentId);
14  if (path === undefined) host.log(`metrics: no record for session ${JSON.stringify(sessionId)} agent ${JSON.stringify(agentId)}`);
15  return path === undefined ? undefined : { root, sessionId, path };
16}
17
18const write = (host: Host, path: string, record: MetricsRecord) => host.fs.write(path, `${JSON.stringify(record, null, 2)}\n`);
19
20const running = (sessionId: string, agentId: string, row: Row): MetricsRecord => ({
21  session_id: sessionId,
22  agent_id: agentId,
23  agent_type: row.type,
24  model: row.model,
25  effort: row.effort,
26  started_at: new Date(row.startedAt).toISOString(),
27  ended_at: null,
28  duration_ms: null,
29  input_tokens: 0,
30  output_tokens: 0,
31  estimated_cost_usd: null,
32  outcome: "running",
33  evidence_logged: null,
34});
35
36// null when the ledger exists but cannot be read: unknown, not "no evidence".
37async function evidenceLogged(host: Host, root: string, startMs: number, endMs: number): Promise<boolean | null> {
38  const path = `${root}/${LEDGER}`;
39  try {
40    return (await host.fs.exists(path)) && isEvidenceLogged(await host.fs.read(path), startMs, endMs);
41  } catch (error) {
42    host.log(`metrics: could not read ${LEDGER}: ${reason(error)}`);
43    return null;
44  }
45}
46
47async function agentRow(host: Host, agentId: string): Promise<Row | undefined> {
48  const row = (await host.state.agents.get()).value?.[agentId];
49  if (row === undefined) host.log(`metrics: agent ${agentId} is not tracked`);
50  return row;
51}
52
53export const metrics: Features = {
54  "agent.spawn": {
55    async post(host, _e, result) {
56      if (result.deny !== undefined || result.agentId === undefined) return undefined;
57      const [at, row] = await Promise.all([locate(host, result.agentId), agentRow(host, result.agentId)]);
58      if (at !== undefined && row !== undefined && !row.teammate) await write(host, at.path, running(at.sessionId, result.agentId, row));
59      return undefined;
60    },
61  },
62  "turn.complete": {
63    async post(host, e) {
64      if (e.agentId === undefined) return undefined;
65      const at = await locate(host, e.agentId);
66      if (at === undefined || !(await host.fs.exists(at.path))) return undefined;
67      if (parseRecord(await host.fs.read(at.path))?.outcome !== "running") return undefined;
68      const row = await agentRow(host, e.agentId);
69      if (row === undefined || row.teammate) return undefined;
70      const endedAt = row.endedAt ?? (await host.clock.now());
71      // A turn without usage counted nothing, by the engine's contract; tokens its steps did
72      // count without one cannot be split into priced kinds, so they stay unpriced.
73      const usage = e.usage ?? (row.inputTokens + row.outputTokens === 0 ? { ...NO_USAGE, model: row.model } : undefined);
74      await write(host, at.path, {
75        ...running(at.sessionId, e.agentId, row),
76        ended_at: new Date(endedAt).toISOString(),
77        duration_ms: endedAt - row.startedAt,
78        input_tokens: row.inputTokens,
79        output_tokens: row.outputTokens,
80        // The lane prices each response on its own, which a model priced by prompt length needs.
81        estimated_cost_usd: usage === undefined ? null : e.usage === undefined ? estimateCostUsd(usage.model, usage) : row.costUsd,
82        outcome: outcomeOf(e),
83        evidence_logged: await evidenceLogged(host, at.root, row.startedAt, endedAt),
84      });
85      return undefined;
86    },
87  },
88};
89
hooks/mod-marker.ts 26 lines
1import { isSafeId } from "../src/core/session-id.ts";
2import type { Features } from "./dispatch.ts";
3import { type Host, pluginVersion } from "./host.ts";
4
5// An unreadable manifest costs the marker its version, not the marker itself.
6async function writeMarker(host: Host): Promise<undefined> {
7  const [root, sessionId, now, version] = await Promise.all([
8    host.session.root(),
9    host.session.id(),
10    host.clock.now(),
11    pluginVersion(host),
12  ]);
13  if (!isSafeId(sessionId)) {
14    host.log(`mod-marker: the session id ${JSON.stringify(sessionId)} cannot name a file`);
15    return undefined;
16  }
17  const marker = { written_at: now, version, options: host.options };
18  await host.fs.write(`${root}/.omca/state/mod/${sessionId}.json`, `${JSON.stringify(marker)}\n`);
19  return undefined;
20}
21
22export const modMarker: Features = {
23  "session.start": { post: writeMarker },
24  "turn.start": { pre: writeMarker },
25};
26
hooks/omca-router.ts 40 lines
1import type { CommandRunResult } from "claude-code";
2import type { Features, Input } from "./dispatch.ts";
3import type { Host } from "./host.ts";
4import * as pane from "./pane.ts";
5import * as doctor from "./tabs/doctor.ts";
6import * as plan from "./tabs/plan.ts";
7import * as stats from "./tabs/stats.ts";
8
9export type Subcommand = (
10  host: Host,
11  e: Input<"command.run">,
12  args: string,
13) => CommandRunResult | undefined | Promise<CommandRunResult | undefined>;
14
15export const USAGE = "Usage: /omca [plan [name|path]|stats|doctor]";
16
17const SUBCOMMANDS = new Map<string, Subcommand>([
18  ["", pane.command],
19  ["plan", plan.command],
20  ["stats", stats.command],
21  ["doctor", doctor.command],
22]);
23
24export function split(args: string): { name: string; rest: string } {
25  const match = /^(\S*)\s*(.*)$/s.exec(args.trim());
26  return { name: match?.[1] ?? "", rest: match?.[2] ?? "" };
27}
28
29export const router: Features = {
30  "command.run": {
31    async pre(host, e) {
32      const { name, rest } = split(e.args);
33      const subcommand = SUBCOMMANDS.get(name);
34      if (subcommand === undefined) return { answer: { text: `Unknown /omca subcommand "${name}". ${USAGE}` } };
35      const answer = await subcommand(host, e, rest);
36      return answer === undefined ? undefined : { answer };
37    },
38  },
39};
40