Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@ Entries record changes when they were made, including superseded decisions.
See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and
[README.md](README.md) for current capabilities.

## Unreleased

- **Stop the gateway from altering native Claude requests.** Claude requests now
reach Anthropic as Claude Code sent them, and Anthropic's answer or failure
reaches Claude Code as it would natively. A tool call cut off by `max_tokens`
no longer ends the stream with an injected "Unexpected end of JSON input"
error: tool observation is best effort and stops quietly when it cannot parse,
while the stream passes through unchanged and uncapped. A connection failure
now drops the connection (so Claude Code's own retry runs) instead of
becoming a "Native gateway: fetch failed" 502 or an injected stream error.
Every `/v1/*` request other than Messages, such as `/v1/files` and
`/v1/files/{id}/content`, is forwarded to Anthropic as raw bytes with the
caller's own headers; it no longer needs the gateway token, which Claude Code
does not send to those routes, and adds no credential of its own.
- **Keep Claude's native request timeout and retries.** Multi set `API_TIMEOUT_MS`
to its maximum so long native runs would not time out, which also meant a stuck
Anthropic connection never timed out or retried. Claude Code's timer only bounds
the wait for response headers, and every streamed provider reply already sends its
headers and periodic `ping` events while it works, so Multi no longer sets it. An
`API_TIMEOUT_MS` you set is still respected.
- **Stop running a command on every Claude tool call.** Multi registered a
`PreToolUse` command hook that started Node for each tool call, including
Claude's own, only to tell the gateway which provider proposed an action. That
attribution now runs inside Claude Mods, and only for provider models.
- **Never refuse a Claude request over session identity.** A Claude request whose
session header and metadata disagreed was rejected with a 400. The check now
applies only to provider models, where session identity matters.
- **Skip gateway calls when Claude starts its own subagents.** Every native Claude
subagent triggered two gateway lookups. A subagent the gateway already classified
as Claude's, in a Claude session, now starts without any.
- **Accept requests up to Claude's own 32 MB limit.** The gateway refused any
request over 8 MiB, so a Claude conversation with a few screenshots or PDFs
failed with "Request too large (max 32MB)" well before Anthropic's limit. It
now allows 32 MB and answers an oversized request with Anthropic's own error.
- **Add GPT-6.1 Sol to the Zen catalog.** Select it with
`/model multi/zen/gpt-6.1-sol` or `model: gpt-6.1-sol` on a `multi-zen` worker.
Codex does not serve it on ChatGPT plans yet, so the OpenAI provider still runs
`gpt-6-sol`.

## 0.3.0 — 2026-09-28

- **Keep empty arguments when launching npm's `claude.cmd` on Windows.** Multi runs
Expand Down
2 changes: 1 addition & 1 deletion docs/zen.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ below lists the full supported catalog; other rows require explicit selection.
| Model IDs | Protocol | Effort |
| --- | --- | --- |
| `deepseek-v4.1-flash`, `deepseek-v4-flash`, `kimi-k3`, `glm-5.3`, `glm-5.3-flash`, `glm-5.2`, `minimax-m2.7` | Chat Completions | Provider-native reasoning |
| `gpt-6-luna`, `gpt-6-sol`, `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `muse-spark-1.3` | Responses | `low`, `medium`, `high`, `xhigh`, and `max` |
| `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-sol`, `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `muse-spark-1.3` | Responses | `low`, `medium`, `high`, `xhigh`, and `max` |

Workers: the Agent tool's `multi-zen` type runs the Zen rows the session's
`/model` picker shows (the curated defaults unless `--models` selects others);
Expand Down
2 changes: 1 addition & 1 deletion plugins/multi-core/hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ type GatewayResponse = {
// Claude Code 2.1.272 loads exactly one entry from hooks.json `modules`; compose here.
export const register: Register = (on, options) => {
const policyState: PolicyState = {};
registerUsage(on, options);
// Detaching makes the gateway forget this session's mode; keeping its generation
// here would leave every later prompt stale against a gateway holding nothing.
const agentModels = new Map<string, string>();
const spawnModels = new Map<string, string>();
registerUsage(on, options, agentModels);
registerLifecycle(
on,
options,
Expand Down
95 changes: 95 additions & 0 deletions plugins/multi-core/hooks/tests/workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,98 @@ test('the Agent tool description tells every model how to pick a provider model'
expect(described.description).toContain('For a multi-* agent type');
expect(described.description.startsWith('Launch a new agent.')).toBe(true);
});

/** The gateway classifies the type at offer, as it does before the model can name it. */
async function offerClaudeType(
$: Parameters<Parameters<typeof test>[1]>[0],
on: On,
agent: string,
) {
on('agent.offer', () => ({ isOffered: true }));
on('session.model', () => ({ value: 'claude-sonnet-5' }));
await $.agent.offer({
agent,
description: agent,
source: 'plugin',
provider: { plugin: 'engine', tier: 'core' },
});
}

/** A gateway that classifies every type as Claude-loop and records each call. */
function claudeGateway(on: On) {
const sent: string[] = [];
on('env.get', (_$, event) => ({
value: event.name === 'MULTI_GATEWAY_TOKEN' ? 'token' : 'http://127.0.0.1:4000',
}));
on('session.id', () => ({ value: 's' }));
on('session.cwd', () => ({ value: '/workspace' }));
on('http.fetch', (_$, event) => {
sent.push(event.url);
return {
value: {
ok: true,
status: 200,
headers: {},
text: '{"execution":"claude","isOffered":true}',
},
};
});
return sent;
}

test('a native Claude subagent spawns without any gateway call', async ($, on) => {
const sent = claudeGateway(on);
on('agent.spawn', () => ({ model: 'haiku', agentId: 'native' }));
await offerClaudeType($, on, 'Explore');
const before = sent.length;
const result = await $.agent.spawn({
prompt: 'task',
subagentType: 'Explore',
model: 'haiku',
parentModel: 'claude-sonnet-5',
});
expect(result.agentId).toBe('native');
expect(sent.length).toBe(before);
});

test('a subagent of a Multi model session still reaches the gateway', async ($, on) => {
const sent = claudeGateway(on);
on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'inherited' }));
await offerClaudeType($, on, 'Explore');
const before = sent.length;
await $.agent.spawn({
prompt: 'task',
subagentType: 'Explore',
parentModel: 'multi/cursor/auto',
});
expect(sent.length).toBeGreaterThan(before);
});

