Claude Code plugin: use ChatGPT (Codex), Cursor, OpenCode Zen, and Antigravity models inside one Claude Code session. Switch with /model, delegate to named…

One Claude Code session. Your models. Their native tools.
Multi brings external models and coding harnesses into one Claude Code session through the /model picker and provider workers started from the Agent tool, with each provider's own login and permissions. Providers are OpenAI (ChatGPT via Codex login), Cursor (official SDK), OpenCode Zen (API key), Antigravity (official CLI), and Grok (official Grok Build CLI).
Quick start · Providers · Documentation · Contributing · Changelog
Illustration of the multi-provider workflow, edited from a live Luna terminal capture; not a recording of a mixed-provider run. Asset provenance.
/model and select supported reasoning effort with /effort.multi-openai, multi-cursor, multi-zen, multi-antigravity, multi-grok); native actions appear as tool rows under the harness's own tool names, with elapsed time and cancellation.In Claude Code, add the marketplace and install the providers you want:
/plugin marketplace add greenpolo/cc-multi-cli-plugin
/plugin install multi-openai@cc-multi-cli-plugin
/plugin install multi-cursor@cc-multi-cli-plugin
/plugin install multi-zen@cc-multi-cli-plugin
/plugin install multi-antigravity@cc-multi-cli-plugin
/plugin install multi-grok@cc-multi-cli-plugin
/reload-plugins
/multi-core:setup
Install any subset; each provider pulls in the shared multi-core plugin. Open a new terminal, run claude-multi, and connect the providers you installed:
| Plugin | Command | What it gives you |
|---|---|---|
multi-openai | /multi-openai:login | ChatGPT models through Codex |
multi-cursor | /multi-cursor:login | Official Cursor SDK models and workers |
multi-zen | /multi-zen:connect | OpenCode Zen models with an API key |
multi-antigravity | /multi-antigravity:connect | Antigravity models and workers through agy |
multi-grok | /multi-grok:login | Grok models and workers through Grok Build |
multi status shows installed/enabled providers; it does not test login or inference. multi uninstall removes the shell integration and keeps provider logins. Plain claude stays unchanged unless you explicitly choose --command claude. Rename the launch command or trim the /model rows with /multi-core:setup --command <name> --models <ids>. Details: installation.
Paste this into any coding agent:
Install cc-multi-cli-plugin by following https://github.com/greenpolo/cc-multi-cli-plugin/blob/main/docs/installation.md#for-agents. Ask which providers I want, what to name the launch command (default
claude-multi), and which models to show in/model(curated provider defaults). Hand browser logins and API-key entry to me, and never ask for credentials in chat.
Launch with claude-multi. /model lists the external models next to Claude's; /effort sets effort where the model supports it. Provider workers run as subagents with live progress, elapsed time and cancellation; the Agent tool's model parameter picks which model of that provider runs. Claude's permission mode governs every provider; see permissions. Resume a saved session with claude-multi --resume <session-id>.
Use /multi-usage to open a provider usage menu with quotas, billed spend where available, session tokens, and worker receipts. Set MULTI_RECEIPTS_FILE before launching to append JSONL receipts. See usage and receipts.
Linux, macOS, and Windows have platform-specific implementations and a CI matrix for offline checks on pushes and pull requests. WSL uses Linux paths and policy handling. See platform support for verification scope and remaining live checks.
| Start here | Learn more |
|---|---|
| Installation and account setup | Permissions and review |
| OpenAI · Cursor | Architecture and execution flow |
| Claude Mods reference | Required integration boundary for Claude Code UI and extensibility |
| OpenCode Zen · Antigravity · Grok | Platform support and verification |
With the default claude-multi command, plain claude stays unchanged. Choosing --command claude explicitly shadows it. Provider plugins are opt-in, and each provider uses its own authentication. multi uninstall removes the shell integration while preserving provider logins.
OpenAI and Zen use Claude Code's tool execution loop. Cursor uses its official SDK, and Antigravity and Grok use their real CLIs. Native harness actions are displayed in the session and are never replayed as executable Claude tool calls. See architecture and permissions for the boundaries.
Bug reports, provider improvements, and documentation fixes are welcome. Read the contributing guide for local setup and checks, or open a bug report or feature request.
CI runs the repository checks on Linux, macOS, and Windows. See the workflow results.
Apache 2.0. See NOTICE for upstream credits.
hooks/register.ts 201 lines1import type { EngineInterface, Register } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3import type { MultiCoreDisplayTools, MultiCorePolicy } from '../types/multi-core.d.ts';
4import { register as registerCompaction } from './compact.ts';
5import { isActive, postJson, type Wire } from './gateway.ts';
6import { register as registerLifecycle } from './lifecycle.ts';
7import { type PromptSnapshot, policyClient, recordPrompt, snapshotKey } from './policy.ts';
8import { isHarnessModel, isMultiModel } from './provider.ts';
9import {
10 callDisplayRow,
11 isDisplayTool,
12 type RowsClient,
13 register as registerRows,
14 syncDisplayTools,
15} from './rows.ts';
16import { defined } from './state.ts';
17import { register as registerUsage } from './usage.ts';
18import { register as registerWorkers } from './workers.ts';
19
20const modKeys = atom(
21 { plugin: 'multi-core', key: 'modKeys' } as const,
22 {} as Record<string, string>,
23);
24
25const wire = ($: EngineInterface): Wire => ({
26 url: () => $.env.get('MULTI_MOD_GATEWAY_URL'),
27 token: () => $.env.get('MULTI_GATEWAY_TOKEN'),
28 fetch: (url, init) => $.http.fetch(url, init),
29 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
30 keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) },
31});
32
33// State values are named where they are read: the engine's scan reads an atom's plugin and key
34// from this file's own source, not across an import.
35const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy);
36const agentModels = atom(
37 { plugin: 'multi-core', key: 'agentModels' } as const,
38 {} as Record<string, string>,
39);
40const displayTools = atom(
41 { plugin: 'multi-core', key: 'displayTools' } as const,
42 { registered: [] } as MultiCoreDisplayTools,
43);
44
45const rowsClient = ($: EngineInterface): RowsClient => ({
46 wire: wire($),
47 sessionId: () => $.session.id(),
48 held: () => read($, displayTools),
49 save: (change) => update($, displayTools, change),
50 register: (tool) => $.tool.register(tool),
51});
52
53type PromptEvent = { session_id: string; cwd: string; permission_mode?: string };
54
55// Claude Code 2.1.272 loads exactly one entry from hooks.json `modules`; compose here.
56// What the hooks keep lives in `$.state` (`state.ts`), so a hot reload loses none of it.
57export const register: Register = (on, options) => {
58 registerUsage(on, options);
59 registerLifecycle(on, options);
60 registerCompaction(on, options);
61 registerWorkers(on, options);
62 registerRows(on);
63 // One hook for every tool call (a module hooks an event once without a matcher): a
64 // display row is answered here, any other call is attributed to its provider's reviewer.
65 on('tool.call', async ($, event, next) => {
66 if (isDisplayTool(event.tool)) {
67 return callDisplayRow(rowsClient($), event);
68 }
69 await attributeToolCall($, event);
70 return next(event);
71 });
72 on('session.start', async ($, event, next) => {
73 if (!(await isActive(wire($)))) {
74 return next(event);
75 }
76 await $.command.register({
77 name: 'multi-usage',
78 description: 'Open provider quotas, spend, and session receipts.',
79 immediate: true,
80 });
81 await greet($, await $.session.id(), await $.session.cwd());
82 return next(event);
83 });
84 on('classic.UserPromptSubmit', async ($, event, next) => {
85 await recordSnapshot($, event);
86 return next(event);
87 });
88 on('classic.SessionStart', async ($, event, next) => {
89 if (typeof event.permission_mode === 'string') {
90 await recordSnapshot($, event);
91 }
92 return next(event);
93 });
94};
95
96/**
97 * The gateway's first record of a session (which also tells the launcher the mod is
98 * live). `/clear` ends a session and starts another in the process without a
99 * `session.start`, and `session.end` made the gateway forget the first, so the next
100 * prompt greets it again before recording its snapshot.
101 */
102async function greet($: EngineInterface, sessionId: string, cwd: string) {
103 const response = await postJson(wire($), '/multi/mod/session', {
104 sessionId,
105 cwd,
106 model: await $.session.model(),
107 event: 'start',
108 });
109 await update($, policy, (held) =>
110 defined({
111 ...held,
112 generation: typeof response?.generation === 'number' ? response.generation : undefined,
113 handshake: sessionId,
114 }),
115 );
116}
117
118/**
119 * Records the prompt-boundary snapshot: the permission mode and workspace every
120 * provider reads. A harness prompt waits for its translated settings policy; any other
121 * model's snapshot is posted only when it differs from the one the gateway holds, so
122 * ordinary Claude turns cost no round trip. The snapshot is always kept here, because a
123 * harness worker spawned later admits against it.
124 */
125async function recordSnapshot($: EngineInterface, event: PromptEvent) {
126 if (!(await isActive(wire($)))) {
127 return;
128 }
129 const held = await read($, policy);
130 if (held.handshake !== event.session_id) {
131 await greet($, event.session_id, event.cwd);
132 }
133 const prompt = snapshotOf(event);
134 const model = await $.session.model();
135 const current = await read($, policy);
136 const harness = isHarnessModel(model);
137 const key = snapshotKey(prompt, model);
138 let generation = current.generation;
139 let posted: string | undefined;
140 if (harness || current.posted !== key || generation === undefined) {
141 generation = await recordPrompt(policyClient(wire($), model), prompt, generation);
142 posted = harness || generation === undefined ? undefined : key;
143 } else {
144 posted = key;
145 }
146 await update($, policy, (latest) =>
147 defined<MultiCorePolicy>({
148 ...latest,
149 prompt,
150 generation,
151 posted,
152 harnessReady: harness && generation !== undefined,
153 }),
154 );
155 if (harness) {
156 // The display rows of this prompt's native run anchor only once their tools exist.
157 await syncDisplayTools(rowsClient($));
158 }
159}
160
161function snapshotOf(event: PromptEvent): PromptSnapshot {
162 return defined({
163 sessionId: event.session_id,
164 cwd: event.cwd,
165 permissionMode: event.permission_mode,
166 });
167}
168
169/**
170 * Reviewer attribution for a provider's tool call: the gateway learns the session, call,
171 * and workspace it needs to match the reviewer request to it. A Claude loop's tool call
172 * is Claude's own and costs no gateway call. The reply never decides the call. It runs
173 * from `tool.call`, which carries the calling loop's agentId (`classic.PreToolUse` does
174 * not, so it could not tell a provider worker's call from the session's own).
175 */
176async function attributeToolCall(
177 $: EngineInterface,
178 event: { tool: string; tool_use_id?: string; agentId?: string },
179) {
180 try {
181 const model =
182 event.agentId === undefined
183 ? await $.session.model()
184 : (await read($, agentModels))[event.agentId];
185 if (!isMultiModel(model) || !(await isActive(wire($)))) {
186 return;
187 }
188 // The engine hands a tool call no permission mode: the prompt-boundary snapshot has it.
189 const mode = (await read($, policy)).prompt?.permissionMode;
190 await postJson(wire($), '/multi/permission', {
191 session_id: await $.session.id(),
192 tool_use_id: event.tool_use_id,
193 tool_name: event.tool,
194 cwd: await $.session.cwd(),
195 ...(mode === undefined ? {} : { permission_mode: mode }),
196 });
197 } catch {
198 // Attribution is best effort; Claude's own checks decide the call.
199 }
200}
201types/multi-core.d.ts 56 lines1// The values multi-core's hooks keep in `$.state`: held by the host for the session,
2// so they survive a hot reload of the hooks module, and a drawing that reads one is
3// drawn again when it is written. Plain JSON data; a field without a value is left out.
4export type MultiCorePrompt = { sessionId: string; cwd: string; permissionMode?: string };
5
6/** The prompt-boundary snapshot the gateway holds this session's mode generation for. */
7export type MultiCorePolicy = {
8 generation?: number;
9 prompt?: MultiCorePrompt;
10 harnessReady?: boolean;
11 /** The snapshot last posted for a prompt that needs no harness policy, so an unchanged one posts nothing. */
12 posted?: string;
13 /** The session the gateway was greeted for (`session.start`, or the first prompt after a `/clear`). */
14 handshake?: string;
15};
16
17export type MultiCoreDisplayTools = { registered: string[]; revision?: number };
18
19export type MultiCoreUsagePane = {
20 updatedAt: string;
21 providers: Array<{
22 id: string;
23 name: string;
24 status: string;
25 summary: string;
26 details: string[];
27 url?: string;
28 }>;
29 receiptLines?: string[];
30 error?: string;
31 quotaAdviceEnabled?: boolean;
32};
33
34declare module 'claude-code' {
35 interface PluginState {
36 'multi-core': {
37 policy: MultiCorePolicy;
38 /** The model each loop last stepped on or was registered with, by agent id (`main` for the main loop). */
39 agentModels: Record<string, string>;
40 /** The model a provider worker resolved to, by its Agent call's tool_use_id. */
41 spawnModels: Record<string, string>;
42 /** Agent types positively classified as Claude-loop on a Claude model. */
43 claudeTypes: string[];
44 /** Provider (multi-*) worker types the model is offered. */
45 offeredProviders: string[];
46 displayTools: MultiCoreDisplayTools;
47 /** The usage pane's props, by session id. */
48 usagePanes: Record<string, MultiCoreUsagePane>;
49 /** The gateway's per-session mod key, by session id. Never logged or drawn. */
50 modKeys: Record<string, string>;
51 /** Sessions whose Agent calls carry the quota advice. */
52 advisorySessions: string[];
53 };
54 }
55}
56hooks/compact.ts 96 lines1import type { EngineInterface, Register } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3import { accepted, getJson, isActive, postJson, type Wire } from './gateway.ts';
4import { isHarnessModel } from './provider.ts';
5
6// State values are named where they are read: the engine's scan reads an atom's plugin and key
7// from this file's own source, not across an import.
8const agentModels = atom(
9 { plugin: 'multi-core', key: 'agentModels' } as const,
10 {} as Record<string, string>,
11);
12
13const modKeys = atom(
14 { plugin: 'multi-core', key: 'modKeys' } as const,
15 {} as Record<string, string>,
16);
17
18const wire = ($: EngineInterface): Wire => ({
19 url: () => $.env.get('MULTI_MOD_GATEWAY_URL'),
20 token: () => $.env.get('MULTI_GATEWAY_TOKEN'),
21 fetch: (url, init) => $.http.fetch(url, init),
22 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
23 keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) },
24});
25
26export const register = (on: Parameters<Register>[0], _options: Parameters<Register>[1]) => {
27 on('session.compact', async ($, event, next) => {
28 // session.model() describes only the main loop. A Claude child must never
29 // inherit its external parent's compaction policy (or the reverse).
30 const model = await compactionModel($, event.agentId);
31 if (!isHarnessModel(model)) {
32 return next(event);
33 }
34 if (!(await isActive(wire($)))) {
35 return next(event);
36 }
37 const sessionId = await $.session.id();
38 const mode = accepted(await getJson(wire($), '/multi/mod/mode', { sessionId }));
39 if (typeof mode?.generation !== 'number') {
40 return { skip: 'Multi compaction policy generation is unavailable.' };
41 }
42 const payload = {
43 sessionId,
44 agentId: event.agentId,
45 generation: mode.generation,
46 trigger: event.trigger,
47 messages: event.messages,
48 instructions: event.instructions,
49 };
50 if (event.trigger === 'precompute') {
51 await precompute($, payload);
52 return { skip: 'Multi summary preparation runs outside the hook budget.' };
53 }
54 const result = accepted(await postJson(wire($), '/multi/mod/compact/authorize', payload));
55 if (result?.messages) {
56 return { messages: result.messages };
57 }
58 if (result?.allow) {
59 return next(event);
60 }
61 // Oversized transcripts still require a separate, small tool-free authorization.
62 const fallback = accepted(
63 await postJson(wire($), '/multi/mod/compact/authorize', {
64 sessionId,
65 agentId: event.agentId,
66 generation: mode.generation,
67 }),
68 );
69 if (!fallback?.allow) {
70 return { skip: 'Multi tool-free compaction authorization was not acknowledged.' };
71 }
72 return next(event);
73 });
74};
75
76async function precompute($: EngineInterface, payload: Record<string, unknown>) {
77 const prepared = accepted(await postJson(wire($), '/multi/mod/compact/precompute', payload));
78 if (prepared?.accepted && prepared.precomputeId) {
79 // The gateway starts the summary and answers at once, so the hook can await it.
80 await postJson(wire($), '/multi/mod/compact/run', {
81 sessionId: payload.sessionId,
82 agentId: payload.agentId,
83 generation: payload.generation,
84 precomputeId: prepared.precomputeId,
85 });
86 }
87}
88
89/** The main model is never evidence of a child's provider. */
90async function compactionModel(
91 $: EngineInterface,
92 agentId: string | undefined,
93): Promise<string | undefined> {
94 return agentId ? (await read($, agentModels))[agentId] : $.session.model();
95}
96hooks/gateway.ts 213 lines1import type { HttpInit, HttpResponse, SessionMessage } from 'claude-code';
2
3/**
4 * The one client for the authenticated loopback `/multi/mod/*` control plane.
5 *
6 * Every hook reaches the gateway through `getJson` or `postJson`: the method is
7 * explicit, a body is bounded in bytes, and the wait is bounded by `$.clock` (a hooks
8 * module has no timers). `undefined` means the gateway is dormant, unreachable, slow or
9 * answered something that is not JSON; a refused reply (any non-2xx status) comes back
10 * with `refused` set and the gateway's reason, never with its body, so a caller that
11 * checks only `accepted` still fails closed.
12 */
13const maxBody = 32000;
14const defaultTimeoutMs = 1500;
15
16export type GatewayResponse = {
17 refused?: true;
18 httpStatus?: number;
19 error?: string;
20 accepted?: boolean;
21 generation?: number | string;
22 status?: string;
23 stale?: boolean;
24 isOffered?: boolean;
25 execution?: 'claude' | 'harness';
26 known?: boolean;
27 model?: string;
28 precomputeId?: string;
29 allow?: boolean;
30 messages?: readonly SessionMessage[];
31 [field: string]: unknown;
32};
33
34export type GatewayOptions = {
35 /** How long to wait; 0 waits on the gateway's own bound (a held long poll). */
36 timeoutMs?: number;
37 /** Ends the wait early, as `next.signal` ends a hook's with its dispatch. */
38 signal?: AbortSignal;
39};
40
41/**
42 * What the client needs of the engine, as four calls and the session keys. The engine follows `$` only into
43 * functions of the file that hooks, never across an import, and wants each `$.env.get`
44 * name and `$.noun.event(...)` spelled at its call site, so each hooks file builds this
45 * from its own `$` and hands it here:
46 *
47 * const wire = ($: EngineInterface): Wire => ({
48 * url: () => $.env.get('MULTI_MOD_GATEWAY_URL'),
49 * token: () => $.env.get('MULTI_GATEWAY_TOKEN'),
50 * fetch: (url, init) => $.http.fetch(url, init),
51 * sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
52 * keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) },
53 * });
54 */
55export type Wire = {
56 /**
57 * The per-session keys the gateway issued, held in `$.state` so a hot reload keeps them. The
58 * gateway refuses a session's token-only POSTs once its key has been echoed, so a lost key
59 * would lock the mod out of that session.
60 */
61 keys: ModKeys;
62 url: () => Promise<string | undefined>;
63 token: () => Promise<string | undefined>;
64 fetch: (url: string, init: HttpInit) => Promise<HttpResponse>;
65 sleep: (ms: number, signal: AbortSignal) => Promise<void>;
66};
67
68export type ModKeys = {
69 read: () => Promise<Readonly<Record<string, string>>>;
70 save: (change: (held: Record<string, string>) => Record<string, string>) => Promise<unknown>;
71};
72
73/** Sent on every request that names a session once the gateway has issued that session's key. */
74const keyHeader = 'x-multi-mod-key';
75const maxKeys = 256;
76
77/** Forgets the key of an ended session. */
78export async function forgetKey(wire: Wire, sessionId: string): Promise<void> {
79 await wire.keys.save((held) => {
80 const { [sessionId]: _ended, ...others } = held;
81 return others;
82 });
83}
84
85async function rememberKey(wire: Wire, sessionId: string, key: string): Promise<void> {
86 await wire.keys.save((held) => {
87 const { [sessionId]: _replaced, ...others } = held;
88 return Object.fromEntries([...Object.entries(others).slice(-(maxKeys - 1)), [sessionId, key]]);
89 });
90}
91
92type Environment = { base: string; token: string };
93
94async function environment(wire: Wire): Promise<Environment | undefined> {
95 const base = await wire.url();
96 const token = await wire.token();
97 return base && token ? { base, token } : undefined;
98}
99
100/** Whether this session was launched with the gateway's Mod control plane. */
101export async function isActive(wire: Wire): Promise<boolean> {
102 return (await environment(wire)) !== undefined;
103}
104
105export function getJson<T extends GatewayResponse = GatewayResponse>(
106 wire: Wire,
107 route: string,
108 query: Record<string, string> = {},
109 options: GatewayOptions = {},
110): Promise<T | undefined> {
111 const search = new URLSearchParams(query).toString();
112 return send<T>(
113 wire,
114 'GET',
115 search ? `${route}?${search}` : route,
116 undefined,
117 options,
118 query.sessionId,
119 );
120}
121
122export function postJson<T extends GatewayResponse = GatewayResponse>(
123 wire: Wire,
124 route: string,
125 payload: object,
126 options: GatewayOptions = {},
127): Promise<T | undefined> {
128 const { sessionId } = payload as { sessionId?: unknown };
129 return send<T>(
130 wire,
131 'POST',
132 route,
133 JSON.stringify(payload),
134 options,
135 typeof sessionId === 'string' ? sessionId : undefined,
136 );
137}
138
139/** The reply only when the gateway accepted the request. */
140export function accepted<T extends GatewayResponse>(reply: T | undefined): T | undefined {
141 return reply && !reply.refused ? reply : undefined;
142}
143
144async function send<T extends GatewayResponse>(
145 wire: Wire,
146 method: 'GET' | 'POST',
147 route: string,
148 body: string | undefined,
149 options: GatewayOptions,
150 sessionId: string | undefined,
151): Promise<T | undefined> {
152 const env = await environment(wire);
153 if (!env || (body !== undefined && new TextEncoder().encode(body).length > maxBody)) {
154 return undefined;
155 }
156 const waiting = new AbortController();
157 const cancel = () => waiting.abort();
158 options.signal?.addEventListener('abort', cancel, { once: true });
159 try {
160 const key = sessionId ? (await wire.keys.read())[sessionId] : undefined;
161 const fetched = wire.fetch(`${env.base}${route}`, {
162 method,
163 headers: {
164 'content-type': 'application/json',
165 'x-multi-gateway-token': env.token,
166 ...(key ? { [keyHeader]: key } : {}),
167 },
168 ...(body === undefined ? {} : { body }),
169 });
170 // A late failure of a fetch that lost the race to its deadline is not reported.
171 fetched.catch(() => undefined);
172 const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
173 const result =
174 timeoutMs > 0
175 ? await Promise.race([fetched, deadline(wire, timeoutMs, waiting.signal)])
176 : await fetched;
177 const issued = result.headers?.[keyHeader];
178 if (sessionId && issued && issued !== key) {
179 await rememberKey(wire, sessionId, issued);
180 }
181 return result.ok ? (JSON.parse(result.text) as T) : (refusal(result) as T);
182 } catch {
183 return undefined;
184 } finally {
185 options.signal?.removeEventListener('abort', cancel);
186 waiting.abort();
187 }
188}
189
190async function deadline(wire: Wire, ms: number, signal: AbortSignal): Promise<never> {
191 await wire.sleep(ms, signal);
192 throw new Error('gateway request timeout');
193}
194
195function refusal(result: { text: string; status: number }): GatewayResponse {
196 let reason: string | undefined;
197 try {
198 const parsed: unknown = JSON.parse(result.text);
199 if (parsed && typeof parsed === 'object') {
200 const value = (parsed as { error?: unknown }).error;
201 reason = typeof value === 'string' && value ? value : undefined;
202 }
203 } catch {
204 // A non-JSON body still names the status below.
205 }
206 const detail = result.text.trim().slice(0, 200);
207 return {
208 refused: true,
209 httpStatus: result.status,
210 error: reason ?? (detail ? `gateway ${result.status}: ${detail}` : `gateway ${result.status}`),
211 };
212}
213hooks/lifecycle.ts 226 lines1import type { EngineInterface, Register, Timer } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3import type {
4 MultiCoreDisplayTools,
5 MultiCorePolicy,
6 MultiCoreUsagePane,
7} from '../types/multi-core.d.ts';
8import { forgetKey, getJson, isActive, postJson, type Wire } from './gateway.ts';
9import { isHarnessModel, isMultiModel } from './provider.ts';
10import { type RowsClient, syncDisplayTools } from './rows.ts';
11import { withBounded } from './state.ts';
12
13// State values are named where they are read: the engine's scan reads an atom's plugin and key
14// from this file's own source, not across an import.
15const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy);
16const agentModels = atom(
17 { plugin: 'multi-core', key: 'agentModels' } as const,
18 {} as Record<string, string>,
19);
20const spawnModels = atom(
21 { plugin: 'multi-core', key: 'spawnModels' } as const,
22 {} as Record<string, string>,
23);
24const claudeTypes = atom({ plugin: 'multi-core', key: 'claudeTypes' } as const, [] as string[]);
25const offeredProviders = atom(
26 { plugin: 'multi-core', key: 'offeredProviders' } as const,
27 [] as string[],
28);
29const displayTools = atom(
30 { plugin: 'multi-core', key: 'displayTools' } as const,
31 { registered: [] } as MultiCoreDisplayTools,
32);
33const usagePanes = atom(
34 { plugin: 'multi-core', key: 'usagePanes' } as const,
35 {} as Record<string, MultiCoreUsagePane>,
36);
37const advisorySessions = atom(
38 { plugin: 'multi-core', key: 'advisorySessions' } as const,
39 [] as string[],
40);
41
42const rowsClient = ($: EngineInterface): RowsClient => ({
43 wire: wire($),
44 sessionId: () => $.session.id(),
45 held: () => read($, displayTools),
46 save: (change) => update($, displayTools, change),
47 register: (tool) => $.tool.register(tool),
48});
49
50const modKeys = atom(
51 { plugin: 'multi-core', key: 'modKeys' } as const,
52 {} as Record<string, string>,
53);
54
55const wire = ($: EngineInterface): Wire => ({
56 url: () => $.env.get('MULTI_MOD_GATEWAY_URL'),
57 token: () => $.env.get('MULTI_GATEWAY_TOKEN'),
58 fetch: (url, init) => $.http.fetch(url, init),
59 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
60 keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) },
61});
62
63type Status = {
64 model?: string;
65 state?: string;
66 detail?: string;
67 error?: string;
68 elapsedMs?: number;
69 startedAt?: number;
70 [field: string]: unknown;
71};
72
73/** A harness loop's status poll: a timer on `$.clock`, so it ends with its environment. */
74type Poll = { timer: Timer; since: number; failures: number; isBusy: boolean };
75
76const pollMs = 500;
77const maximumPolls = 128;
78const maximumFailures = 5;
79/** The polls of this module instance; a hot reload cancels the timers with it. */
80const polls = new Map<string, Poll>();
81
82function stopPoll(key: string): boolean {
83 const poll = polls.get(key);
84 poll?.timer.cancel();
85 return polls.delete(key);
86}
87
88async function startPoll($: EngineInterface, key: string, agentId: string | undefined) {
89 if (polls.has(key) || polls.size >= maximumPolls) {
90 return;
91 }
92 const poll: Poll = {
93 since: await $.clock.now(),
94 failures: 0,
95 isBusy: false,
96 timer: $.clock.every(pollMs, () => {
97 void tick($, key, agentId);
98 }),
99 };
100 polls.set(key, poll);
101}
102
103/** One status read; a tick still in flight when the next is due is skipped. */
104async function tick($: EngineInterface, key: string, agentId: string | undefined) {
105 const poll = polls.get(key);
106 if (!poll || poll.isBusy) {
107 return;
108 }
109 poll.isBusy = true;
110 try {
111 const status = await getJson<Status>(wire($), '/multi/mod/lifecycle', {
112 sessionId: await $.session.id(),
113 agentId: agentId ?? 'main',
114 });
115 if (polls.get(key) !== poll) {
116 return;
117 }
118 poll.failures = status && !status.refused ? 0 : poll.failures + 1;
119 if (status?.state && (status.startedAt ?? 0) >= poll.since) {
120 $.ui.status(statusText(status, agentId));
121 if (status.state !== 'running') {
122 stopPoll(key);
123 }
124 }
125 if (poll.failures >= maximumFailures) {
126 stopPoll(key);
127 }
128 } finally {
129 poll.isBusy = false;
130 }
131}
132
133async function prepareStep(
134 $: EngineInterface,
135 event: { agentId?: string; model: string; effort?: unknown },
136) {
137 const key = event.agentId ?? 'main';
138 const known = (await read($, agentModels))[key];
139 if (known !== event.model) {
140 await update($, agentModels, (held) => withBounded(held, key, event.model));
141 }
142 if (isMultiModel(event.model)) {
143 // The gateway reads a Multi request's model and effort from this record, so it is
144 // sent before the request; a Claude step costs the gateway nothing.
145 await postJson(wire($), '/multi/mod/telemetry', {
146 ...event,
147 sessionId: await $.session.id(),
148 });
149 }
150 if (isHarnessModel(event.model)) {
151 await startPoll($, key, event.agentId);
152 // A harness may announce a tool the mod has not registered; register it
153 // before the step's request, so the rows of later runs can anchor.
154 await syncDisplayTools(rowsClient($));
155 }
156}
157
158export const register = (on: Parameters<Register>[0], _options: Parameters<Register>[1]) => {
159 on('turn.step', async function* ($, event, next) {
160 await prepareStep($, event);
161 return yield* next(event);
162 });
163 on('turn.complete', async ($, event, next) => {
164 const key = event.agentId ?? 'main';
165 const model = (await read($, agentModels))[key];
166 if (isMultiModel(model)) {
167 await postJson(wire($), '/multi/mod/usage/complete', {
168 sessionId: await $.session.id(),
169 agentId: event.agentId,
170 turnId: event.turnId,
171 outcome: event.reason,
172 });
173 }
174 const wasRunning = stopPoll(key);
175 // Retain a child's identity between turns: compaction can precede its next step.
176 if (event.isAborted && isHarnessModel(model)) {
177 await postJson(wire($), '/multi/mod/compact/cancel', {
178 sessionId: await $.session.id(),
179 agentId: event.agentId,
180 });
181 }
182 if (wasRunning && polls.size === 0) {
183 $.ui.status(undefined);
184 }
185 return next(event);
186 });
187 // The session is over for good: a `/clear` or a resume ends one and starts another in
188 // the same process (`session.start` fires for neither), and the gateway forgets the
189 // ended one. `session.detach` fires when any client leaves the roster, a session still
190 // running, so it is not a place to forget anything.
191 on('session.end', async ($, event, next) => {
192 for (const key of [...polls.keys()]) {
193 stopPoll(key);
194 }
195 await forgetSession($, event.sessionId, next.signal);
196 return next(event);
197 });
198};
199
200/** Forgets everything the hooks held for the session that ended, here and in the gateway. */
201async function forgetSession($: EngineInterface, sessionId: string, signal: AbortSignal) {
202 await update($, policy, () => ({}));
203 await update($, agentModels, () => ({}));
204 await update($, spawnModels, () => ({}));
205 await update($, claudeTypes, () => []);
206 await update($, offeredProviders, () => []);
207 await update($, displayTools, () => ({ registered: [] }));
208 await update($, advisorySessions, (held) => held.filter((id) => id !== sessionId));
209 await update($, usagePanes, (held) => {
210 const { [sessionId]: _closed, ...others } = held;
211 return others;
212 });
213 $.ui.status(undefined);
214 if (await isActive(wire($))) {
215 await postJson(wire($), '/multi/mod/detach', { sessionId }, { timeoutMs: 1000, signal });
216 }
217 // After the detach, which the gateway admits only with the key.
218 await forgetKey(wire($), sessionId);
219}
220
221function statusText(status: Status, agentId: string | undefined) {
222 const elapsed = Math.floor((status.elapsedMs ?? 0) / 1000);
223 // A failed or refused run names its reason, so the line says why, not only that.
224 return `${status.model} · ${agentId ?? 'main'} · ${status.state} · ${elapsed}s ${status.error ?? status.detail ?? ''}`;
225}
226hooks/policy.ts 173 lines1import type { MultiCorePolicy, MultiCorePrompt } from '../types/multi-core.d.ts';
2import { type GatewayOptions, getJson, postJson, type Wire } from './gateway.ts';
3import { isHarnessModel } from './provider.ts';
4
5export type PolicyResponse = {
6 refused?: true;
7 httpStatus?: number;
8 error?: string;
9 generation?: number | string;
10 status?: string;
11 accepted?: boolean;
12};
13/** The policy's gateway for one session model; `wire` is the calling file's own. */
14export function policyClient(wire: Wire, model: string): PolicyClient {
15 return {
16 model,
17 post: (route, payload, options) => postJson(wire, route, payload, options),
18 get: (route, query) => getJson(wire, route, query),
19 };
20}
21
22/** The gateway as the policy needs it: explicit methods, as `gateway.ts` offers them. */
23export type PolicyClient = {
24 model: string;
25 post: (
26 route: string,
27 payload: Record<string, unknown>,
28 options?: GatewayOptions,
29 ) => Promise<PolicyResponse | undefined>;
30 get: (route: string, query: Record<string, string>) => Promise<PolicyResponse | undefined>;
31};
32export type PromptSnapshot = MultiCorePrompt;
33export type PolicyState = MultiCorePolicy;
34
35/** The admission each prompt is running, shared by every helper spawned from that prompt. */
36const preparing = new Map<string, Promise<number | undefined>>();
37
38/** A prompt's identity as the gateway holds it: what, with the model, decides a repost. */
39export function snapshotKey(snapshot: PromptSnapshot, model: string): string {
40 return JSON.stringify([snapshot.sessionId, snapshot.cwd, snapshot.permissionMode ?? null, model]);
41}
42
43/**
44 * Share one admission across helpers spawned from the same prompt. It updates `state`,
45 * a copy the caller persists; only a state still holding the admitted prompt is changed.
46 */
47export async function ensureHarnessPolicy(client: PolicyClient, state: PolicyState) {
48 const snapshot = state.prompt;
49 if (!snapshot || state.harnessReady) {
50 return;
51 }
52 const key = snapshotKey(snapshot, client.model);
53 const admission = preparing.get(key) ?? admitPrompt(client, snapshot, state.generation);
54 preparing.set(key, admission);
55 let generation: number | undefined;
56 try {
57 generation = await admission;
58 } finally {
59 if (preparing.get(key) === admission) {
60 preparing.delete(key);
61 }
62 }
63 state.generation = generation;
64 state.harnessReady = generation !== undefined;
65}
66
67/** Prepare translated settings only for a harness prompt or a requested harness worker. */
68export async function admitPrompt(
69 client: PolicyClient,
70 snapshot: PromptSnapshot,
71 sourceGeneration: number | undefined,
72): Promise<number | undefined> {
73 const prepared = await preparePolicy(client, snapshot.sessionId, snapshot.cwd, sourceGeneration);
74 if (!prepared) {
75 return undefined;
76 }
77 const response = await client.post('/multi/mod/session', {
78 policyGeneration: prepared.policyGeneration,
79 sessionId: snapshot.sessionId,
80 cwd: snapshot.cwd,
81 model: client.model,
82 event: 'prompt',
83 generation: prepared.generation,
84 permissionMode: snapshot.permissionMode,
85 });
86 if (!response?.accepted || typeof response.generation !== 'number') {
87 return undefined;
88 }
89 return response.generation;
90}
91
92/** Only an actual harness prompt waits for translated settings policy. */
93export async function recordPrompt(
94 client: PolicyClient,
95 snapshot: PromptSnapshot,
96 generation: number | undefined,
97): Promise<number | undefined> {
98 const model = client.model;
99 if (isHarnessModel(model)) {
100 return admitPrompt(client, snapshot, generation);
101 }
102 const response = await client.post('/multi/mod/session', {
103 ...snapshot,
104 model,
105 event: 'prompt',
106 });
107 return response?.accepted && typeof response.generation === 'number'
108 ? response.generation
109 : undefined;
110}
111
112type PolicyHandoff = { policyGeneration: string; generation: number | undefined };
113
114/** Exported for offline coverage of the stale-generation resync. */
115export async function preparePolicy(
116 client: PolicyClient,
117 sessionId: string,
118 cwd: string,
119 sourceGeneration: number | undefined,
120): Promise<PolicyHandoff | undefined> {
121 let generation = sourceGeneration;
122 let started = await client.post('/multi/mod/policy', { sessionId, cwd, sourceGeneration });
123 if (started?.refused) {
124 // A reloaded hooks module keeps a mode generation the gateway no longer agrees
125 // with, and `/clear` detaches the session so the gateway holds none at all;
126 // either way every later prompt reads stale. Adopt the gateway's own value once,
127 // including the absence of one, which begins a fresh session.
128 const resynced = await modeGeneration(client, sessionId);
129 if (!resynced || resynced.generation === generation) {
130 return undefined;
131 }
132 generation = resynced.generation;
133 started = await client.post('/multi/mod/policy', {
134 sessionId,
135 cwd,
136 sourceGeneration: generation,
137 });
138 }
139 if (started?.refused || typeof started?.generation !== 'string') {
140 return undefined;
141 }
142 const policyGeneration = await awaitPolicy(client, sessionId, started.generation);
143 return policyGeneration === undefined ? undefined : { policyGeneration, generation };
144}
145
146/**
147 * One request: the gateway holds the reply until discovery ends (`claude plugin list`
148 * and settings admission take seconds on a cold Windows start) or its own bound passes.
149 * The wait is the gateway's, so it needs no polling loop in the hook's budget.
150 */
151async function awaitPolicy(client: PolicyClient, sessionId: string, generation: string) {
152 const result = await client.post(
153 '/multi/mod/policy',
154 { sessionId, generation, wait: true },
155 { timeoutMs: 0 },
156 );
157 return result?.status === 'ready' && !result.refused ? generation : undefined;
158}
159
160/** The gateway's own mode generation, or `{ generation: undefined }` when it holds none. */
161async function modeGeneration(client: PolicyClient, sessionId: string) {
162 const mode = await client.get('/multi/mod/mode', { sessionId });
163 if (typeof mode?.generation === 'number') {
164 return { generation: mode.generation };
165 }
166 // A 409 is the gateway answering that it holds no mode for this session, as after
167 // `/clear` detaches it. An unreachable gateway leaves the truth unknown instead.
168 if (mode && (!mode.refused || mode.httpStatus === 409)) {
169 return { generation: undefined };
170 }
171 return undefined;
172}
173hooks/provider.ts 14 lines1/** These providers execute tools outside Claude Code and need policy translation. */
2export function isHarnessModel(model: string | undefined): boolean {
3 return Boolean(
4 model?.startsWith('multi/cursor/') ||
5 model?.startsWith('multi/antigravity/') ||
6 model?.startsWith('multi/grok/'),
7 );
8}
9
10/** Any Multi model: a harness, or a provider whose tool loop Claude Code runs. */
11export function isMultiModel(model: string | undefined): boolean {
12 return Boolean(model?.startsWith('multi/'));
13}
14hooks/rows.ts 277 lines1import type { EngineInterface, Register, ToolSpec } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3import type { MultiCoreDisplayTools } from '../types/multi-core.d.ts';
4import { accepted, getJson, postJson, type Wire } from './gateway.ts';
5import {
6 errorBody,
7 headerSummary,
8 record,
9 response,
10 resultBody,
11 toolHeader,
12 unconfirmedBody,
13} from './rows-view.ts';
14import { rememberBounded } from './state.ts';
15
16/**
17 * Native harness actions as Claude Code tool rows.
18 *
19 * Cursor, Antigravity and Grok run their own tools. The gateway writes each
20 * finished native action into the harness's reply as a tool_use block named after
21 * the native tool (`mcp__multi-core__run_command`), with the input of the built-in
22 * it mirrors, the native parameters under `native` and a one-time token, so the
23 * row anchors in the transcript of the session or worker that ran it. This module
24 * registers those names, keeps them out of every model's prompt, refuses any call
25 * the gateway did not originate, answers the gateway's own with the native output,
26 * and draws the row as Claude Code draws the built-in the action mirrors
27 * (`rows-view.ts`), under the native tool's name.
28 * The names are registered lazily, so a session that never runs a harness model lists
29 * none: `register.ts` syncs them at a harness prompt, `workers.ts` before a harness
30 * worker spawns, and `lifecycle.ts` before each harness step (a module hooks each
31 * event once). What is registered, and the catalog revision it answers, is `$.state`.
32 */
33const prefix = 'mcp__multi-core__';
34const tokenKey = 'multi_row';
35const namePattern = /^[A-Za-z0-9_-]{1,47}$/;
36const maximumTools = 160;
37const maximumRemembered = 512;
38const reminder = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
39const description =
40 'Display row for a native Cursor, Antigravity or Grok action. Only the Multi gateway ' +
41 'originates it; it is not a tool a model can call.';
42const refusal =
43 'This is a display row for a native harness action. Only the Multi gateway originates it; ' +
44 'it is not a tool a model can call.';
45
46type On = Parameters<Register>[0];
47type Row = { output: string; isError: boolean };
48
49// Named where it is read: the engine's scan reads an atom's plugin and key from this file.
50const modKeys = atom(
51 { plugin: 'multi-core', key: 'modKeys' } as const,
52 {} as Record<string, string>,
53);
54
55const rowGateway = ($: EngineInterface): RowGateway => ({
56 wire: {
57 url: () => $.env.get('MULTI_MOD_GATEWAY_URL'),
58 token: () => $.env.get('MULTI_GATEWAY_TOKEN'),
59 fetch: (url, init) => $.http.fetch(url, init),
60 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
61 keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) },
62 },
63 sessionId: () => $.session.id(),
64});
65
66/** What answering a display row needs: the gateway and this session's id. */
67export type RowGateway = { wire: Wire; sessionId: () => Promise<string> };
68
69/**
70 * What syncing needs of the engine. The engine follows `$` only into functions of the
71 * file that hooks, so each caller (`register.ts`, `lifecycle.ts`, `workers.ts`) builds
72 * this from its own `$`: the state calls are `read`/`update` on `displayTools`.
73 */
74export type RowsClient = RowGateway & {
75 held: () => Promise<MultiCoreDisplayTools>;
76 save: (
77 change: (held: MultiCoreDisplayTools) => MultiCoreDisplayTools,
78 ) => Promise<MultiCoreDisplayTools>;
79 register: (tool: ToolSpec) => Promise<unknown>;
80};
81
82/**
83 * Row inputs by tool_use id: a `ToolResult` carries no input, and its drawing needs it.
84 * A render memo (every draw of a row's `ToolUse` refills it), not state a reload loses,
85 * and never written from a render hook, which `$.state` refuses.
86 */
87const inputs = new Map<string, Record<string, unknown>>();
88
89export function isDisplayTool(tool: unknown): tool is string {
90 return typeof tool === 'string' && tool.startsWith(prefix);
91}
92
93export const register = (on: On) => {
94 on('tool.describe', async (_$, event, next) => {
95 if (!isDisplayTool(event.tool)) {
96 return next(event);
97 }
98 // Behind ToolSearch, so no model's prompt lists a display row as a tool.
99 return { description: event.description, isDeferred: true };
100 });
101 on('tool.check', async ($, event, next) => {
102 if (!isDisplayTool(event.tool)) {
103 return next(event);
104 }
105 const row = await issuedRow(rowGateway($), event.input, event.tool_use_id);
106 return row
107 ? { decision: 'allow' as const, reason: 'A row the Multi gateway issued for this call.' }
108 : { decision: 'deny' as const, reason: refusal };
109 });
110 on('ui.render', { component: 'ToolUse' }, async ($, event, next) => {
111 if (!isDisplayTool(event.props.tool)) {
112 return next(event);
113 }
114 const input = record(event.props.input) ?? {};
115 remember(event.props.tool_use_id, input);
116 return toolHeader($.ui.resolve(event), {
117 name: event.props.tool.slice(prefix.length),
118 summary: headerSummary(input, await sessionCwd($)),
119 color: dotColor(event.props, input.failed === true, input.unconfirmed === true),
120 });
121 });
122 on('ui.render', { component: 'ToolResult' }, async ($, event, next) => {
123 if (!isDisplayTool(event.props.tool)) {
124 return next(event);
125 }
126 const tags = $.ui.resolve(event);
127 const input = inputs.get(event.props.tool_use_id) ?? {};
128 const output = outputText(event.props.output);
129 if (event.props.isErrored || input.failed === true) {
130 return response(tags, errorBody(tags, output || 'failed'));
131 }
132 if (input.unconfirmed === true) {
133 return response(tags, unconfirmedBody(tags, output));
134 }
135 const body = resultBody(tags, input, output, await sessionCwd($));
136 if (body) {
137 return response(tags, body);
138 }
139 // The built-in shows the output itself: the engine draws it, a few lines
140 // and `… +N lines` in the compact view, all of it under ctrl+o.
141 const shown = [{ type: 'text', text: output || '(No output)' }];
142 return next({ ...event, props: { ...event.props, output: shown } });
143 });
144};
145
146/**
147 * A display row's call, answered with the native output when the gateway issued its
148 * token for this call; any other call to a display name is refused. `register.ts`
149 * routes display tools here from the module's one `tool.call` hook.
150 */
151export async function callDisplayRow(client: RowGateway, event: { tool_use_id?: string }) {
152 const row = await issuedRow(client, event, event.tool_use_id);
153 if (!row) {
154 return { deny: refusal };
155 }
156 remember(String(event.tool_use_id), event as Record<string, unknown>);
157 return row.isError
158 ? { isError: true as const, result: row.output }
159 : { result: [{ type: 'text', text: row.output }] };
160}
161
162function remember(toolUseId: string, input: Record<string, unknown>) {
163 rememberBounded(inputs, toolUseId, input, maximumRemembered);
164}
165
166async function sessionCwd($: EngineInterface) {
167 try {
168 return await $.session.cwd();
169 } catch {
170 return '';
171 }
172}
173
174/**
175 * Registers every display tool the gateway offers that is not registered yet,
176 * then tells the gateway which are, since it only emits rows for those.
177 */
178export async function syncDisplayTools(client: RowsClient) {
179 const catalog = record(accepted(await getJson(client.wire, '/multi/mod/display-tools')));
180 const known = await client.held();
181 if (!catalog || typeof catalog.revision !== 'number' || catalog.revision === known.revision) {
182 return;
183 }
184 const names = (Array.isArray(catalog.names) ? catalog.names : [])
185 .filter((name): name is string => typeof name === 'string' && namePattern.test(name))
186 .slice(0, maximumTools)
187 .filter((name) => !known.registered.includes(name));
188 const results = await Promise.allSettled(
189 names.map((name) =>
190 client.register({
191 name,
192 description,
193 inputSchema: { type: 'object', additionalProperties: true },
194 }),
195 ),
196 );
197 // A toolless session cannot register tools; its gateway emits no rows.
198 const complete = results.every((result) => result.status === 'fulfilled');
199 const registered = [
200 ...known.registered,
201 ...names.filter((_name, index) => results[index]?.status === 'fulfilled'),
202 ];
203 // The revision advances only once the gateway confirmed the registered set; a
204 // timeout or an HTTP failure (which the client returns as no reply) is retried
205 // on the next sync, with the names registered so far kept.
206 const confirmed = await acknowledged(client, registered);
207 const revision = complete && confirmed ? (catalog.revision as number) : undefined;
208 await client.save((latest) =>
209 revision === undefined ? { ...latest, registered } : { registered, revision },
210 );
211}
212
213/** Tells the gateway which names are registered; true only for its validated reply. */
214async function acknowledged(client: RowsClient, registered: readonly string[]): Promise<boolean> {
215 if (!registered.length) {
216 return true;
217 }
218 const reply = record(
219 accepted(
220 await postJson(client.wire, '/multi/mod/display-tools', {
221 sessionId: await client.sessionId(),
222 registered,
223 }),
224 ),
225 );
226 return typeof reply?.registered === 'number';
227}
228
229/** The native output of a row, only when the gateway issued its token for this call. */
230async function issuedRow(client: RowGateway, input: unknown, toolUseId: unknown) {
231 const token = record(input)?.[tokenKey];
232 if (typeof token !== 'string' || typeof toolUseId !== 'string') {
233 return undefined;
234 }
235 const reply = record(
236 accepted(
237 await postJson(client.wire, '/multi/mod/display', {
238 sessionId: await client.sessionId(),
239 token,
240 toolUseId,
241 }),
242 ),
243 );
244 if (!reply || typeof reply.output !== 'string') {
245 return undefined;
246 }
247 return { output: reply.output, isError: reply.isError === true } satisfies Row;
248}
249
250/** The row's dot: red for a failure, grey while running or when never confirmed, else green. */
251function dotColor(
252 props: { isRunning: boolean; isErrored: boolean; isInterrupted: boolean },
253 failed: boolean,
254 unconfirmed: boolean,
255) {
256 if (props.isErrored || props.isInterrupted || failed) {
257 return 'error';
258 }
259 return props.isRunning || unconfirmed ? 'inactive' : 'success';
260}
261
262/** A row's stored result as text: the text blocks joined, reminders removed. */
263export function outputText(output: unknown): string {
264 let text = '';
265 if (typeof output === 'string') {
266 text = output;
267 } else if (Array.isArray(output)) {
268 text = output
269 .map((block) => {
270 const value = record(block)?.text;
271 return typeof value === 'string' ? value : '';
272 })
273 .join('\n');
274 }
275 return text.replaceAll(reminder, '').replaceAll('\r', '').trimEnd();
276}
277hooks/state.ts 46 lines1/**
2 * What the hooks keep across a hot reload of this module is `$.state` (declared in
3 * `types/multi-core.d.ts`): read with `read($, atom)`, changed with `update($, atom, change)`.
4 * Each hooks file names its atoms itself, since the engine's scan reads a state value's
5 * plugin and key from the file that uses it. Never mutate a value read: copy it. A
6 * `ui.render` hook that reads one is drawn again when it changes.
7 */
8const defaultLimit = 256;
9
10/** A copy of `entries` with `key` newest, the oldest dropped past `limit`. */
11export function withBounded<T>(
12 entries: Readonly<Record<string, T>>,
13 key: string,
14 value: T,
15 limit = defaultLimit,
16): Record<string, T> {
17 const { [key]: _replaced, ...others } = entries;
18 const kept = Object.entries(others).slice(-(limit - 1));
19 return Object.fromEntries([...kept, [key, value]]);
20}
21
22/** A copy of `items` with `item` newest and at most `limit` kept. */
23export function withItem(items: readonly string[], item: string, limit = defaultLimit): string[] {
24 return [...items.filter((held) => held !== item), item].slice(-limit);
25}
26
27/** Bounded like the other per-call records: the oldest entry makes room. */
28export function rememberBounded<T>(
29 entries: Map<string, T>,
30 key: string,
31 value: T,
32 limit = defaultLimit,
33) {
34 entries.delete(key);
35 const oldest = entries.keys().next();
36 if (entries.size >= limit && !oldest.done) {
37 entries.delete(oldest.value);
38 }
39 entries.set(key, value);
40}
41
42/** An object without its undefined fields, which the state's JSON data does not hold. */
43export function defined<T extends object>(value: T): T {
44 return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as T;
45}
46hooks/usage.ts 242 lines1import type { EngineInterface, Register } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3import type { MultiCoreUsagePane } from '../types/multi-core.d.ts';
4import { accepted, getJson, type Wire } from './gateway.ts';
5import { quotaAdvice } from './quota-advice.ts';
6import { withBounded } from './state.ts';
7import type { UsagePaneProps } from './usage-view.ts';
8
9// State values are named where they are read: the engine's scan reads an atom's plugin and key
10// from this file's own source, not across an import.
11const advisorySessions = atom(
12 { plugin: 'multi-core', key: 'advisorySessions' } as const,
13 [] as string[],
14);
15const usagePanes = atom(
16 { plugin: 'multi-core', key: 'usagePanes' } as const,
17 {} as Record<string, MultiCoreUsagePane>,
18);
19
20/** A provider dashboard can take a few seconds; the command's own budget is 10 s. */
21const usageTimeoutMs = 8500;
22const maximumPanes = 16;
23
24const modKeys = atom(
25 { plugin: 'multi-core', key: 'modKeys' } as const,
26 {} as Record<string, string>,
27);
28
29const wire = ($: EngineInterface): Wire => ({
30 url: () => $.env.get('MULTI_MOD_GATEWAY_URL'),
31 token: () => $.env.get('MULTI_GATEWAY_TOKEN'),
32 fetch: (url, init) => $.http.fetch(url, init),
33 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
34 keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) },
35});
36
37export const register = (on: Parameters<Register>[0], _options?: Parameters<Register>[1]) => {
38 on('classic.PreToolUse', async ($, event, next) => {
39 // `Task` is the Agent tool's legacy name.
40 const tool: string = event.tool;
41 if (tool !== 'Agent' && tool !== 'Task') {
42 return next(event);
43 }
44 const session = await $.session.id();
45 if (!(await read($, advisorySessions)).includes(session)) {
46 return next(event);
47 }
48 const snapshot = dashboard(accepted(await fetchUsage($, session)));
49 const result = await next(event);
50 // Preserve all permission decisions and tool arguments, including on lookup failure.
51 if (!(await read($, advisorySessions)).includes(session)) {
52 return result;
53 }
54 return {
55 ...result,
56 additionalContext: [...(result.additionalContext ?? []), quotaAdvice(snapshot)],
57 };
58 });
59 on('command.run', { command: 'multi-usage' }, async ($, event) => {
60 if (event.args.trim()) {
61 return { text: 'Use /multi-usage without arguments.' };
62 }
63 const session = await $.session.id();
64 const response = dashboard(accepted(await fetchUsage($, session)));
65 if (!response) {
66 return { text: 'Multi usage is unavailable. Launch this session with claude-multi.' };
67 }
68 const quotaAdviceEnabled = (await read($, advisorySessions)).includes(session);
69 // The pane draws from this state: writing it redraws an open pane without an invalidate.
70 await update($, usagePanes, (held) =>
71 withBounded<MultiCoreUsagePane>(
72 held,
73 session,
74 { ...response, quotaAdviceEnabled },
75 maximumPanes,
76 ),
77 );
78 const summary = response.providers
79 .map((provider) => `${provider.name}: ${provider.summary}`)
80 .join('\n');
81 try {
82 const opened = await $.ui.open({
83 id: 'multi-usage',
84 title: 'Multi usage',
85 focus: true,
86 closeOnEscape: true,
87 rows: 20,
88 });
89 if (opened.isPlaced) {
90 return {};
91 }
92 // The pane waits undrawn (a surface that places no panes): say so, and show the numbers.
93 $.ui.toast(`Multi usage: ${opened.reason}`);
94 return { text: summary };
95 } catch {
96 return { text: summary };
97 }
98 });
99 on('ui.render', { component: 'Pane' }, async ($, event, next) => {
100 if (event.requestId !== 'multi-usage' || event.surface !== 'terminal') {
101 return next(event);
102 }
103 const props = (await read($, usagePanes))[await $.session.id()];
104 if (!props) {
105 return next(event);
106 }
107 const { Client } = $.ui.resolve(event);
108 return Client({ key: 'usage', module: './usage-view.ts', props, width: '100%', flexGrow: 1 });
109 });
110 on('ui.message', { element: 'usage' }, async ($, event, next) => {
111 if (event.requestId !== 'multi-usage' || event.element !== 'usage' || !record(event.data)) {
112 return next(event);
113 }
114 const action = event.data.action;
115 if (action !== 'refresh' && action !== 'receipts' && action !== 'toggle-quota-advice') {
116 return next(event);
117 }
118 const session = await $.session.id();
119 const previous = (await read($, usagePanes))[session];
120 if (!previous) {
121 return next(event);
122 }
123 if (action === 'toggle-quota-advice') {
124 const enabled = !(await read($, advisorySessions)).includes(session);
125 await update($, advisorySessions, (held) =>
126 enabled ? [...held, session] : held.filter((id) => id !== session),
127 );
128 const props = { ...previous, quotaAdviceEnabled: enabled };
129 await update($, usagePanes, (held) =>
130 withBounded<MultiCoreUsagePane>(held, session, props, maximumPanes),
131 );
132 return { props };
133 }
134 const response =
135 action === 'refresh'
136 ? await fetchUsage($, session, true)
137 : await getJson(
138 wire($),
139 '/multi/mod/receipts',
140 { sessionId: session },
141 { timeoutMs: usageTimeoutMs },
142 );
143 const props = updatePane(previous, action, accepted(response));
144 await update($, usagePanes, (held) =>
145 withBounded<MultiCoreUsagePane>(held, session, props, maximumPanes),
146 );
147 return { props };
148 });
149};
150
151function fetchUsage($: EngineInterface, session: string, refresh = false) {
152 const query: Record<string, string> = { sessionId: session, view: 'providers' };
153 if (refresh) {
154 query.refresh = 'true';
155 }
156 return getJson(wire($), '/multi/mod/usage', query, { timeoutMs: usageTimeoutMs });
157}
158
159function record(value: unknown): value is Record<string, unknown> {
160 return value !== null && typeof value === 'object' && !Array.isArray(value);
161}
162function dashboard(value: unknown): UsagePaneProps | undefined {
163 if (!record(value) || typeof value.updatedAt !== 'string' || !Array.isArray(value.providers)) {
164 return undefined;
165 }
166 if (
167 !value.providers.every(
168 (item) =>
169 record(item) &&
170 typeof item.id === 'string' &&
171 typeof item.name === 'string' &&
172 typeof item.status === 'string' &&
173 typeof item.summary === 'string' &&
174 Array.isArray(item.details) &&
175 item.details.every((line) => typeof line === 'string'),
176 )
177 ) {
178 return undefined;
179 }
180 return value as UsagePaneProps;
181}
182/** Props are plain data: a field without a value is left out, never set to undefined. */
183function updatePane(previous: UsagePaneProps, action: string, response: unknown): UsagePaneProps {
184 const { error: _error, ...kept } = previous;
185 if (action === 'refresh') {
186 const refreshed = dashboard(response);
187 return refreshed
188 ? {
189 ...refreshed,
190 ...(previous.receiptLines ? { receiptLines: previous.receiptLines } : {}),
191 ...(previous.quotaAdviceEnabled === undefined
192 ? {}
193 : { quotaAdviceEnabled: previous.quotaAdviceEnabled }),
194 }
195 : { ...previous, error: 'Refresh failed. Showing the previous values.' };
196 }
197 if (!record(response) || !Array.isArray(response.receipts)) {
198 return { ...previous, error: 'Could not load receipts.' };
199 }
200 return {
201 ...kept,
202 receiptLines: response.receipts.slice(-20).reverse().flatMap(receiptLines),
203 };
204}
205function receiptLines(value: unknown): string[] {
206 if (!record(value) || !record(value.usage)) {
207 return [];
208 }
209 const owner = typeof value.agentId === 'string' ? value.agentId : 'Main turn';
210 return [
211 `${owner} · ${String(value.outcome)}${value.incomplete ? ' (incomplete)' : ''} · ${String(value.time)}`,
212 ` ${receiptUsage(value.usage, value.requests, value.context)}`,
213 ...(Array.isArray(value.entries) ? value.entries.flatMap(receiptEntry) : []),
214 ];
215}
216/**
217 * Context and consumption side by side: a harness that resends its whole context
218 * on every model call without a cache reads as a small context and a large spend.
219 */
220function receiptUsage(usage: Record<string, unknown>, requests: unknown, context: unknown) {
221 const count = (value: unknown) =>
222 typeof value === 'number' ? value.toLocaleString('en-US') : '0';
223 const calls =
224 typeof usage.model_calls === 'number' ? ` · ${count(usage.model_calls)} model calls` : '';
225 const window = record(context)
226 ? `context ${count(
227 [context.input_tokens, context.cache_read_input_tokens, context.cache_creation_input_tokens]
228 .filter((item) => typeof item === 'number')
229 .reduce((sum, item) => sum + item, 0),
230 )} · `
231 : '';
232 return `${String(requests)} requests${calls} · ${window}consumed ${count(usage.input_tokens)} input · cached ${count(usage.cache_read_input_tokens)} · ${count(usage.output_tokens)} output`;
233}
234function receiptEntry(value: unknown): string[] {
235 if (!record(value)) {
236 return [];
237 }
238 return [
239 ` ${String(value.provider)} · ${String(value.model ?? 'model unreported')} · effort ${String(value.effort ?? 'unreported')} · ${String(value.endpoint ?? 'endpoint unreported')} · ${String(value.source)}`,
240 ];
241}
242hooks/workers.ts 337 lines1import type { AgentSpawnInput, EngineInterface, Register } from 'claude-code';
2import { atom, read, update } from 'claude-code';
3import type { MultiCoreDisplayTools, MultiCorePolicy } from '../types/multi-core.d.ts';
4import {
5 accepted,
6 type GatewayResponse,
7 getJson,
8 isActive,
9 postJson,
10 type Wire,
11} from './gateway.ts';
12import { ensureHarnessPolicy, policyClient } from './policy.ts';
13import { isHarnessModel, isMultiModel } from './provider.ts';
14import { type RowsClient, syncDisplayTools } from './rows.ts';
15import { defined, rememberBounded, withBounded, withItem } from './state.ts';
16import { isProviderWorker, labelled, register as registerWorkerRows } from './worker-rows.ts';
17
18// State values are named where they are read: the engine's scan reads an atom's plugin and key
19// from this file's own source, not across an import.
20const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy);
21const agentModels = atom(
22 { plugin: 'multi-core', key: 'agentModels' } as const,
23 {} as Record<string, string>,
24);
25const spawnModels = atom(
26 { plugin: 'multi-core', key: 'spawnModels' } as const,
27 {} as Record<string, string>,
28);
29const claudeTypes = atom({ plugin: 'multi-core', key: 'claudeTypes' } as const, [] as string[]);
30const offeredProviders = atom(
31 { plugin: 'multi-core', key: 'offeredProviders' } as const,
32 [] as string[],
33);
34const displayTools = atom(
35 { plugin: 'multi-core', key: 'displayTools' } as const,
36 { registered: [] } as MultiCoreDisplayTools,
37);
38
39const rowsClient = ($: EngineInterface): RowsClient => ({
40 wire: wire($),
41 sessionId: () => $.session.id(),
42 held: () => read($, displayTools),
43 save: (change) => update($, displayTools, change),
44 register: (tool) => $.tool.register(tool),
45});
46
47const modKeys = atom(
48 { plugin: 'multi-core', key: 'modKeys' } as const,
49 {} as Record<string, string>,
50);
51
52const wire = ($: EngineInterface): Wire => ({
53 url: () => $.env.get('MULTI_MOD_GATEWAY_URL'),
54 token: () => $.env.get('MULTI_GATEWAY_TOKEN'),
55 fetch: (url, init) => $.http.fetch(url, init),
56 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
57 keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) },
58});
59
60const issues =
61 'https://github.com/greenpolo/cc-multi-cli-plugin/issues/new?template=bug_report.yml';
62
63/**
64 * A refusal the agent reads, with the invitation to report it.
65 *
66 * Most refusals are the plugin working: a mode the user chose, a worker their
67 * settings do not offer. The invitation is conditional so an ordinary denial
68 * does not become an issue, and it never files anything on the user's behalf.
69 */
70function reportable(reason: string) {
71 return `${reason}\n\nIf this reads like a defect in the multi-cli plugin rather than a permission the user chose, tell them so and offer to open an issue at ${issues}, quoting the reason above.`;
72}
73
74type SpawnEvent = AgentSpawnInput;
75
76/**
77 * The model an Agent call named for a provider worker, by tool_use_id. The Agent tool's
78 * schema only admits Claude aliases, so `tool.call` (which runs before the engine checks
79 * the input) takes the value out and `agent.spawn` resolves it against the catalog. It
80 * lives from that `tool.call` to its `agent.spawn`, inside one call, so it is not state.
81 */
82const requestedModels = new Map<string, string>();
83
84const providerGuidance =
85 "\n\nFor a multi-* agent type (multi-cursor, multi-openai, ...), `model` names one of that provider's models from the type's description, not a Claude alias; omit it for the provider's default.";
86
87export const register = (on: Parameters<Register>[0], _options: Parameters<Register>[1]) => {
88 registerWorkerRows(on);
89 on('tool.describe', { tool: 'Agent' }, async ($, event, next) => {
90 const described = await next(event);
91 // The paragraph is for the provider types the model is offered; none, no paragraph.
92 if (!(await isActive(wire($))) || !(await read($, offeredProviders)).length) {
93 return described;
94 }
95 return { ...described, description: `${described.description}${providerGuidance}` };
96 });
97 on('tool.call', { tool: 'Agent' }, async ($, event, next) => {
98 if (event.tool !== 'Agent' || event.model === undefined) {
99 return next(event);
100 }
101 if (!isProviderWorker(event.subagent_type) || !(await isActive(wire($)))) {
102 return next(event);
103 }
104 rememberBounded(requestedModels, event.tool_use_id, String(event.model));
105 const { model: _named, ...call } = event;
106 try {
107 return await next(call);
108 } finally {
109 requestedModels.delete(event.tool_use_id);
110 }
111 });
112 on('agent.offer', async ($, event, next) => {
113 if (!(await isActive(wire($)))) {
114 return next(event);
115 }
116 const parentModel = await $.session.model();
117 // A built-in agent on a Claude session is Claude's own: no gateway call, ever.
118 if (
119 event.source === 'built-in' &&
120 !isMultiModel(parentModel) &&
121 !isProviderWorker(event.agent)
122 ) {
123 await classify($, event.agent, true);
124 return next(event);
125 }
126 const response = await postJson(wire($), '/multi/mod/offer', {
127 sessionId: await $.session.id(),
128 cwd: await $.session.cwd(),
129 agent: event.agent,
130 parentModel,
131 });
132 await classify($, event.agent, nativeClaude(response));
133 // Claude owns its own catalog. Only a positively identified harness worker
134 // is subject to Multi's settings-translation compatibility filter.
135 const hidden = response?.execution === 'harness' && response.isOffered === false;
136 const result = hidden ? { isOffered: false } : await next(event);
137 await markOffered($, event.agent, result.isOffered);
138 return result;
139 });
140 on('classic.SubagentStart', async ($, event, next) => {
141 if (!(await isActive(wire($)))) {
142 return next(event);
143 }
144 // A native Claude subagent in a Claude session needs no gateway record. The
145 // subagent's own model and its parent's are not on this event, so any loop known to
146 // run a Multi model (an inheriting child of a provider worker) keeps the record.
147 if (
148 (await read($, claudeTypes)).includes(event.agent_type) &&
149 !isProviderWorker(event.agent_type) &&
150 !isMultiModel(await $.session.model()) &&
151 !Object.values(await read($, agentModels)).some(isMultiModel)
152 ) {
153 return next(event);
154 }
155 const response = await postJson(wire($), '/multi/mod/worker', {
156 sessionId: event.session_id,
157 agentId: event.agent_id,
158 subagentType: event.agent_type,
159 cwd: event.cwd,
160 });
161 const model = response?.accepted ? response.model : undefined;
162 if (model) {
163 await update($, agentModels, (held) => withBounded(held, event.agent_id, model));
164 }
165 // Registration is observational for Claude-loop workers. An unregistered
166 // harness worker still cannot dispatch: resolveHarness rejects its scope.
167 return next(event);
168 });
169 on('agent.spawn', async ($, event, next) => {
170 if (!(await isActive(wire($)))) {
171 return next(event);
172 }
173 // A native Claude subagent runs on Claude Code's own path: no gateway call.
174 if (nativeClaudeSpawn(event, await read($, claudeTypes))) {
175 return next(event);
176 }
177 const resolved = await providerSpawn($, event);
178 if ('deny' in resolved) {
179 return { deny: resolved.deny };
180 }
181 const spawn = resolved.event;
182 const resolvedModel = spawn.model;
183 if (resolvedModel && spawn !== event) {
184 // Written to state, so the Agent row and the task notification draw the model.
185 await update($, spawnModels, (held) => withBounded(held, event.tool_use_id, resolvedModel));
186 }
187 const denial = await admit($, spawn, resolved.selection);
188 if (denial) {
189 return { deny: denial };
190 }
191 const result = await next(spawn);
192 const { agentId, model } = result;
193 if (agentId && model) {
194 await update($, agentModels, (held) => withBounded(held, agentId, model));
195 }
196 return result;
197 });
198};
199
200/** Truly native: the gateway classifies the type as Claude-loop and its model is not a Multi one. */
201function nativeClaude(response: GatewayResponse | undefined): boolean {
202 return response?.execution === 'claude' && !isMultiModel(response.model);
203}
204
205/** Remembers whether an agent type is a native Claude subagent, changing state only on a change. */
206async function classify($: EngineInterface, agent: string, isNative: boolean) {
207 if ((await read($, claudeTypes)).includes(agent) === isNative) {
208 return;
209 }
210 await update($, claudeTypes, (held) =>
211 isNative ? withItem(held, agent) : held.filter((type) => type !== agent),
212 );
213}
214
215/** Tracks the provider worker types the model is offered, for the Agent tool's description. */
216async function markOffered($: EngineInterface, agent: string, isOffered: boolean) {
217 if (!isProviderWorker(agent) || (await read($, offeredProviders)).includes(agent) === isOffered) {
218 return;
219 }
220 await update($, offeredProviders, (held) =>
221 isOffered ? withItem(held, agent) : held.filter((type) => type !== agent),
222 );
223 $.ui.invalidate('tool.describe');
224}
225
226async function spawnPayload($: EngineInterface, event: SpawnEvent) {
227 return {
228 sessionId: await $.session.id(),
229 parentAgentId: event.parentAgentId,
230 permissionMode: event.permissionMode,
231 subagentType: event.subagentType,
232 cwd: event.cwd ?? (await $.session.cwd()),
233 model: event.model,
234 parentModel: event.parentModel,
235 fork: event.fork,
236 background: event.background,
237 };
238}
239
240/**
241 * Resolve a provider worker's model: the one its Agent call named, else the provider's
242 * default. An unknown or other provider's model refuses the spawn with the gateway's
243 * reason, which names the provider's models. Every other spawn keeps its own model.
244 */
245async function providerSpawn(
246 $: EngineInterface,
247 event: SpawnEvent,
248): Promise<{ deny: string } | { event: SpawnEvent; selection: GatewayResponse | undefined }> {
249 const provider = isProviderWorker(event.subagentType) && !event.fork;
250 const named = provider ? (requestedModels.get(event.tool_use_id) ?? event.model) : event.model;
251 const payload = { ...(await spawnPayload($, event)), model: named };
252 const selection = await postJson(wire($), '/multi/mod/worker-model', payload);
253 if (!provider) {
254 return { event, selection };
255 }
256 if (!selection) {
257 return {
258 deny: reportable(`The Multi gateway did not resolve the ${event.subagentType} model.`),
259 };
260 }
261 if (selection.refused || !selection.model) {
262 return { deny: selection.error ?? `The ${event.subagentType} model was not resolved.` };
263 }
264 // The task's description is what the running-agents list and its notification show.
265 const description = labelled(event.description, selection.model);
266 return { event: { ...event, model: selection.model, description }, selection };
267}
268
269/** Admit a harness spawn against the current policy generation; undefined admits it. */
270async function admit(
271 $: EngineInterface,
272 event: SpawnEvent,
273 selection: GatewayResponse | undefined,
274): Promise<string | undefined> {
275 const payload = await spawnPayload($, event);
276 if (!harnessSpawn(event, selection)) {
277 // Keep context for a possible later harness child, but never veto the
278 // engine's native worker because Multi could not reconstruct its policy.
279 await postJson(wire($), '/multi/mod/worker', payload);
280 return undefined;
281 }
282 await prepareHarness($);
283 // The worker's rows anchor in its own transcript only once their tools exist.
284 await syncDisplayTools(rowsClient($));
285 const mode = accepted(
286 await getJson(wire($), '/multi/mod/mode', { sessionId: payload.sessionId }),
287 );
288 const response = await postJson(wire($), '/multi/mod/worker', {
289 ...payload,
290 generation: mode?.generation,
291 });
292 return response?.accepted
293 ? undefined
294 : reportable(response?.error ?? 'Multi harness worker policy was not acknowledged.');
295}
296
297/**
298 * A Claude subagent: not a `multi-*` worker type, classified as native Claude (a Claude
299 * model, not a provider model its definition pins), and neither its model nor its
300 * parent's (which a fork or an inheriting subagent runs on) is a Multi model.
301 */
302function nativeClaudeSpawn(event: SpawnEvent, claudeTypeNames: readonly string[]): boolean {
303 return (
304 claudeTypeNames.includes(event.subagentType) &&
305 !isProviderWorker(event.subagentType) &&
306 !isMultiModel(event.model) &&
307 !isMultiModel(event.parentModel)
308 );
309}
310
311function harnessSpawn(
312 event: { fork?: boolean; model?: string; parentModel?: string },
313 selection: GatewayResponse | undefined,
314): boolean {
315 const inferred = event.fork ? event.parentModel : (event.model ?? event.parentModel);
316 return selection?.execution === 'harness' || (!selection?.known && isHarnessModel(inferred));
317}
318
319/**
320 * Admits the prompt's harness policy once for every helper it spawns. The snapshot is
321 * state, so the admission outlives a reload; concurrent helpers share one admission in
322 * `policy.ts`.
323 */
324async function prepareHarness($: EngineInterface) {
325 const held = await read($, policy);
326 if (!held.prompt || held.harnessReady) {
327 return;
328 }
329 const admitted: MultiCorePolicy = { ...held };
330 await ensureHarnessPolicy(policyClient(wire($), await $.session.model()), admitted);
331 await update($, policy, (latest) =>
332 JSON.stringify(latest.prompt) === JSON.stringify(held.prompt)
333 ? defined({ ...latest, generation: admitted.generation, harnessReady: admitted.harnessReady })
334 : latest,
335 );
336}
337hooks/rows-view.ts 380 lines1import type { ElementTable, RenderElement } from 'claude-code';
2
3/**
4 * The drawing of a native harness action's row, as Claude Code draws the
5 * built-in tool the action mirrors (`kind`, set by the gateway's `mirroredInput`).
6 *
7 * The engine draws a registered tool as `probe - view_file (MCP)(file_path: "a")`
8 * and would draw a mirrored input the same way, so the header and the summary
9 * results are built here, cell for cell after the built-ins as this build draws
10 * them in the terminal: a `●` in the theme's `success` (or `error`) colour (`inactive`
11 * for an action the native run never confirmed), the
12 * bold tool name, the argument in parentheses in the text colour, and under it
13 * ` ⎿ ` in `inactive` with the result beside it. Only the name differs: the
14 * native tool's (`view_file`), not the built-in's (`Read`).
15 */
16type Kind = 'Read' | 'Bash' | 'Grep' | 'Glob' | 'LS' | 'Edit' | 'Write';
17type Tags = Pick<ElementTable, 'Box' | 'Text'>;
18type Input = Record<string, unknown>;
19type Part = string | { bold: string };
20type DiffLine = { mark: ' ' | '-' | '+'; text: string; number?: number };
21type Hunk = { old: number; new: number };
22
23const kinds = new Set<string>(['Read', 'Bash', 'Grep', 'Glob', 'LS', 'Edit', 'Write']);
24const argumentLength = 160;
25const previewLines = 10;
26const diffLines = 40;
27const errorLines = 10;
28const tokenKey = 'multi_row';
29const summaryKeys: readonly RegExp[] = [
30 /^command(line)?$|^cmd$/i,
31 /path|file|directory|^dir$/i,
32 /pattern|query|glob|url|search/i,
33];
34
35function mirroredKind(input: Input | undefined): Kind | undefined {
36 const kind = input?.kind;
37 return typeof kind === 'string' && kinds.has(kind) ? (kind as Kind) : undefined;
38}
39
40function text(input: Input, key: string): string {
41 const value = input[key];
42 return typeof value === 'string' ? value : '';
43}
44
45function count(value: number, unit: string): Part[] {
46 return [{ bold: String(value) }, ` ${unit}${value === 1 ? '' : 's'}`];
47}
48
49function oneLine(value: string) {
50 const line = value.replaceAll(/\s+/g, ' ').trim();
51 return line.length > argumentLength ? `${line.slice(0, argumentLength)}…` : line;
52}
53
54/** A path as the built-ins show it: relative to the session's directory when inside it. */
55function shownPath(path: string, cwd: string): string {
56 if (cwd && path === cwd) {
57 return '.';
58 }
59 const root = cwd.endsWith('/') ? cwd : `${cwd}/`;
60 return cwd && path.startsWith(root) ? path.slice(root.length) : path;
61}
62
63function readRange(input: Input) {
64 const offset = typeof input.offset === 'number' ? input.offset : undefined;
65 const limit = typeof input.limit === 'number' ? input.limit : undefined;
66 if (limit !== undefined) {
67 const start = offset ?? 1;
68 return ` · lines ${start}-${start + limit - 1}`;
69 }
70 return offset === undefined ? '' : ` · from line ${offset}`;
71}
72
73function quoted(input: Input, keys: readonly string[], cwd: string) {
74 return keys
75 .filter((key) => text(input, key))
76 .map((key) => {
77 const value = text(input, key);
78 return `${key}: "${oneLine(key === 'path' ? shownPath(value, cwd) : value)}"`;
79 })
80 .join(', ');
81}
82
83/** The argument a built-in shows in its header: `Read(src/a.ts)`, `Bash(echo hi)`, ... */
84export function headerSummary(input: Input, cwd: string): string {
85 switch (mirroredKind(input)) {
86 case 'Read':
87 return `${shownPath(text(input, 'file_path'), cwd)}${readRange(input)}`;
88 case 'Bash':
89 return oneLine(text(input, 'command'));
90 case 'Grep':
91 return quoted(input, ['pattern', 'path', 'glob'], cwd);
92 case 'Glob':
93 return quoted(input, ['pattern', 'path'], cwd);
94 case 'LS':
95 return shownPath(text(input, 'path'), cwd);
96 case 'Edit':
97 case 'Write':
98 return shownPath(text(input, 'file_path'), cwd);
99 default:
100 return argumentSummary(input.native ?? input);
101 }
102}
103
104/** The native path, command or pattern of an input with no mirrored built-in, on one line. */
105function argumentSummary(input: unknown): string {
106 const values = Object.entries(record(input) ?? {}).filter(
107 (entry): entry is [string, string] =>
108 entry[0] !== tokenKey && typeof entry[1] === 'string' && entry[1].trim() !== '',
109 );
110 for (const pattern of summaryKeys) {
111 const found = values.find(([key]) => pattern.test(key));
112 if (found) {
113 return oneLine(found[1]);
114 }
115 }
116 return values.length ? oneLine(values[0]?.[1] ?? '') : '';
117}
118
119/** `● name(argument)`, as the transcript draws a built-in tool's call. */
120export function toolHeader(
121 { Box, Text }: Tags,
122 row: { name: string; summary: string; color: string },
123): RenderElement {
124 return Box({
125 flexDirection: 'row',
126 children: [
127 Box({ minWidth: 2, children: Text({ color: row.color, children: '●' }) }),
128 Text({ bold: true, children: row.name }),
129 ...(row.summary ? [Text({ children: `(${row.summary})` })] : []),
130 ],
131 });
132}
133
134/**
135 * ` ⎿ ` and a no-break space (as the engine draws it) with the result beside it,
136 * continuation lines under the result.
137 */
138export function response({ Box, Text }: Tags, lines: RenderElement[]): RenderElement {
139 return Box({
140 flexDirection: 'row',
141 children: [
142 Box({ minWidth: 5, children: Text({ color: 'inactive', children: ' ⎿ \u00a0' }) }),
143 Box({ flexDirection: 'column', flexGrow: 1, children: lines }),
144 ],
145 });
146}
147
148function sentence({ Text }: Tags, parts: readonly Part[]): RenderElement {
149 return Text({
150 children: parts.map((part) =>
151 typeof part === 'string' ? part : Text({ bold: true, children: part.bold }),
152 ),
153 });
154}
155
156function more({ Text }: Tags, hidden: number): RenderElement[] {
157 return hidden > 0 ? [Text({ dimColor: true, children: `… +${hidden} lines` })] : [];
158}
159
160/** A text's lines, without the empty one after a final newline. */
161function textLines(value: string): string[] {
162 const lines = value.split('\n');
163 if (lines.length > 1 && lines.at(-1) === '') {
164 lines.pop();
165 }
166 return value === '' ? [] : lines;
167}
168
169/** How many lines a read returned: `agy` reports `2 lines, 6 bytes`, Cursor the content. */
170function readCount(output: string): number {
171 const reported = /^(\d+) lines?\b/.exec(output.trim());
172 return reported ? Number(reported[1]) : textLines(output).length;
173}
174
175/** How many results a search listed, a trailing `… N more` counted in. */
176function foundCount(output: string): number {
177 if (/^no (results|matches|files)\b/i.test(output.trim())) {
178 return 0;
179 }
180 const lines = textLines(output).filter((line) => line.trim() !== '');
181 const hidden = /^… (\d+) more$/.exec(lines.at(-1) ?? '');
182 return hidden ? lines.length - 1 + Number(hidden[1]) : lines.length;
183}
184
185function numbered(tags: Tags, number: string, line: string): RenderElement {
186 const { Box, Text } = tags;
187 return Box({
188 flexDirection: 'row',
189 children: [Text({ dimColor: true, children: `${number} ` }), Text({ children: line })],
190 });
191}
192
193function writeBody(tags: Tags, input: Input, cwd: string): RenderElement[] {
194 const lines = textLines(text(input, 'content'));
195 const width = String(lines.length).length + 1;
196 const shown = lines.slice(0, previewLines);
197 return [
198 sentence(tags, [
199 'Wrote ',
200 ...count(lines.length, 'line'),
201 ' to ',
202 { bold: shownPath(text(input, 'file_path'), cwd) },
203 ]),
204 ...shown.map((line, index) => numbered(tags, String(index + 1).padStart(width), line)),
205 ...more(tags, lines.length - shown.length),
206 ];
207}
208
209function hunkStart(line: string): Hunk | undefined {
210 const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
211 return match ? { old: Number(match[1]), new: Number(match[2]) } : undefined;
212}
213
214function hunkLine(line: string, at: Hunk): DiffLine | undefined {
215 const mark = line[0] ?? ' ';
216 if (mark === '-') {
217 return { mark, text: line.slice(1), number: at.old++ };
218 }
219 if (mark === '+') {
220 return { mark, text: line.slice(1), number: at.new++ };
221 }
222 if (mark !== ' ' && line !== '') {
223 return undefined;
224 }
225 at.old++;
226 return { mark: ' ', text: line.slice(1), number: at.new++ };
227}
228
229/** The lines of a unified diff's hunks, numbered as the file's lines; undefined without one. */
230function unifiedDiff(value: string): DiffLine[] | undefined {
231 const lines: DiffLine[] = [];
232 let at: Hunk | undefined;
233 for (const line of textLines(value)) {
234 const start = hunkStart(line);
235 if (start) {
236 at = start;
237 } else if (at) {
238 const parsed = hunkLine(line, at);
239 if (parsed) {
240 lines.push(parsed);
241 }
242 }
243 }
244 return at ? lines : undefined;
245}
246
247function pairDiff(input: Input): DiffLine[] {
248 const start = typeof input.offset === 'number' ? input.offset : undefined;
249 const side = (key: string, mark: '-' | '+'): DiffLine[] =>
250 textLines(text(input, key)).map((line, index) => ({
251 mark,
252 text: line,
253 ...(start === undefined ? {} : { number: start + index }),
254 }));
255 return [...side('old_string', '-'), ...side('new_string', '+')];
256}
257
258function diffRow(tags: Tags, line: DiffLine, width: number): RenderElement {
259 const { Box, Text } = tags;
260 const number = width ? `${String(line.number ?? '').padStart(width)} ` : '';
261 if (line.mark === ' ') {
262 return numbered(tags, number.trimEnd(), ` ${line.text}`);
263 }
264 const background = line.mark === '-' ? 'diffRemoved' : 'diffAdded';
265 return Box({
266 flexDirection: 'row',
267 children: [
268 Text({
269 backgroundColor: background,
270 color: line.mark === '-' ? 'diffRemovedWord' : 'diffAddedWord',
271 children: `${number}${line.mark}`,
272 }),
273 Text({ backgroundColor: background, color: 'text', children: line.text }),
274 ],
275 });
276}
277
278function editSummary(added: number, removed: number): Part[] {
279 const parts: Part[] = added ? ['Added ', ...count(added, 'line')] : [];
280 if (removed) {
281 parts.push(added ? ', removed ' : 'Removed ', ...count(removed, 'line'));
282 }
283 return parts;
284}
285
286/**
287 * The counts an edit reported without a diff (Cursor's `+1 -1 lines` when its
288 * result carries `linesAdded`/`linesRemoved` and no `diffString`).
289 */
290function reportedCounts(output: string) {
291 const match = /^(?:\+(\d+))? ?(?:-(\d+))? lines$/.exec(output.trim());
292 return match ? { added: Number(match[1] ?? 0), removed: Number(match[2] ?? 0) } : undefined;
293}
294
295function editBody(tags: Tags, input: Input, output: string): RenderElement[] | undefined {
296 const lines = unifiedDiff(output) ?? pairDiff(input);
297 const counted = lines.length ? undefined : reportedCounts(output);
298 const summary = counted
299 ? editSummary(counted.added, counted.removed)
300 : editSummary(
301 lines.filter((line) => line.mark === '+').length,
302 lines.filter((line) => line.mark === '-').length,
303 );
304 if (!summary.length) {
305 return undefined;
306 }
307 const numbers = lines.map((line) => line.number ?? 0);
308 const width = numbers.some(Boolean) ? String(Math.max(...numbers)).length + 1 : 0;
309 const shown = lines.slice(0, diffLines);
310 return [
311 sentence(tags, summary),
312 ...shown.map((line) => diffRow(tags, line, width)),
313 ...more(tags, lines.length - shown.length),
314 ];
315}
316
317function searchUnit(output: string) {
318 return textLines(output).some((line) => /^[^:\n]+:\d+:/.test(line)) ? 'line' : 'file';
319}
320
321/**
322 * The result lines of a successful mirrored row, as its built-in draws them in
323 * both the compact and the ctrl+o view (`Read 40 lines`, `Found 3 files`, a
324 * write's preview, an edit's diff); undefined where the built-in shows the output
325 * itself, which the engine then draws with its own compact and ctrl+o forms.
326 */
327export function resultBody(
328 tags: Tags,
329 input: Input,
330 output: string,
331 cwd: string,
332): RenderElement[] | undefined {
333 switch (mirroredKind(input)) {
334 case 'Read':
335 return [sentence(tags, ['Read ', ...count(readCount(output), 'line')])];
336 case 'Grep':
337 return [sentence(tags, ['Found ', ...count(foundCount(output), searchUnit(output))])];
338 case 'Glob':
339 return [sentence(tags, ['Found ', ...count(foundCount(output), 'file')])];
340 case 'LS':
341 return [sentence(tags, ['Listed ', ...count(foundCount(output), 'path')])];
342 case 'Write':
343 return text(input, 'content') ? writeBody(tags, input, cwd) : undefined;
344 case 'Edit':
345 return editBody(tags, input, output);
346 default:
347 return undefined;
348 }
349}
350
351/** A failed action's result: `Error: ...` in the theme's error colour, as the built-ins. */
352export function errorBody(tags: Tags, output: string): RenderElement[] {
353 const lines = textLines(/^error\b/i.test(output) ? output : `Error: ${output}`);
354 const shown = lines.slice(0, errorLines);
355 return [
356 ...shown.map((line) => tags.Text({ color: 'error', children: line })),
357 ...more(tags, lines.length - shown.length),
358 ];
359}
360
361/**
362 * An action the native run never confirmed: neither a success nor a failure, so
363 * it is drawn in the theme's `inactive` tone and says so.
364 */
365export function unconfirmedBody(tags: Tags, output: string): RenderElement[] {
366 const detail = output.trim() || 'the native run reported no completion for this action.';
367 const lines = textLines(`Unconfirmed: ${detail.charAt(0).toLowerCase()}${detail.slice(1)}`);
368 const shown = lines.slice(0, errorLines);
369 return [
370 ...shown.map((line) => tags.Text({ color: 'inactive', children: line })),
371 ...more(tags, lines.length - shown.length),
372 ];
373}
374
375export function record(value: unknown): Record<string, unknown> | undefined {
376 return typeof value === 'object' && value !== null && !Array.isArray(value)
377 ? (value as Record<string, unknown>)
378 : undefined;
379}
380