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.

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.
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.
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.
/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./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./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.
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.">
Based on oh-my-openagent by @code-yeongyu.
MIT
hooks/register.ts 197 lines1import 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};
197hooks/agents-tracker.ts 263 lines1import 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};
263hooks/band.ts 248 lines1import 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};
248hooks/bash-guard.ts 259 lines1import 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};
259hooks/compact.ts 32 lines1import { 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};
32hooks/completion.ts 21 lines1import { 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};
21hooks/dispatch.ts 112 lines1import 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}
112hooks/footer.ts 64 lines1import { 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};
64hooks/host.ts 261 lines1import 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}
261hooks/metrics.ts 89 lines1import { 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};
89hooks/mod-marker.ts 26 lines1import { 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};
26hooks/omca-router.ts 40 lines1import 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