test('a type not offered as Claude-loop keeps gateway admission', async ($, on) => {
const sent = catalogGateway(on);
on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'worker' }));
await $.agent.spawn({ prompt: 'task', subagentType: 'cursor-auto', parentModel: 'claude' });
expect(sent.some((item) => item.url.endsWith('/multi/mod/worker-model'))).toBe(true);
});

test('a Claude tool call posts nothing to the gateway', async ($, on) => {
const sent = catalogGateway(on);
on('session.model', () => ({ value: 'claude-sonnet-5' }));
on('tool.call', () => ({ result: 'ran' }));
await $.tool.call({ tool: 'Read', tool_use_id: 'toolu_1' } as never);
expect(sent.length).toBe(0);
});

test('a provider model tool call is attributed to the gateway reviewer', async ($, on) => {
const sent = catalogGateway(on);
on('session.model', () => ({ value: 'multi/openai/gpt-6-astra' }));
on('tool.call', () => ({ result: 'ran' }));
await $.tool.call({ tool: 'Bash', tool_use_id: 'toolu_1' } as never);
expect(sent.map((item) => item.url.replace(/^.*(?=\/multi)/, ''))).toEqual(['/multi/permission']);
expect(sent[0]?.body).toEqual({
session_id: 's',
tool_use_id: 'toolu_1',
tool_name: 'Bash',
cwd: '/workspace',
});
});
50 changes: 49 additions & 1 deletion plugins/multi-core/hooks/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ export function forgetUsageSession(session: string) {
panes.delete(session);
}

