SLOPSHOPPER

multi-core

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

newpanerowsguardcommandtoast
★ 186v?Apache-2.0updated 2026-10-05greenpolo/cc-multi-cli-plugin/plugins/multi-core
A shopper browsing a rack in a slop shop
README

multi-cli — plugin for claude code

cc-multi-cli-plugin

One Claude Code session. Your models. Their native tools.

CI License: Apache 2.0 Latest release Built for Claude Code Node 24.12+ Linux · macOS · Windows Stars

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 Fable 5.1 coordinating GPT-5.6 Luna, Grok 4.6, and Gemini 3.8 Flash workers

Illustration of the multi-provider workflow, edited from a live Luna terminal capture; not a recording of a mixed-provider run. Asset provenance.

Why Multi?

  • Choose your model in place. Switch through /model and select supported reasoning effort with /effort.
  • Delegate to provider workers. Run a provider's own model from the Agent tool (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.
  • Keep native execution. OpenAI and Zen use Claude Code's tools; Cursor, Antigravity and Grok run their own SDK or CLI tools.
  • Carry your session forward. Resume saved sessions while keeping provider credentials and native state separate.
  • Stay in control. Claude's permission mode and explicit tool restrictions govern provider dispatch.

Install

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:

Providers

PluginCommandWhat it gives you
multi-openai/multi-openai:loginChatGPT models through Codex
multi-cursor/multi-cursor:loginOfficial Cursor SDK models and workers
multi-zen/multi-zen:connectOpenCode Zen models with an API key
multi-antigravity/multi-antigravity:connectAntigravity models and workers through agy
multi-grok/multi-grok:loginGrok 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.

Use

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.

Platforms

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.

Documentation

Start hereLearn more
Installation and account setupPermissions and review
OpenAI · CursorArchitecture and execution flow
Claude Mods referenceRequired integration boundary for Claude Code UI and extensibility
OpenCode Zen · Antigravity · GrokPlatform 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.

Contributing

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.

License

Apache 2.0. See NOTICE for upstream credits.

Source 15 files
hooks/register.ts 201 lines
1import 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}
201
types/multi-core.d.ts 56 lines
1// 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}
56
hooks/compact.ts 96 lines
1import 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}
96
hooks/gateway.ts 213 lines
1import 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}
213
hooks/lifecycle.ts 226 lines
1import 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}
226
hooks/policy.ts 173 lines
1import 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}
173
hooks/provider.ts 14 lines
1/** 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}
14
hooks/rows.ts 277 lines
1import 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}
277
hooks/state.ts 46 lines
1/**
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}
46
hooks/usage.ts 242 lines
1import 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}
242
hooks/workers.ts 337 lines
1import 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}
337
hooks/rows-view.ts 380 lines
1import 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