export const register: Register = (on) => {
export const register = (
on: Parameters<Register>[0],
_options?: Parameters<Register>[1],
agentModels: ReadonlyMap<string, string> = new Map(),
) => {
on('classic.PreToolUse', async ($, event, next) => {
await attributeToolCall($, event, agentModels);
// `Task` is the Agent tool's legacy name; a worker's own spawn carries its agentId.
const tool: string = event.tool;
if (
Expand Down Expand Up @@ -224,3 +229,46 @@ async function request($: EngineInterface, route: string): Promise<unknown> {
clearTimeout(timer);
}
}

/**
* Reviewer attribution for a provider's tool call: the gateway learns the session, call,
* and workspace it needs to match the reviewer request to it. A Claude loop's tool call
* is Claude's own and costs no gateway call. The reply never decides the call.
*/
async function attributeToolCall(
$: EngineInterface,
event: { tool: string; tool_use_id: string },
agentModels: ReadonlyMap<string, string>,
) {
const agentId = (event as { agentId?: string }).agentId;
const model = agentId === undefined ? await $.session.model() : agentModels.get(agentId);
const base = await $.env.get('MULTI_MOD_GATEWAY_URL');
const token = await $.env.get('MULTI_GATEWAY_TOKEN');
if (!model?.startsWith('multi/') || !base || !token) {
return;
}
const mode = (event as { permission_mode?: unknown }).permission_mode;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
$.http.fetch(`${base}/multi/permission`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token },
body: JSON.stringify({
session_id: await $.session.id(),
tool_use_id: event.tool_use_id,
tool_name: event.tool,
cwd: await $.session.cwd(),
...(typeof mode === 'string' ? { permission_mode: mode } : {}),
}),
}),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('Attribution timeout')), 1500);
}),
]);
} catch {
// Attribution is best effort; Claude's own checks decide the call.
} finally {
clearTimeout(timer);
}
}
39 changes: 39 additions & 0 deletions plugins/multi-core/hooks/workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export const register = (
) => {
/** The resolved model of each provider-worker spawn, by its Agent call's tool_use_id. */
registerWorkerRows(on, agentModels, spawnModels);
/**
* Agent types the gateway classified as Claude-loop at offer. A definition may name a
* provider model the spawn event does not carry, so only such a type skips admission.
*/
const claudeTypes = new Set<string>();
on('tool.describe', { tool: 'Agent' }, async ($, event, next) => {
const described = await next(event);
if (!(await active($))) {
Expand Down Expand Up @@ -120,6 +125,11 @@ export const register = (
},
'/multi/mod/offer',
);
if (response?.execution === 'claude') {
claudeTypes.add(event.agent);
} else {
claudeTypes.delete(event.agent);
}
// Claude owns its own catalog. Only a positively identified harness worker
// is subject to Multi's settings-translation compatibility filter.
return response?.execution === 'harness' && response.isOffered === false
Expand All @@ -130,6 +140,14 @@ export const register = (
if (!(await active($))) {
return next(event);
}
// A native Claude subagent in a Claude session needs no gateway record.
if (
claudeTypes.has(event.agent_type) &&
!isProviderWorker(event.agent_type) &&
!isMultiModel(await $.session.model())
) {
return next(event);
}
const response = await request($, {
sessionId: event.session_id,
agentId: event.agent_id,
Expand All @@ -147,6 +165,10 @@ export const register = (
if (!(await active($))) {
return next(event);
}
// A native Claude subagent runs on Claude Code's own path: no gateway call.
if (nativeClaudeSpawn(event, claudeTypes)) {
return next(event);
}
const resolved = await providerSpawn($, event);
if ('deny' in resolved) {
return { deny: resolved.deny };
Expand Down Expand Up @@ -237,6 +259,23 @@ async function admit(
: reportable(response?.error ?? 'Multi harness worker policy was not acknowledged.');
}

function isMultiModel(model: string | undefined): boolean {
return Boolean(model?.startsWith('multi/'));
}

/**
* A Claude subagent: not a `multi-*` worker type, offered as Claude-loop, and neither its
* model nor its parent's (which a fork or an inheriting subagent runs on) is a Multi model.
*/
function nativeClaudeSpawn(event: SpawnEvent, claudeTypes: ReadonlySet<string>): boolean {
return (
claudeTypes.has(event.subagentType) &&
!isProviderWorker(event.subagentType) &&
!isMultiModel(event.model) &&
!isMultiModel(event.parentModel)
);
}

function harnessSpawn(
event: { fork?: boolean; model?: string; parentModel?: string },
selection: GatewayResponse | undefined,
Expand Down
4 changes: 3 additions & 1 deletion plugins/multi-core/src/gateway/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
interface GatewayFetchInit {
method: string;
headers: Record<string, string>;
body?: Buffer | string;
body?: Buffer | string | ReadableStream<Uint8Array>;
/** Node's fetch requires half duplex for streamed request bodies. */
duplex?: 'half';
signal: AbortSignal;
redirect: 'error';
}
Expand Down
Loading
Loading