From 6f74d545988c076d125dceab7e336c0a41c27324 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:34:22 -0400 Subject: [PATCH 01/36] Refuse cmd.exe metacharacters in arguments routed through .cmd launchers Add isCmdSafeArgument, argvSafe and promptArgvSafe so providers can send prompt text over stdin when a non-shim .cmd/.bat would re-parse it. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- node_modules | 1 + plugins/multi-core/src/gateway/executable.ts | 41 +++++++++++++ .../multi-core/src/gateway/harness-process.ts | 32 +++++++++- test/unit/native-executable.test.ts | 61 +++++++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..10b9d81 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/walshlab/Software/cc-multi-cli-plugin/node_modules \ No newline at end of file diff --git a/plugins/multi-core/src/gateway/executable.ts b/plugins/multi-core/src/gateway/executable.ts index 3e7c4bb..2b556cf 100644 --- a/plugins/multi-core/src/gateway/executable.ts +++ b/plugins/multi-core/src/gateway/executable.ts @@ -71,6 +71,39 @@ function missingExecutable(message: string): NodeJS.ErrnoException { return error; } +const CMD_UNSAFE = /["%!^&|<>\r\n\0]/; + +/** True when text can pass through `cmd.exe /d /s /c` as one literal argument. */ +export function isCmdSafeArgument(value: string): boolean { + return !CMD_UNSAFE.test(value); +} + +export class UnsafeCommandArgumentError extends Error { + readonly code = 'EUNSAFEARG'; + constructor() { + super( + 'Refusing to pass an argument containing cmd.exe metacharacters through a .cmd/.bat launcher; deliver it on stdin instead', + ); + this.name = 'UnsafeCommandArgumentError'; + } +} + +/** + * Whether `value` may travel as a command-line argument to this executable on + * this platform. Only a non-shim `.cmd`/`.bat` launcher restricts arguments. + */ +export function argvSafe( + executable: string, + value: string, + platform = process.platform, + shimOptions: ExecutableInvocationOptions = {}, +): boolean { + if (platform !== 'win32' || !/\.(?:cmd|bat)$/i.test(executable)) { + return true; + } + return resolveNpmShimTarget(executable, shimOptions) !== undefined || isCmdSafeArgument(value); +} + /** Invoke Windows command shims without shell:true, preserving argument boundaries. */ export function executableInvocation( executable: string, @@ -90,6 +123,14 @@ export function executableInvocation( viaComSpec: false, }; } + // cmd.exe re-parses the line: it expands %VAR% and does not honor the + // backslash-quote escape the child's runtime uses. No quoting is provably + // safe for such text, so refuse it and let callers use stdin or a file. + for (const value of [executable, ...args]) { + if (!isCmdSafeArgument(value)) { + throw new UnsafeCommandArgumentError(); + } + } const command = environmentValue(env, 'ComSpec') ?? process.env.ComSpec ?? 'cmd.exe'; const commandLine = [quoteWindows(executable), ...args.map(quoteWindows)].join(' '); return { diff --git a/plugins/multi-core/src/gateway/harness-process.ts b/plugins/multi-core/src/gateway/harness-process.ts index f5429b7..834e45c 100644 --- a/plugins/multi-core/src/gateway/harness-process.ts +++ b/plugins/multi-core/src/gateway/harness-process.ts @@ -1,6 +1,6 @@ import { type ChildProcess, spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; -import { executableInvocation, resolveExecutable } from './executable.ts'; +import { argvSafe, executableInvocation, resolveExecutable } from './executable.ts'; import { terminateProcessTree } from './process-tree.ts'; export const defaultMaxOutputBytes = 8 * 1024 * 1024; @@ -17,6 +17,36 @@ export function promptArgumentLimitBytes(platform: NodeJS.Platform): number { return platform === 'win32' ? windowsPromptArgumentLimitBytes : posixPromptArgumentLimitBytes; } +/** + * Whether prompt text may ride in argv to the resolved CLI. A non-shim + * `.cmd`/`.bat` launcher is parsed by cmd.exe, which expands `%VAR%` and has no + * safe escape for quotes, so such prompts must go through stdin. An unresolved + * binary reports true; the spawn itself then fails with a clear ENOENT. + */ +export function promptArgvSafe( + prompt: string, + options: { + executable: string; + configuredPath?: string; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + }, +): boolean { + if (options.platform !== 'win32') { + return true; + } + try { + const resolved = resolveExecutable(options.executable, { + platform: options.platform, + env: options.env, + configuredPath: options.configuredPath, + }); + return argvSafe(resolved, prompt, options.platform); + } catch { + return true; + } +} + /** * Credentials of other providers never reach a native CLI. A `drop` entry ending * in `_` removes a whole prefix; any other entry removes exactly that name. diff --git a/test/unit/native-executable.test.ts b/test/unit/native-executable.test.ts index 3917225..48de200 100644 --- a/test/unit/native-executable.test.ts +++ b/test/unit/native-executable.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + argvSafe, executableInvocation, + isCmdSafeArgument, + UnsafeCommandArgumentError, resolveExecutable, } from '../../plugins/multi-core/src/gateway/executable.ts'; @@ -160,3 +163,61 @@ test('Windows resolution reads Path and PathExt regardless of spelling', () => { }); assert.equal(found, 'C:\\bin\\claude.cmd'); }); + +const comSpec = { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }; +const noShim = { readShim: () => 'unexpected shim body' }; + +test('refuses cmd.exe metacharacters on the ComSpec path', () => { + for (const hostile of [ + 'a"&calc&"', + '%PATH%', + '!VAR!', + 'x^y', + 'a&b', + 'a|b', + 'ab', + 'line\nbreak', + 'line\rbreak', + 'say "hi"', + ]) { + assert.throws( + () => executableInvocation('C:\\tools\\agy.cmd', ['-p', hostile], 'win32', comSpec, noShim), + UnsafeCommandArgumentError, + JSON.stringify(hostile), + ); + assert.equal(isCmdSafeArgument(hostile), false); + assert.equal(argvSafe('C:\\tools\\agy.cmd', hostile, 'win32', noShim), false); + } +}); + +test('refuses a hostile executable path on the ComSpec path', () => { + assert.throws( + () => executableInvocation('C:\\a%TEMP%\\agy.cmd', [], 'win32', comSpec, noShim), + UnsafeCommandArgumentError, + ); +}); + +test('plain text with spaces and parentheses still passes through cmd.exe', () => { + const invocation = executableInvocation( + 'C:\\tools\\agy.cmd', + ['-p', 'fix (the) bug, now'], + 'win32', + comSpec, + noShim, + ); + assert.equal(invocation.viaComSpec, true); + assert.equal(argvSafe('C:\\tools\\agy.cmd', 'fix (the) bug', 'win32', noShim), true); +}); + +test('argvSafe only restricts non-shim cmd launchers on Windows', () => { + assert.equal(argvSafe('C:\\tools\\agy.exe', '%PATH%', 'win32'), true); + assert.equal(argvSafe('/usr/bin/agy', '%PATH% "x"', 'linux'), true); + assert.equal( + argvSafe('C:\\tools\\agy.cmd', '"%PATH%"', 'win32', { + readShim: () => '"%dp0%\\node_modules\\pkg\\cli.js" %*', + exists: () => true, + }), + true, + ); +}); From 58f2055ffd77577727d03dcebb9abb557d0703d7 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:35:38 -0400 Subject: [PATCH 02/36] Gracefully stop Windows trees, skip post-close kills, bound lines not total output Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .../multi-core/src/gateway/harness-process.ts | 68 +++++++++++-------- .../multi-core/src/gateway/process-tree.ts | 17 +++-- test/unit/native-harness-process.test.ts | 32 ++++++++- test/unit/native-process-tree.test.ts | 28 +++++++- 4 files changed, 107 insertions(+), 38 deletions(-) diff --git a/plugins/multi-core/src/gateway/harness-process.ts b/plugins/multi-core/src/gateway/harness-process.ts index 834e45c..c77f141 100644 --- a/plugins/multi-core/src/gateway/harness-process.ts +++ b/plugins/multi-core/src/gateway/harness-process.ts @@ -3,6 +3,7 @@ import { StringDecoder } from 'node:string_decoder'; import { argvSafe, executableInvocation, resolveExecutable } from './executable.ts'; import { terminateProcessTree } from './process-tree.ts'; +/** Per-line and retained-stderr bound; the stream itself is not capped in total. */ export const defaultMaxOutputBytes = 8 * 1024 * 1024; export const interruptGraceMs = 1500; export const terminateGraceMs = 1500; @@ -176,11 +177,11 @@ type RunContext

= { decoder: StringDecoder; limit: number; buffer: string; - stdoutBytes: number; - stderrBytes: number; stderr: string; aborted: boolean; settled: boolean; + /** The child's own `close` arrived: its PID may already belong to someone else. */ + closed: boolean; interruptTimer?: NodeJS.Timeout; terminateTimer?: NodeJS.Timeout; cancellationTimer?: NodeJS.Timeout; @@ -215,11 +216,10 @@ export function runNativeCli

( decoder: new StringDecoder('utf8'), limit: spec.maxOutputBytes ?? defaultMaxOutputBytes, buffer: '', - stdoutBytes: 0, - stderrBytes: 0, stderr: '', aborted: false, settled: false, + closed: false, onAbort: () => {}, resolve, reject, @@ -266,7 +266,10 @@ function attach

(context: RunContext): voi child.stdin?.on('error', (error: Error) => { fail(context, new NativeCliError(`${spec.executable} stdin failed: ${error.message}`, 'spawn')); }); - child.once('close', (exitCode, signal) => finishRun(context, exitCode, signal)); + child.once('close', (exitCode, signal) => { + context.closed = true; + finishRun(context, exitCode, signal); + }); spec.signal.addEventListener('abort', context.onAbort, { once: true }); if (spec.signal.aborted) { context.onAbort(); @@ -280,9 +283,19 @@ function kill

( context: RunContext, signal: NodeJS.Signals, ): void { - if (context.child.pid) { - terminateProcessTree(context.child.pid, { platform: context.spec.platform, signal }); + const { pid } = context.child; + if (!pid) { + return; + } + if (context.closed) { + // The child is gone. taskkill by PID could hit a recycled PID, and on POSIX + // only the group (which outlives its leader while members remain) is safe. + if (context.spec.platform !== 'win32') { + terminateProcessTree(pid, { platform: context.spec.platform, signal, groupOnly: true }); + } + return; } + terminateProcessTree(pid, { platform: context.spec.platform, signal }); } /** Interrupt first, then terminate, then kill: a CLI may save native state. */ @@ -328,33 +341,32 @@ function consumeStdout

( if (context.spec.parser.failure) { return; } - context.stdoutBytes += chunk.byteLength; - if (context.stdoutBytes > context.limit) { - fail( - context, - new NativeCliError(`${context.spec.name} stdout exceeded its safety limit`, 'output_limit'), - ); - return; - } context.buffer += context.decoder.write(chunk); let newline = context.buffer.indexOf('\n'); while (newline >= 0) { const line = context.buffer.slice(0, newline).replace(/\r$/, ''); context.buffer = context.buffer.slice(newline + 1); + if (line.length > context.limit) { + failLineLimit(context); + return; + } parse(context, line); newline = context.buffer.indexOf('\n'); } + // The stream is JSONL and consumed incrementally, so only one unfinished + // line is ever retained; a long healthy run is bounded per line, not in total. if (context.buffer.length > context.limit) { - fail( - context, - new NativeCliError( - `${context.spec.name} stdout line exceeded its safety limit`, - 'output_limit', - ), - ); + failLineLimit(context); } } +function failLineLimit

(context: RunContext): void { + fail( + context, + new NativeCliError(`${context.spec.name} stdout line exceeded its safety limit`, 'output_limit'), + ); +} + function parse

(context: RunContext, line: string): void { context.spec.parseLine( line, @@ -371,15 +383,11 @@ function consumeStderr

( if (context.spec.parser.failure) { return; } - context.stderrBytes += chunk.byteLength; - if (context.stderrBytes > context.limit) { - fail( - context, - new NativeCliError(`${context.spec.name} stderr exceeded its safety limit`, 'output_limit'), - ); - return; - } + // Only the tail is diagnostic; a chatty CLI must not fail an otherwise healthy run. context.stderr += chunk.toString('utf8'); + if (context.stderr.length > context.limit) { + context.stderr = context.stderr.slice(-context.limit); + } } function abortRun

(context: RunContext): void { diff --git a/plugins/multi-core/src/gateway/process-tree.ts b/plugins/multi-core/src/gateway/process-tree.ts index fc4aa7d..89483dc 100644 --- a/plugins/multi-core/src/gateway/process-tree.ts +++ b/plugins/multi-core/src/gateway/process-tree.ts @@ -4,18 +4,24 @@ export interface ProcessTreeOptions { platform?: NodeJS.Platform; signal?: NodeJS.Signals; kill?: (pid: number, signal?: NodeJS.Signals | number) => void; - taskkill?: (pid: number) => void; + /** Windows only: `force` adds `/F`; without it the tree is asked to close. */ + taskkill?: (pid: number, force: boolean) => void; + /** POSIX only: signal the group but never the bare PID (it may be reused). */ + groupOnly?: boolean; } /** * Stop a CLI and its descendants. POSIX group membership is best effort: a * child can leave the group, so the direct-PID fallback is always attempted. * Windows has no portable POSIX group signal, therefore taskkill owns the tree. + * Only SIGKILL (or an unspecified signal, the historical behavior) forces; any + * other signal asks politely so a CLI can still save state. */ export function terminateProcessTree(pid: number, options: ProcessTreeOptions = {}): void { const platform = options.platform ?? process.platform; if (platform === 'win32') { - (options.taskkill ?? defaultTaskkill)(pid); + const force = options.signal === undefined || options.signal === 'SIGKILL'; + (options.taskkill ?? defaultTaskkill)(pid, force); return; } const kill = options.kill ?? process.kill; @@ -25,6 +31,9 @@ export function terminateProcessTree(pid: number, options: ProcessTreeOptions = } catch { // The process may have exited, or the group may not exist. } + if (options.groupOnly) { + return; + } try { kill(pid, signal); } catch { @@ -32,10 +41,10 @@ export function terminateProcessTree(pid: number, options: ProcessTreeOptions = } } -function defaultTaskkill(pid: number): void { +function defaultTaskkill(pid: number, force: boolean): void { const child = spawn( process.env.ComSpec ?? 'cmd.exe', - ['/d', '/c', 'taskkill', '/T', '/F', '/PID', String(pid)], + ['/d', '/c', 'taskkill', '/T', ...(force ? ['/F'] : []), '/PID', String(pid)], { stdio: 'ignore', windowsHide: true, diff --git a/test/unit/native-harness-process.test.ts b/test/unit/native-harness-process.test.ts index 2c75898..254b0f5 100644 --- a/test/unit/native-harness-process.test.ts +++ b/test/unit/native-harness-process.test.ts @@ -182,7 +182,7 @@ test('an oversized stream stops the run instead of buffering it', async (t) => { await assert.rejects(run, (error: unknown) => { assert.ok(error instanceof NativeCliError); assert.equal(error.code, 'output_limit'); - assert.match(error.message, /Native stdout exceeded its safety limit/); + assert.match(error.message, /Native stdout line exceeded its safety limit/); return true; }); }); @@ -293,3 +293,33 @@ test('platform limits and a native environment keep other providers out', () => ); assert.equal(new NativeCliError('bare', 'parse').systemCode, undefined); }); + +test('many small lines past the byte limit are not a failure', async () => { + const child = new FakeChild(); + const { run, parser } = start(child, { maxOutputBytes: 256 }); + await flush(); + for (let index = 0; index < 50; index += 1) { + child.stdout.write('{"type":"text","id":"a"}\n'); + child.stderr.write(`${'e'.repeat(100)}\n`); + } + child.stdout.write('{"type":"end","id":"long"}\n'); + await flush(); + child.emit('close', 0, null); + const outcome = await run; + assert.deepEqual(outcome.result, { id: 'long' }); + assert.equal(parser.lines.length, 51); + assert.ok(outcome.stderr.length <= 256); +}); + +test('after the child closes only the process group is signaled, never the bare PID', async (t) => { + const targets: number[] = []; + t.mock.method(process, 'kill', (pid: number) => { + targets.push(pid); + }); + const child = new FakeChild(); + const { run } = start(child); + await flush(); + child.emit('close', 0, null); + await run.catch(() => undefined); + assert.deepEqual(targets, [-4242]); +}); diff --git a/test/unit/native-process-tree.test.ts b/test/unit/native-process-tree.test.ts index 47a4c0a..0e4491e 100644 --- a/test/unit/native-process-tree.test.ts +++ b/test/unit/native-process-tree.test.ts @@ -15,13 +15,35 @@ test('terminates POSIX process groups and always attempts the direct PID fallbac }); test('uses taskkill tree termination on Windows', () => { - const calls: number[] = []; + const calls: [number, boolean][] = []; terminateProcessTree(42, { platform: 'win32', kill: () => assert.fail('Windows must not send POSIX signals'), - taskkill: (pid) => calls.push(pid), + taskkill: (pid, force) => calls.push([pid, force]), }); - assert.deepEqual(calls, [42]); + assert.deepEqual(calls, [[42, true]]); +}); + +test('Windows asks politely for SIGINT/SIGTERM and forces only for SIGKILL', () => { + const calls: [number, boolean][] = []; + for (const signal of ['SIGINT', 'SIGTERM', 'SIGKILL'] as const) { + terminateProcessTree(7, { + platform: 'win32', + signal, + taskkill: (pid, force) => calls.push([pid, force]), + }); + } + assert.deepEqual(calls, [ + [7, false], + [7, false], + [7, true], + ]); +}); + +test('groupOnly never signals the bare PID', () => { + const calls: number[] = []; + terminateProcessTree(42, { platform: 'linux', groupOnly: true, kill: (pid) => calls.push(pid) }); + assert.deepEqual(calls, [-42]); }); test('continues direct POSIX cleanup when group cleanup fails', () => { From ff114fc0886634f2dfecb3f201a4a57124347e81 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:35:53 -0400 Subject: [PATCH 03/36] Run managed-policy probes from system paths with a timeout Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .../multi-core/src/gateway/cursor-settings.ts | 44 +++++++++++++++---- .../multi-core/src/gateway/harness-process.ts | 5 ++- test/unit/native-cursor-settings.test.ts | 15 +++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/plugins/multi-core/src/gateway/cursor-settings.ts b/plugins/multi-core/src/gateway/cursor-settings.ts index 1860bcd..72c7054 100644 --- a/plugins/multi-core/src/gateway/cursor-settings.ts +++ b/plugins/multi-core/src/gateway/cursor-settings.ts @@ -31,16 +31,44 @@ export interface CursorSettingsOptions { /** Every discovery option resolved to a value; not part of the public surface. */ type SettingsDiscovery = Required; +export const policyCommandTimeoutMs = 10_000; + +/** + * Policy probes run from fixed system directories: a bare name would let a + * Windows working directory (searched first) or a user PATH entry answer for + * the managed policy. + */ +export function systemCommandPath( + command: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string { + if (platform === 'win32') { + const root = env.SystemRoot ?? env.SYSTEMROOT ?? env.windir ?? 'C:\\Windows'; + const system32 = path.win32.join(root, 'System32'); + if (command === 'powershell.exe') { + return path.win32.join(system32, 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + } + return path.win32.join(system32, command.endsWith('.exe') ? command : `${command}.exe`); + } + return command === 'defaults' ? '/usr/bin/defaults' : command; +} + const defaultRunCommand = (command: string, args: readonly string[]): Promise => new Promise((resolve, reject) => { - childProcess.execFile(command, [...args], { encoding: 'utf8' }, (error, stdout, stderr) => { - if (error) { - Object.assign(error, { stderr }); - reject(error); - return; - } - resolve(stdout); - }); + childProcess.execFile( + systemCommandPath(command), + [...args], + { encoding: 'utf8', timeout: policyCommandTimeoutMs, windowsHide: true, cwd: os.tmpdir() }, + (error, stdout, stderr) => { + if (error) { + Object.assign(error, { stderr }); + reject(error); + return; + } + resolve(stdout); + }, + ); }); /** Re-read on each native dispatch; Claude-side rules cannot constrain SDK tools. */ diff --git a/plugins/multi-core/src/gateway/harness-process.ts b/plugins/multi-core/src/gateway/harness-process.ts index c77f141..baea130 100644 --- a/plugins/multi-core/src/gateway/harness-process.ts +++ b/plugins/multi-core/src/gateway/harness-process.ts @@ -363,7 +363,10 @@ function consumeStdout

( function failLineLimit

(context: RunContext): void { fail( context, - new NativeCliError(`${context.spec.name} stdout line exceeded its safety limit`, 'output_limit'), + new NativeCliError( + `${context.spec.name} stdout line exceeded its safety limit`, + 'output_limit', + ), ); } diff --git a/test/unit/native-cursor-settings.test.ts b/test/unit/native-cursor-settings.test.ts index 2a0bb3d..b26fb27 100644 --- a/test/unit/native-cursor-settings.test.ts +++ b/test/unit/native-cursor-settings.test.ts @@ -8,6 +8,8 @@ import { antigravityPermissionPolicy } from '../../plugins/multi-antigravity/src import { type CursorSettingsOptions, checkCursorSettings, + policyCommandTimeoutMs, + systemCommandPath, } from '../../plugins/multi-core/src/gateway/cursor-settings.ts'; import type { PermissionContext } from '../../plugins/multi-core/src/gateway/mode-hook.ts'; import { sharedAdmission } from '../../plugins/multi-core/src/launcher.ts'; @@ -563,3 +565,16 @@ test('native settings admission treats WSL as Linux managed file discovery', asy ); assert.deepEqual(policy.disallowedTools, ['Read']); }); + +test('policy probes use absolute system paths and a bounded timeout', () => { + assert.equal( + systemCommandPath('reg', 'win32', { SystemRoot: 'D:\\Win' }), + 'D:\\Win\\System32\\reg.exe', + ); + assert.equal( + systemCommandPath('powershell.exe', 'win32', {}), + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + ); + assert.equal(systemCommandPath('defaults', 'darwin'), '/usr/bin/defaults'); + assert.ok(policyCommandTimeoutMs > 0 && policyCommandTimeoutMs <= 30_000); +}); From 3c91cdf552cb6005cea74cbe7c58c27cd63b7353 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:36:08 -0400 Subject: [PATCH 04/36] fsync atomic writes before rename and the directory after Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .../multi-core/src/gateway/atomic-write.ts | 48 ++++++++++++++++++- test/unit/native-atomic-write.test.ts | 32 ++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/plugins/multi-core/src/gateway/atomic-write.ts b/plugins/multi-core/src/gateway/atomic-write.ts index 7de0a21..ad0d9f4 100644 --- a/plugins/multi-core/src/gateway/atomic-write.ts +++ b/plugins/multi-core/src/gateway/atomic-write.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { rename, rm, writeFile } from 'node:fs/promises'; +import { open, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; export async function atomicWriteFile( file: string, @@ -10,6 +11,7 @@ export async function atomicWriteFile( retries?: number; rename?: typeof rename; rm?: typeof rm; + open?: typeof open; } = {}, ): Promise { const temporary = `${file}.${randomUUID()}.tmp`; @@ -19,14 +21,16 @@ export async function atomicWriteFile( if (!Number.isSafeInteger(retries) || retries < 0) { throw new RangeError('Atomic write retries must be a non-negative integer'); } - await writeFile(temporary, data, { mode: options.mode }); + const openFile = options.open ?? open; const attempts = options.platform === 'win32' ? retries : 0; const renameFile = options.rename ?? rename; const removeFile = options.rm ?? rm; try { + await writeDurably(openFile, temporary, data, options.mode); for (let attempt = 0; ; attempt += 1) { try { await renameFile(temporary, file); + await syncDirectory(openFile, path.dirname(file), options.platform); return; } catch (error) { const retryable = isWindowsRenameRetryable(error, options.platform); @@ -49,3 +53,43 @@ function isWindowsRenameRetryable(error: unknown, platform: NodeJS.Platform | un (error.code === 'EPERM' || error.code === 'EBUSY') ); } + +/** Flush the bytes before the rename can make them visible under the final name. */ +async function writeDurably( + openFile: typeof open, + file: string, + data: string | Uint8Array, + mode: number | undefined, +): Promise { + const handle = await openFile(file, 'w', mode); + try { + await handle.writeFile(data); + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** + * Persist the rename itself. Windows cannot open a directory for syncing, and + * some POSIX filesystems refuse it, so this step is best effort by design. + */ +async function syncDirectory( + openFile: typeof open, + directory: string, + platform: NodeJS.Platform | undefined, +): Promise { + if ((platform ?? process.platform) === 'win32') { + return; + } + try { + const handle = await openFile(directory, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } catch { + // The data is already durable; only rename durability is unavailable here. + } +} diff --git a/test/unit/native-atomic-write.test.ts b/test/unit/native-atomic-write.test.ts index 3464383..795c8ce 100644 --- a/test/unit/native-atomic-write.test.ts +++ b/test/unit/native-atomic-write.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { mkdtemp, open, readFile, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -45,3 +45,33 @@ test('rejects an unbounded retry configuration', async () => { /retries must be a non-negative integer/, ); }); + +test('syncs the temporary file before rename and the directory after, except on Windows', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'atomic-write-sync-')); + t.after(() => removeTemporary(directory)); + for (const platform of ['linux', 'win32'] as const) { + const events: string[] = []; + const spyOpen = (async (target: string, flags: string, mode?: number) => { + const handle = await open(target, flags, mode); + const sync = handle.sync.bind(handle); + handle.sync = async () => { + events.push(`sync:${target === directory ? 'dir' : 'tmp'}`); + await sync(); + }; + return handle; + }) as typeof open; + await atomicWriteFile(path.join(directory, 'state.json'), 'v', { + platform, + open: spyOpen, + rename: async (from, to) => { + events.push('rename'); + const { rename } = await import('node:fs/promises'); + await rename(from, to); + }, + }); + assert.deepEqual( + events, + platform === 'win32' ? ['sync:tmp', 'rename'] : ['sync:tmp', 'rename', 'sync:dir'], + ); + } +}); From d3514b5d35030daabce9744686cb3bafa7b69a32 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:37:19 -0400 Subject: [PATCH 05/36] Make stale state-lock takeover hostname-tolerant and race-verified Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-core/src/gateway/state-lock.ts | 133 +++++++++++++++++-- test/unit/native-state-lock.test.ts | 56 +++++++- 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/plugins/multi-core/src/gateway/state-lock.ts b/plugins/multi-core/src/gateway/state-lock.ts index 03440e9..50a200f 100644 --- a/plugins/multi-core/src/gateway/state-lock.ts +++ b/plugins/multi-core/src/gateway/state-lock.ts @@ -1,8 +1,15 @@ +import { execFileSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { lstat, open, readFile, rename, unlink } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { link, lstat, open, readFile, rename, unlink } from 'node:fs/promises'; import { hostname } from 'node:os'; export interface LockStateFileOptions { + /** Exclusive re-creation of a marker that was moved aside by mistake. */ + link?: typeof link; + /** Stable machine identity; defaults to the OS-provided id when one exists. */ + machineId?: string | undefined; + hostname?: string; platform?: NodeJS.Platform; maxAttempts?: number; rename?: typeof rename; @@ -18,6 +25,63 @@ interface LockOwner { pid: number; hostname: string; token: string; + /** OS machine identity that survives a hostname change (absent in older markers). */ + machine?: string; +} + +let cachedMachineId: { value: string | undefined } | undefined; + +/** + * A hostname follows the network (macOS flips between `.local`, `.lan` and + * conflict suffixes), so identity prefers an OS machine id when one is readable. + */ +export function machineIdentity(platform: NodeJS.Platform = process.platform): string | undefined { + if (cachedMachineId) { + return cachedMachineId.value; + } + let value: string | undefined; + try { + if (platform === 'linux') { + value = readFileSync('/etc/machine-id', 'utf8').trim() || undefined; + } else if (platform === 'darwin') { + const output = execFileSync('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { + encoding: 'utf8', + timeout: 2000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + value = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(output)?.[1]; + } + } catch { + value = undefined; + } + cachedMachineId = { value }; + return value; +} + +/** Lower-cased first label: `Mac.local`, `mac.lan` and `MAC` are one machine name. */ +export function normalizeHostname(name: string): string { + return name.toLowerCase().split('.')[0] ?? ''; +} + +/** Whether a marker was written by this machine, tolerating hostname drift. */ +export function sameMachine( + recorded: LockOwner, + self: { hostname: string; machine?: string | undefined }, +): boolean { + if (recorded.machine !== undefined && self.machine !== undefined) { + return recorded.machine === self.machine; + } + return normalizeHostname(recorded.hostname) === normalizeHostname(self.hostname); +} + +function newOwner(options: LockStateFileOptions, platform: NodeJS.Platform): LockOwner { + const machine = 'machineId' in options ? options.machineId : machineIdentity(platform); + return { + pid: process.pid, + hostname: options.hostname ?? hostname(), + token: randomUUID(), + ...(machine === undefined ? {} : { machine }), + }; } /** @@ -35,13 +99,18 @@ export async function lockStateFile( options: LockStateFileOptions = {}, ): Promise<() => Promise> { const platform = options.platform ?? process.platform; - const owner: LockOwner = { pid: process.pid, hostname: hostname(), token: randomUUID() }; + const owner = newOwner(options, platform); const maxAttempts = options.maxAttempts ?? 100; - const renameFile = options.rename ?? rename; const unlinkFile = options.unlink ?? unlink; const openFile = options.open ?? open; - const lstatFile = options.lstat ?? lstat; const readFileContents = options.readFile ?? readFile; + const operations: LockOperations = { + renameFile: options.rename ?? rename, + unlinkFile, + lstatFile: options.lstat ?? lstat, + readFileContents, + linkFile: options.link ?? link, + }; if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) { throw new RangeError('State file lock maxAttempts must be a positive integer'); } @@ -51,7 +120,7 @@ export async function lockStateFile( if (release) { return release; } - await takeOverStaleLock(file, platform, renameFile, unlinkFile, lstatFile, readFileContents); + await takeOverStaleLock(file, owner, platform, operations); if (platform === 'win32') { await delay(20); } @@ -93,14 +162,21 @@ async function openLock(file: string, openFile: typeof open) { } } +interface LockOperations { + renameFile: typeof rename; + unlinkFile: typeof unlink; + lstatFile: typeof lstat; + readFileContents: typeof readFile; + linkFile: typeof link; +} + async function takeOverStaleLock( file: string, + self: LockOwner, platform: NodeJS.Platform, - renameFile: typeof rename, - unlinkFile: typeof unlink, - lstatFile: typeof lstat, - readFileContents: typeof readFile, + operations: LockOperations, ): Promise { + const { renameFile, unlinkFile, lstatFile, readFileContents, linkFile } = operations; const info = await lstatFile(file).catch((error: unknown) => { if (isCode(error, 'ENOENT')) { return undefined; @@ -119,7 +195,7 @@ async function takeOverStaleLock( } if ( current !== legacyEmptyMarker && - (current.hostname !== hostname() || isProcessAlive(current.pid)) + (!sameMachine(current, self) || isProcessAlive(current.pid)) ) { throw new Error('State file is locked by another gateway'); } @@ -134,6 +210,13 @@ async function takeOverStaleLock( cause: error, }); } + // Between our read and the rename another gateway may have taken over and + // acquired; then the marker we moved is a live lock and must go back. + const moved = await readOwner(stale, platform, readFileContents); + if (!sameMarker(current, moved)) { + await restoreMovedMarker(file, stale, platform, { linkFile, unlinkFile }); + return; + } await retryLockOperation(() => unlinkFile(stale), platform, 'stale-lock cleanup').catch( (error: unknown) => { if (!isCode(error, 'ENOENT')) { @@ -143,6 +226,34 @@ async function takeOverStaleLock( ); } +function sameMarker( + expected: LockOwner | typeof legacyEmptyMarker, + actual: LockOwner | typeof legacyEmptyMarker | undefined, +): boolean { + if (expected === legacyEmptyMarker || actual === legacyEmptyMarker) { + return expected === actual; + } + return actual !== undefined && actual.token === expected.token; +} + +/** `link` creates the marker only if none exists, so a newer owner is never clobbered. */ +async function restoreMovedMarker( + file: string, + stale: string, + platform: NodeJS.Platform, + operations: Pick, +): Promise { + try { + await retryLockOperation(() => operations.linkFile(stale, file), platform, 'lock restore'); + } catch (error) { + // Another gateway already re-acquired; the contested marker is stale-by-token. + if (!(error instanceof Error && isCode(error.cause, 'EEXIST'))) { + throw error; + } + } + await operations.unlinkFile(stale).catch(() => {}); +} + async function releaseLock( file: string, owner: LockOwner, @@ -155,7 +266,7 @@ async function releaseLock( !current || current === legacyEmptyMarker || current.token !== owner.token || - current.hostname !== owner.hostname + !sameMachine(current, owner) ) { return; } diff --git a/test/unit/native-state-lock.test.ts b/test/unit/native-state-lock.test.ts index 2f0dc3e..9ad2f12 100644 --- a/test/unit/native-state-lock.test.ts +++ b/test/unit/native-state-lock.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; import os, { hostname } from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -199,3 +199,57 @@ test('an empty marker left by the previous flock-based lock is taken over', asyn assert.match(await readFile(file, 'utf8'), /"pid"/); await release(); }); + +async function writeMarker(file: string, owner: Record) { + await writeFile(file, `${JSON.stringify(owner)}\n`, { mode: 0o600 }); +} + +test('takes over a dead owner recorded under a renamed host (.local vs .lan)', async (t) => { + const directory = await temporaryDirectory(t, 'multi-renamed-host-'); + const file = path.join(directory, 'session.lock'); + await writeMarker(file, { pid: 2147483647, hostname: 'Nicks-Mac.local', token: 'old' }); + const release = await lockStateFile(file, { hostname: 'nicks-mac.lan', machineId: undefined }); + await release(); +}); + +test('a matching machine id outranks a changed hostname, a different one refuses', async (t) => { + const directory = await temporaryDirectory(t, 'multi-machine-id-'); + const file = path.join(directory, 'session.lock'); + await writeMarker(file, { + pid: 2147483647, + hostname: 'Mac-2', + token: 'old', + machine: 'uuid-1', + }); + await assert.rejects( + lockStateFile(file, { hostname: 'Mac-2', machineId: 'uuid-2' }), + /locked by another gateway/, + ); + const release = await lockStateFile(file, { hostname: 'Totally-Different', machineId: 'uuid-1' }); + await release(); +}); + +test('puts back a live marker that a racing takeover moved aside', async (t) => { + const directory = await temporaryDirectory(t, 'multi-takeover-race-'); + const file = path.join(directory, 'session.lock'); + await writeMarker(file, { pid: 2147483647, hostname: hostname(), token: 'dead' }); + const live = { pid: process.pid, hostname: hostname(), token: 'live' }; + let raced = false; + await assert.rejects( + lockStateFile(file, { + machineId: undefined, + maxAttempts: 2, + rename: async (from, to) => { + if (!raced) { + raced = true; + // Another gateway wins the takeover and acquires before our rename lands. + await rm(file); + await writeMarker(file, live); + } + await rename(from, to); + }, + }), + /locked by another gateway/, + ); + assert.equal(JSON.parse(await readFile(file, 'utf8')).token, 'live'); +}); From 086c567f843141ff36052d405f48a5bd3417d5f3 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:38:10 -0400 Subject: [PATCH 06/36] Count actions past the tracking cap and prune reply archives Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .../src/gateway/harness-completion.ts | 44 ++++++++++++ .../src/gateway/harness-progress.ts | 51 +++++++++---- test/unit/native-executable.test.ts | 2 +- .../native-harness-completion-prune.test.ts | 72 +++++++++++++++++++ test/unit/native-harness-progress.test.ts | 22 ++++++ 5 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 test/unit/native-harness-completion-prune.test.ts diff --git a/plugins/multi-core/src/gateway/harness-completion.ts b/plugins/multi-core/src/gateway/harness-completion.ts index 28243c5..eb4e59e 100644 --- a/plugins/multi-core/src/gateway/harness-completion.ts +++ b/plugins/multi-core/src/gateway/harness-completion.ts @@ -1,3 +1,4 @@ +import { readdir, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import type { HarnessExchange } from './harness-exchange.ts'; import type { HarnessResponse } from './harness-response.ts'; @@ -65,6 +66,10 @@ export async function archiveHarnessReply; stateDirectory: string; platform: NodeJS.Platform; + /** Archives kept beyond the one just written; defaults to `archiveKeepCount`. */ + keep?: number; + maxAgeMs?: number; + now?: number; }): Promise { const { replay, response } = args.session.saved; if (!replay || !response) { @@ -75,4 +80,43 @@ export async function archiveHarnessReply { + try { + const names = (await readdir(directory)).filter((name) => name.endsWith('.response.json')); + const dated = await Promise.all( + names.map(async (name) => { + const file = path.join(directory, name); + const info = await stat(file).catch(() => undefined); + return info ? { file, time: info.mtimeMs } : undefined; + }), + ); + const present = dated + .filter((entry): entry is { file: string; time: number } => entry !== undefined) + .sort((left, right) => right.time - left.time); + const doomed = present.filter( + (entry, index) => index >= limits.keep || limits.now - entry.time > limits.maxAgeMs, + ); + await Promise.all(doomed.map((entry) => rm(entry.file, { force: true }).catch(() => {}))); + } catch { + // Pruning is housekeeping only. + } } diff --git a/plugins/multi-core/src/gateway/harness-progress.ts b/plugins/multi-core/src/gateway/harness-progress.ts index 39935d3..af9efab 100644 --- a/plugins/multi-core/src/gateway/harness-progress.ts +++ b/plugins/multi-core/src/gateway/harness-progress.ts @@ -35,7 +35,7 @@ export type NativeAction = { }; const maximumListed = 8; -const maximumTracked = 512; +export const maximumTracked = 512; const unsettledOutput = "The native run ended without reporting this action's completion."; /** @@ -48,12 +48,18 @@ export class NativeActionSummary { private readonly changed = new Set(); private readonly problems: string[] = []; private unconfirmed = 0; + private untracked = 0; private readonly tag: string; constructor(tag: string) { this.tag = tag; } + /** Counts an action past the tracking cap: seen, but with no row, outcome or change entry. */ + recordUntracked() { + this.untracked++; + } + /** * Counts one finished action; `changed` names what a successful edit touched. * An unconfirmed action is counted but never listed as a change. @@ -78,28 +84,40 @@ export class NativeActionSummary { } } - /** `modelCalls` is how many model calls the run made, when the harness reported them. */ - text(modelCalls = 0): string { - const total = [...this.counts.values()].reduce((sum, count) => sum + count, 0); - if (!total && modelCalls < 2) { - return ''; - } - const calls = modelCalls ? `${modelCalls} model call${modelCalls === 1 ? '' : 's'}` : ''; - const parts = [...this.counts].map(([kind, count]) => `${count} ${kind}`); - const actions = total - ? `${total} native action${total === 1 ? '' : 's'}: ${parts.join(', ')}` - : ''; - const lines = [`[${this.tag}] ${[actions, calls].filter(Boolean).join('; ')}.`]; + private notices(): string[] { + const lines: string[] = []; if (this.changed.size) { lines.push(`[${this.tag}] Changed: ${[...this.changed].join(', ')}.`); } if (this.problems.length) { lines.push(`[${this.tag}] Not completed: ${this.problems.join('; ')}.`); } + if (this.untracked) { + lines.push( + `[${this.tag}] Truncated: ${this.untracked} further action${this.untracked === 1 ? '' : 's'} beyond the first ${maximumTracked} were not tracked, so the counts above and any changes among them are incomplete.`, + ); + } if (this.unconfirmed) { const count = `${this.unconfirmed} action${this.unconfirmed === 1 ? '' : 's'}`; lines.push(`[${this.tag}] Unconfirmed: ${count} ended without a reported outcome.`); } + return lines; + } + + /** `modelCalls` is how many model calls the run made, when the harness reported them. */ + text(modelCalls = 0): string { + const total = [...this.counts.values()].reduce((sum, count) => sum + count, 0); + if (!total && !this.untracked && modelCalls < 2) { + return ''; + } + const calls = modelCalls ? `${modelCalls} model call${modelCalls === 1 ? '' : 's'}` : ''; + const parts = [...this.counts].map(([kind, count]) => `${count} ${kind}`); + const actions = total + ? `${total} native action${total === 1 ? '' : 's'}: ${parts.join(', ')}` + : ''; + const headline = [actions, calls].filter(Boolean).join('; '); + const lines = headline ? [`[${this.tag}] ${headline}.`] : []; + lines.push(...this.notices()); return `\n\n${lines.join('\n')}\n`; } } @@ -139,7 +157,12 @@ export class NativeActionTracker { /** Reports a new action once; a repeated start for the same id is ignored. */ start(id: string, action: NativeAction) { - if (this.actions.has(id) || this.actions.size >= maximumTracked) { + if (this.actions.has(id)) { + return; + } + if (this.actions.size >= maximumTracked) { + // Counted so the summary can say the record stopped; ids are not retained. + this.summary.recordUntracked(); return; } const description = displayLine(action.description) || action.tool || action.kind; diff --git a/test/unit/native-executable.test.ts b/test/unit/native-executable.test.ts index 48de200..a9478e3 100644 --- a/test/unit/native-executable.test.ts +++ b/test/unit/native-executable.test.ts @@ -4,8 +4,8 @@ import { argvSafe, executableInvocation, isCmdSafeArgument, - UnsafeCommandArgumentError, resolveExecutable, + UnsafeCommandArgumentError, } from '../../plugins/multi-core/src/gateway/executable.ts'; test('resolves Windows npm shims using PATHEXT order', () => { diff --git a/test/unit/native-harness-completion-prune.test.ts b/test/unit/native-harness-completion-prune.test.ts new file mode 100644 index 0000000..5007e69 --- /dev/null +++ b/test/unit/native-harness-completion-prune.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, utimes, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + archiveHarnessReply, + pruneArchives, +} from '../../plugins/multi-core/src/gateway/harness-completion.ts'; +import type { + HarnessSession, + HarnessSessionBase, +} from '../../plugins/multi-core/src/gateway/harness-session.ts'; +import { removeTemporary } from '../temporary.ts'; + +async function seedArchives(directory: string, count: number): Promise { + for (let index = 0; index < count; index += 1) { + const file = path.join(directory, `old${index}.response.json`); + await writeFile(file, '{}'); + const seconds = 1_000_000 + index; + await utimes(file, seconds, seconds); + } + await writeFile(path.join(directory, 'keep.session.json'), '{}'); +} + +test('pruneArchives keeps the newest archives and never touches other files', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'archive-prune-')); + t.after(() => removeTemporary(directory)); + await seedArchives(directory, 10); + await pruneArchives(directory, { + keep: 4, + maxAgeMs: Number.MAX_SAFE_INTEGER, + now: 2_000_000_000, + }); + const left = (await readdir(directory)).sort(); + assert.deepEqual(left, [ + 'keep.session.json', + 'old6.response.json', + 'old7.response.json', + 'old8.response.json', + 'old9.response.json', + ]); +}); + +test('pruneArchives drops archives older than the age limit', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'archive-age-')); + t.after(() => removeTemporary(directory)); + await seedArchives(directory, 3); + // mtimes are in seconds; `now` is in milliseconds. + await pruneArchives(directory, { keep: 10, maxAgeMs: 1000, now: 1_000_100_000 }); + assert.deepEqual(await readdir(directory), ['keep.session.json']); +}); + +test('archiveHarnessReply writes the archive and then prunes the directory', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'archive-turn-')); + t.after(() => removeTemporary(directory)); + await seedArchives(directory, 5); + const key = 'f'.repeat(64); + const session = { + saved: { replay: { key, events: [] }, response: { id: 'x' } }, + } as unknown as HarnessSession; + await archiveHarnessReply({ + session, + stateDirectory: directory, + platform: 'linux', + keep: 2, + maxAgeMs: Number.MAX_SAFE_INTEGER, + }); + const left = (await readdir(directory)).filter((name) => name.endsWith('.response.json')); + assert.equal(left.length, 2); + assert.ok(left.includes(`${key}.response.json`)); +}); diff --git a/test/unit/native-harness-progress.test.ts b/test/unit/native-harness-progress.test.ts index f29b3af..be37d0e 100644 --- a/test/unit/native-harness-progress.test.ts +++ b/test/unit/native-harness-progress.test.ts @@ -16,6 +16,7 @@ import { } from '../../plugins/multi-antigravity/src/progress.ts'; import { DisplayRows, ROW_TOKEN } from '../../plugins/multi-core/src/gateway/display-rows.ts'; import { + maximumTracked, NativeActionTracker, type NativeObservation, } from '../../plugins/multi-core/src/gateway/harness-progress.ts'; @@ -889,3 +890,24 @@ test('an action whose completion never arrives is unconfirmed: no success row, n assert.match(summary, /\[Native\] 2 native actions: 1 read, 1 edit\.\n/); assert.match(summary, /\[Native\] Unconfirmed: 1 action ended without a reported outcome\.\n/); }); + +test('actions past the tracking cap are counted and the summary says it truncated', () => { + const tracker = new NativeActionTracker('Test'); + const total = maximumTracked + 3; + for (let index = 0; index < total; index += 1) { + tracker.start(`a${index}`, { kind: 'read', tool: 'Read', input: {}, description: 'file' }); + tracker.finish(`a${index}`, { outcome: 'ok', error: false }); + } + const text = tracker.text(); + assert.match(text, new RegExp(`${maximumTracked} native actions: ${maximumTracked} read`)); + assert.match(text, /Truncated: 3 further actions beyond the first 512/); +}); + +test('a summary with only untracked actions still reports truncation', () => { + const tracker = new NativeActionTracker('Test'); + for (let index = 0; index < maximumTracked; index += 1) { + tracker.start(`a${index}`, { kind: 'read', tool: 'Read', input: {}, description: 'f' }); + } + tracker.start('extra', { kind: 'edit', tool: 'Edit', input: {}, description: 'f' }); + assert.match(tracker.text(), /Truncated: 1 further action beyond/); +}); From 9d837c30f64ffb3bbe07af325f5f9ef7399d05cb Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:38:29 -0400 Subject: [PATCH 07/36] Preserve unsupported-version native session records aside Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .../multi-core/src/gateway/harness-session.ts | 27 +++++++++++++++++-- test/unit/native-harness-session.test.ts | 24 ++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/plugins/multi-core/src/gateway/harness-session.ts b/plugins/multi-core/src/gateway/harness-session.ts index 6f262aa..19e548f 100644 --- a/plugins/multi-core/src/gateway/harness-session.ts +++ b/plugins/multi-core/src/gateway/harness-session.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; -import { mkdir, readFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { copyFile, mkdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { atomicWriteFile } from './atomic-write.ts'; import { isDisplayTool } from './display-rows.ts'; @@ -253,6 +254,9 @@ export class HarnessSessionStore = { @@ -289,7 +293,8 @@ export class HarnessSessionStore { + const version = isRecord(saved) ? String(saved.version ?? 'unversioned') : 'unknown'; + const label = version.replaceAll(/[^A-Za-z0-9._-]/g, '_').slice(0, 32); + try { + await copyFile(file, `${file}.unsupported-v${label}`, constants.COPYFILE_EXCL); + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; + if (code !== 'EEXIST' && code !== 'ENOENT') { + throw error; + } + } +} + function resolveStateFile(directory: string, file: string): string { return path.isAbsolute(file) ? file : path.join(directory, file); } diff --git a/test/unit/native-harness-session.test.ts b/test/unit/native-harness-session.test.ts index bc4e2f9..8cb330f 100644 --- a/test/unit/native-harness-session.test.ts +++ b/test/unit/native-harness-session.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -414,3 +414,25 @@ test('persisted state is validated before it is trusted', async (t) => { await writeFile(file, '{oops', 'utf8'); await assert.rejects(readJson(file)); }); + +test('an unsupported-version record is copied aside before the fresh record replaces it', async (t) => { + const { made, directory } = await store(t); + const file = made.sessionFile('worker-future'); + const original = JSON.stringify({ + version: 2, + provider: 'native', + identity: 'worker-future', + interrupted: false, + sessionId: 'native-from-the-future', + }); + await writeFile(file, original); + const lease = await made.acquireLease('worker-future'); + assert.equal(lease.session.saved.version, 1); + assert.equal(lease.session.saved.sessionId, undefined); + lease.session.saved.sessionId = 'fresh'; + await made.save(lease.session); + await lease.release(); + assert.equal(await readFile(`${file}.unsupported-v2`, 'utf8'), original); + assert.equal(JSON.parse(await readFile(file, 'utf8')).sessionId, 'fresh'); + assert.ok((await readdir(directory)).includes(path.basename(`${file}.unsupported-v2`))); +}); From 9e7055e63bb4da48af0c98fabf406fa255aa9696 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:41:18 -0400 Subject: [PATCH 08/36] Hold policy replies, evict idle mod sessions, and scope display tools per session The policy route can hold its reply until discovery ends (bounded), so a hook asks once instead of polling. Unconsumed policy jobs expire after a minute, and the policy and snapshot tables evict their least recently used idle session instead of refusing new ones until restart. Display tools are acknowledged per session, and ToolSearch tool_reference blocks naming them are stripped from forwarded history. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .../multi-core/src/gateway/display-rows.ts | 42 +++++++++-- plugins/multi-core/src/gateway/mod-bridge.ts | 22 +++++- plugins/multi-core/src/gateway/mod-policy.ts | 73 +++++++++++++++++-- plugins/multi-core/src/gateway/mod-routes.ts | 52 +++++++++---- test/unit/mod-policy.test.ts | 52 +++++++++++++ test/unit/native-cursor-progress.test.ts | 4 +- test/unit/native-display-rows.test.ts | 46 +++++++++++- test/unit/native-harness-progress.test.ts | 10 ++- test/unit/native-mod-routes.test.ts | 16 ++++ 9 files changed, 278 insertions(+), 39 deletions(-) diff --git a/plugins/multi-core/src/gateway/display-rows.ts b/plugins/multi-core/src/gateway/display-rows.ts index 6049c19..505320a 100644 --- a/plugins/multi-core/src/gateway/display-rows.ts +++ b/plugins/multi-core/src/gateway/display-rows.ts @@ -34,6 +34,7 @@ export const ROW_TOKEN = 'multi_row'; const maximumName = 64 - DISPLAY_TOOL_PREFIX.length; const maximumTools = 160; const maximumRows = 2048; +const maximumSessions = 128; const maximumFollowUps = 2048; const maximumOutput = 16 * 1024; const maximumInputValue = 2000; @@ -355,7 +356,8 @@ function remember(map: Map, key: string, value: T, capacity: numbe */ export class DisplayRows { private readonly known = new Set(); - private readonly registered = new Set(); + /** The names each session's mod acknowledged: another session registered nothing of these. */ + private readonly registered = new Map>(); private readonly rows = new Map(); /** The pending reply of each scope, and which scope wrote each of its rows. */ private readonly followUps = new Map(); @@ -377,17 +379,19 @@ export class DisplayRows { return { revision: this.revision, names: [...this.known] }; } - /** Records names the mod registered; only those are ever emitted as rows. */ - acknowledge(names: unknown) { + /** Records names a session's mod registered; only those are ever emitted as its rows. */ + acknowledge(session: string, names: unknown) { if (!Array.isArray(names)) { throw new Error('Invalid display tool acknowledgement'); } + const own = new Set(); for (const name of names) { if (typeof name === 'string' && this.known.has(name)) { - this.registered.add(name); + own.add(name); } } - return { registered: this.registered.size }; + remember(this.registered, session, own, maximumSessions); + return { registered: own.size }; } /** @@ -397,12 +401,12 @@ export class DisplayRows { */ issue(scope: string, row: NativeRow): DisplayToolUse | undefined { const name = displayName(row.tool); - if (!name || !this.registered.has(name)) { + const session = sessionOf(scope); + if (!name || !this.registered.get(session)?.has(name)) { return undefined; } const id = `toolu_multi_${randomUUID().replaceAll('-', '')}`; const token = randomBytes(16).toString('hex'); - const session = String(JSON.parse(scope)[0]); remember( this.rows, token, @@ -494,6 +498,7 @@ export class DisplayRows { } forgetSession(session: string) { + this.registered.delete(session); for (const [token, row] of this.rows) { if (row.session === session) { this.rows.delete(token); @@ -628,6 +633,9 @@ export function displayFollowUp(body: MessagesRequest): string[] | undefined { * ToolSearch catalogue line naming one, and the grant in a worker's tool list. */ function scrubbed(block: ContentBlock): ContentBlock { + if (block.type === 'tool_result') { + return withoutReferences(block); + } if ( block.type !== 'text' || typeof block.text !== 'string' || @@ -643,6 +651,26 @@ function scrubbed(block: ContentBlock): ContentBlock { return { ...block, text }; } +/** + * A ToolSearch result whose `tool_reference` blocks name display tools: the + * references are dropped, so no provider is told those tools exist. + */ +function withoutReferences(block: ContentBlock): ContentBlock { + const content = block.content; + if (!Array.isArray(content) || !content.some((item) => isReferenceToDisplay(item))) { + return block; + } + const kept = content.filter((item) => !isReferenceToDisplay(item)); + return { + ...block, + content: kept.length ? kept : [{ type: 'text', text: 'No matching deferred tools found.' }], + }; +} + +function isReferenceToDisplay(item: unknown): boolean { + return isObject(item) && item.type === 'tool_reference' && isDisplayTool(item.tool_name); +} + function withoutRows(message: RequestMessage, rows: ReadonlySet): RequestMessage { const content = blocks(message); if (!content) { diff --git a/plugins/multi-core/src/gateway/mod-bridge.ts b/plugins/multi-core/src/gateway/mod-bridge.ts index 0b0919a..37a9c5e 100644 --- a/plugins/multi-core/src/gateway/mod-bridge.ts +++ b/plugins/multi-core/src/gateway/mod-bridge.ts @@ -60,7 +60,24 @@ export class ModBridge { } mode(key: string) { - return this.snapshots.get(key); + const snapshot = this.snapshots.get(key); + if (snapshot) { + // A read counts as use: the map's order is the recency eviction reads. + this.snapshots.delete(key); + this.snapshots.set(key, snapshot); + } + return snapshot; + } + + /** Drops the least recently used snapshot whose scope has no running native run. */ + private evictSnapshot() { + const idle = [...this.snapshots.keys()].find( + (key) => this.lifecycle.get(key)?.state !== 'running', + ); + const victim = idle ?? this.snapshots.keys().next().value; + if (victim !== undefined) { + this.snapshots.delete(victim); + } } private record(key: string, value: { effective: Effective; cwd?: string; generation?: number }) { @@ -71,7 +88,7 @@ export class ModBridge { return undefined; } if (!this.snapshots.has(key) && this.snapshots.size >= MAX_KEYS) { - throw new Error('Mod session capacity reached; restart the gateway'); + this.evictSnapshot(); } const snapshot = { generation: ++this.generation, @@ -82,6 +99,7 @@ export class ModBridge { }, cwd: value.cwd, }; + this.snapshots.delete(key); this.snapshots.set(key, snapshot); return snapshot; } diff --git a/plugins/multi-core/src/gateway/mod-policy.ts b/plugins/multi-core/src/gateway/mod-policy.ts index c632ada..d900020 100644 --- a/plugins/multi-core/src/gateway/mod-policy.ts +++ b/plugins/multi-core/src/gateway/mod-policy.ts @@ -8,6 +8,9 @@ export type PreparedPolicy = { }; type Job = { cwd: string; + createdAt: number; + /** Resolves when discovery ends, ready or failed, so a waiting hook is released at once. */ + settled: Promise; consumed: boolean; generation: string; status: 'pending' | 'ready' | 'failed'; @@ -15,28 +18,55 @@ type Job = { error?: string; }; +/** An unconsumed job is reused for this long; settings read after that are read again. */ +const JOB_TTL_MS = 60_000; +const MAX_SESSIONS = 128; +/** The longest a hook's long poll holds its reply; the hook sends one request, not a loop. */ +export const POLICY_WAIT_MS = 8000; + /** File discovery runs detached from the bounded hook requests. */ export class ModPolicies { private inFlight = 0; private readonly jobs = new Map(); private readonly load: (cwd: string) => Promise; + private readonly now: () => number; - constructor(load: (cwd: string) => Promise) { + constructor(load: (cwd: string) => Promise, now: () => number = Date.now) { this.load = load; + this.now = now; } begin(session: string, cwd: string) { const existing = this.jobs.get(session); - if (existing && existing.cwd === cwd && !existing.consumed && existing.status !== 'failed') { + if ( + existing && + existing.cwd === cwd && + !existing.consumed && + existing.status !== 'failed' && + this.now() - existing.createdAt < JOB_TTL_MS + ) { return { generation: existing.generation, status: existing.status }; } if (this.inFlight >= 64) { throw new Error('Policy discovery capacity reached'); } - if (!this.jobs.has(session) && this.jobs.size >= 128) { - throw new Error('Policy session capacity reached'); + if (!this.jobs.has(session) && this.jobs.size >= MAX_SESSIONS) { + this.evictIdle(); } - const job: Job = { cwd, consumed: false, generation: randomUUID(), status: 'pending' }; + let settle = () => {}; + const settled = new Promise((resolve) => { + settle = resolve; + }); + const job: Job = { + cwd, + createdAt: this.now(), + settled, + consumed: false, + generation: randomUUID(), + status: 'pending', + }; + // Re-inserted last: the map's order is the recency eviction reads. + this.jobs.delete(session); this.jobs.set(session, job); this.inFlight++; void this.load(cwd).then( @@ -46,16 +76,49 @@ export class ModPolicies { job.policy = structuredClone(policy); job.status = 'ready'; } + settle(); }, (error: unknown) => { this.inFlight--; job.status = 'failed'; job.error = error instanceof Error ? error.message : 'Policy discovery failed'; + settle(); }, ); return { generation: job.generation, status: job.status }; } + /** Makes room by dropping the least recently begun session whose discovery has ended. */ + private evictIdle() { + for (const [session, job] of this.jobs) { + if (job.status !== 'pending') { + this.jobs.delete(session); + return; + } + } + throw new Error('Policy session capacity reached; every session is still discovering'); + } + + /** + * The status once discovery has ended, or still pending after `timeoutMs`: a hook asks + * once and the gateway holds the reply, so the wait costs the hook no polling loop. + */ + async wait(session: string, generation: string, timeoutMs = POLICY_WAIT_MS) { + const job = this.jobs.get(session); + if (job?.generation === generation && job.status === 'pending') { + let timer: ReturnType | undefined; + const expired = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }); + try { + await Promise.race([job.settled, expired]); + } finally { + clearTimeout(timer); + } + } + return this.status(session, generation); + } + status(session: string, generation: string) { const job = this.jobs.get(session); if (!job || job.generation !== generation) { diff --git a/plugins/multi-core/src/gateway/mod-routes.ts b/plugins/multi-core/src/gateway/mod-routes.ts index 0eb967f..97ce266 100644 --- a/plugins/multi-core/src/gateway/mod-routes.ts +++ b/plugins/multi-core/src/gateway/mod-routes.ts @@ -148,21 +148,7 @@ async function handlePostRoute( } switch (route) { case '/multi/mod/policy': - if (parsed.generation === undefined) { - if (parsed.sourceGeneration !== bridge.mode(key)?.generation) { - throw new Error('Policy source generation is stale'); - } - compactions?.cancel(sessionId); - } - if (!permissionModes) { - throw new Error('Policy admission is unavailable'); - } - return reply( - res, - parsed.generation === undefined - ? permissionModes.beginPolicy(sessionId, text(parsed.cwd, 'cwd')) - : permissionModes.policies.status(sessionId, text(parsed.generation, 'generation')), - ); + return await policyRoute(res, parsed, key, bridge, permissionModes, compactions); case '/multi/mod/detach': compactions?.cancel(sessionId); bridge.forgetSession(sessionId); @@ -195,6 +181,40 @@ async function handlePostRoute( } } +/** + * Starts a policy discovery for a prompt, or reports one: with `wait` the reply is held + * until discovery ends (bounded), so a hook asks once instead of polling. + */ +async function policyRoute( + res: ServerResponse, + parsed: Record, + key: string, + bridge: ModBridge, + permissionModes?: PermissionModes, + compactions?: ModCompactions, +) { + const sessionId = text(parsed.sessionId, 'sessionId'); + if (parsed.generation === undefined) { + if (parsed.sourceGeneration !== bridge.mode(key)?.generation) { + throw new Error('Policy source generation is stale'); + } + compactions?.cancel(sessionId); + } + if (!permissionModes) { + throw new Error('Policy admission is unavailable'); + } + if (parsed.generation === undefined) { + return reply(res, permissionModes.beginPolicy(sessionId, text(parsed.cwd, 'cwd'))); + } + const generation = text(parsed.generation, 'generation'); + return reply( + res, + parsed.wait === true + ? await permissionModes.policies.wait(sessionId, generation) + : permissionModes.policies.status(sessionId, generation), + ); +} + /** * Display rows: the mod acknowledges the display tools it registered, and * answers a row's call with the native output only for a token this gateway @@ -216,7 +236,7 @@ function displayRoute( throw new Error('Display rows are unavailable'); } if (route === '/multi/mod/display-tools') { - return reply(res, rows.acknowledge(parsed.registered)); + return reply(res, rows.acknowledge(sessionId, parsed.registered)); } const row = rows.verify(sessionId, parsed.token, parsed.toolUseId); if (!row) { diff --git a/test/unit/mod-policy.test.ts b/test/unit/mod-policy.test.ts index ba26eed..993ff36 100644 --- a/test/unit/mod-policy.test.ts +++ b/test/unit/mod-policy.test.ts @@ -429,3 +429,55 @@ test('a planning parent binds every worker, whatever mode it was spawned or defi assert.equal(modes.resolve('s', 'explicit').permissionMode, 'bypassPermissions'); assert.throws(() => modes.planning('s', 'unregistered'), /unavailable/); }); + +test('a hook long-polls one request that returns when discovery ends', async () => { + let finish: ((value: PreparedPolicy) => void) | undefined; + const store = new ModPolicies( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const job = store.begin('s', '/workspace'); + const waiting = store.wait('s', job.generation, 5000); + finish?.(policy); + assert.equal((await waiting).status, 'ready'); + // A wait that outlasts its bound answers pending instead of blocking the hook. + const slow = store.begin('t', '/workspace'); + assert.equal((await store.wait('t', slow.generation, 5)).status, 'pending'); + await assert.rejects(store.wait('t', 'other', 5), /stale/); +}); + +test('an unconsumed policy job expires instead of being reused indefinitely', async () => { + let clock = 1000; + let discoveries = 0; + const store = new ModPolicies( + async () => { + discoveries++; + return policy; + }, + () => clock, + ); + const first = store.begin('s', '/workspace'); + await setImmediate(); + clock += 59_000; + assert.equal(store.begin('s', '/workspace').generation, first.generation); + clock += 2_000; + assert.notEqual(store.begin('s', '/workspace').generation, first.generation); + assert.equal(discoveries, 2); +}); + +test('policy sessions beyond capacity evict the least recent idle one', async () => { + const store = new ModPolicies(async () => policy); + const first = store.begin('session-0', '/workspace'); + for (let index = 1; index < 128; index++) { + store.begin(`session-${index}`, '/workspace'); + if (index % 32 === 0) { + await setImmediate(); + } + } + await setImmediate(); + const added = store.begin('session-new', '/workspace'); + assert.equal(added.status, 'pending'); + assert.throws(() => store.status('session-0', first.generation), /stale/); +}); diff --git a/test/unit/native-cursor-progress.test.ts b/test/unit/native-cursor-progress.test.ts index e5b3574..082dedf 100644 --- a/test/unit/native-cursor-progress.test.ts +++ b/test/unit/native-cursor-progress.test.ts @@ -238,7 +238,7 @@ test('an edit row takes its two sides from the diff, a created file becomes a Wr }); const rows = new DisplayRows(); rows.announce(['edit']); - rows.acknowledge(['edit']); + rows.acknowledge('s', ['edit']); const issued: Array> = []; const tracker = new NativeActionTracker( 'Cursor', @@ -401,7 +401,7 @@ test('a Cursor action becomes a display row under its native name; later text is }); const rows = new DisplayRows(); rows.announce(CURSOR_TOOLS); - rows.acknowledge(['read']); + rows.acknowledge('session', ['read']); const scope = JSON.stringify(['session', 'worker']); const events: Array<[string, unknown]> = []; const emit: Emit = (name, value) => { diff --git a/test/unit/native-display-rows.test.ts b/test/unit/native-display-rows.test.ts index 0b8041f..e723986 100644 --- a/test/unit/native-display-rows.test.ts +++ b/test/unit/native-display-rows.test.ts @@ -45,7 +45,7 @@ test('rows are issued only for registered native tools, with bounded native inpu error: false, }; assert.equal(rows.issue(scope, native), undefined, 'unregistered tools write no row'); - rows.acknowledge(['run_command', 'never_offered']); + rows.acknowledge('session', ['run_command', 'never_offered']); const issued = rows.issue(scope, { ...native, input: { CommandLine: 'y'.repeat(5000), [ROW_TOKEN]: 'forged', nested: { a: 1 } }, @@ -116,7 +116,7 @@ test('captured agy steps mirror Read and Bash, the native parameters kept under } const rows = new DisplayRows(); rows.announce(['run_command']); - rows.acknowledge(['run_command']); + rows.acknowledge('session', ['run_command']); const failed = steps.find((step) => step.info.parameters.CommandLine === 'cat /nonexistent/file'); assert.ok(failed); const issued = rows.issue(JSON.stringify(['session']), { @@ -274,7 +274,7 @@ test('fields derived from a result take precedence, and may name another built-i assert.equal(String(long.new_string).length, 16 * 1024 + 1); const rows = new DisplayRows(); rows.announce(['edit']); - rows.acknowledge(['edit']); + rows.acknowledge('s', ['edit']); const issued = rows.issue('["s"]', { tool: 'edit', input: created, @@ -514,3 +514,43 @@ test('the gateway forwards Anthropic requests without display tools or rows', as content: [{ type: 'text', text: 'Now summarise.' }], }); }); + +test('a session registers its own display tools and ToolSearch references are stripped', () => { + const rows = new DisplayRows(); + rows.announce(['run_command']); + rows.acknowledge('one', ['run_command']); + const native = { tool: 'run_command', input: {}, output: 'ok', error: false }; + assert.ok(rows.issue('["one","main"]', native)); + assert.equal( + rows.issue('["two","main"]', native), + undefined, + 'another session registered nothing', + ); + rows.forgetSession('one'); + assert.equal(rows.issue('["one","main"]', native), undefined); + const search = withoutDisplayTools({ + model: 'x', + messages: [ + { role: 'user', content: 'find tools' }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_search', name: 'ToolSearch', input: {} }], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_search', + content: [ + { type: 'tool_reference', tool_name: 'mcp__multi-core__run_command' }, + { type: 'tool_reference', tool_name: 'WebFetch' }, + ], + }, + ], + }, + ], + }); + assert.doesNotMatch(JSON.stringify(search), /mcp__multi-core/); + assert.match(JSON.stringify(search), /WebFetch/); +}); diff --git a/test/unit/native-harness-progress.test.ts b/test/unit/native-harness-progress.test.ts index f29b3af..b1fe80f 100644 --- a/test/unit/native-harness-progress.test.ts +++ b/test/unit/native-harness-progress.test.ts @@ -397,12 +397,12 @@ async function gateway(t: test.TestContext, options: Parameters response.json())) as Record, /** What the mod does at session start: read the catalog, register, acknowledge. */ - register: async () => { + register: async (sessionId = 'progress-session') => { const catalog = (await fetch(`${base}/multi/mod/display-tools`, { headers }).then( (response) => response.json(), )) as { names: string[] }; await post('/multi/mod/display-tools', { - sessionId: 'progress-session', + sessionId, registered: catalog.names, }); return catalog.names; @@ -766,7 +766,8 @@ test('a follow-up is answered only to the session and worker whose reply wrote t displayTools: { antigravity: ANTIGRAVITY_TOOLS }, antigravity: admitted(harness), }); - await register(); + await register('session-a'); + await register('session-b'); const answer = (await ( await send(agyModel.model, 'agy-worker', undefined, 'session-a') ).json()) as MessagesResponse; @@ -825,7 +826,8 @@ test('a follow-up is recovered from the harness record after a restart, and fail displayTools: { antigravity: ANTIGRAVITY_TOOLS }, antigravity: admitted(first), }); - await before.register(); + await before.register('session-a'); + await before.register('session-b'); const answer = (await ( await before.send(agyModel.model, 'agy-worker', undefined, 'session-a') ).json()) as MessagesResponse; diff --git a/test/unit/native-mod-routes.test.ts b/test/unit/native-mod-routes.test.ts index 4e6af39..849f889 100644 --- a/test/unit/native-mod-routes.test.ts +++ b/test/unit/native-mod-routes.test.ts @@ -496,3 +496,19 @@ test('wrong tokens cannot update sessions, acknowledge workers, or authorize com assert.equal(rejectedCompaction.status, 401); assert.throws(() => modes.resolve('unauthorized'), /unavailable/); }); + +test('snapshots beyond capacity evict the least recently used idle scope', () => { + const bridge = new ModBridge(); + const effective = { permissionMode: 'default' }; + for (let index = 0; index < 128; index++) { + bridge.recordSession(JSON.stringify([`s${index}`, 'main']), { effective }); + } + const running = JSON.stringify(['s0', 'main']); + bridge.begin(running, 'multi/cursor/auto'); + // Reading s1 makes it recent, so the next oldest idle scope (s2) is evicted. + assert.ok(bridge.mode(JSON.stringify(['s1', 'main']))); + assert.ok(bridge.recordSession(JSON.stringify(['fresh', 'main']), { effective })); + assert.ok(bridge.mode(running), 'a running scope keeps its snapshot'); + assert.ok(bridge.mode(JSON.stringify(['s1', 'main']))); + assert.equal(bridge.mode(JSON.stringify(['s2', 'main'])), undefined); +}); From d9e40e2164fe86eb8482c7fdcee9a581a28a6acd Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:44:25 -0400 Subject: [PATCH 09/36] Make Antigravity permissions an allowlist and protect its hook files Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- docs/antigravity.md | 15 ++- plugins/multi-antigravity/src/permissions.ts | 120 +++++++++++++++++- test/unit/native-antigravity-hook.test.ts | 12 +- .../native-antigravity-permissions.test.ts | 83 ++++++++++++ 4 files changed, 220 insertions(+), 10 deletions(-) diff --git a/docs/antigravity.md b/docs/antigravity.md index 6f54bf9..ef7a5ae 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -68,8 +68,12 @@ Claude's window governs when the session compacts. It is separate from the nativ Claude's permission mode and tool rules take precedence. `agy` runs with native permissions skipped. The namespaced global hook reads `MULTI_ANTIGRAVITY_DENY` -and denies Claude-excluded native tools. Native children and MCP tools are always -denied. Each finished tool step is a row under agy's own tool name (`view_file`, +and `MULTI_ANTIGRAVITY_ALLOW`. Every mode is an allowlist: only the native tools Claude's +rules grant, plus the `finish` and `wait` control tools, run, so a tool `agy` announces +that no Claude tool maps to (for example `schedule`, `send_message`, `generate_image`) +stays denied. Browser tools follow the `WebFetch` grant. Native children, MCP-like +resource tools, scheduling and messaging are always denied, and write tools can never +target `agy`'s own config tree (`~/.gemini`), where the hook lives. Each finished tool step is a row under agy's own tool name (`view_file`, `run_command`, ...) with its native parameters and `tool_info.output`, in the `/model` Antigravity session or inside the Antigravity worker's transcript; the names come from agy's `init` toolset. Rows are never replayed as executable Claude @@ -77,7 +81,8 @@ tools. `agy` does not distinguish a failed tool step: a failing command such as `cat /nonexistent/file` ends `DONE` with the error only in its output and no exit code, so the row shows as done and its output carries the error; Multi does not guess failure from the text. The transcript keeps streamed text, run diagnostics, -and one closing summary of action counts, changed files, and failed steps. Claude and OpenAI parents can spawn Antigravity +and one closing summary of action counts, changed files, and failed steps; timing and +billing are shown by receipts, not written into the text. Claude and OpenAI parents can spawn Antigravity workers. Every run selects its native workspace explicitly with `--add-dir`; subprocess @@ -137,7 +142,9 @@ refuse the same way. | Windows | `%LOCALAPPDATA%\gemini\config\hooks.json` (or `%APPDATA%`) | `%LOCALAPPDATA%\gemini\antigravity-cli\settings.json` (or `%APPDATA%`) | The installed hook is named `multi-cli-antigravity`. Setup preserves other hook -entries. Run setup again after moving the checkout or changing the Node install. +entries. Every launch with Antigravity enabled rewrites the entry, so it follows a plugin +update; run setup again after moving the checkout or changing the Node install. +`multi uninstall` removes the entry. ## Limits diff --git a/plugins/multi-antigravity/src/permissions.ts b/plugins/multi-antigravity/src/permissions.ts index 7d3d3ec..ae1bb63 100644 --- a/plugins/multi-antigravity/src/permissions.ts +++ b/plugins/multi-antigravity/src/permissions.ts @@ -1,7 +1,24 @@ +import os from 'node:os'; +import path from 'node:path'; import { nativeToolRules } from '../../multi-core/src/gateway/display-rows.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; /** Native names reachable from each mapped Claude tool. */ +/** The agy config roots that hold hooks and settings; see antigravityProtectedRoots. */ +export function antigravityProtectedRoots( + options: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; homedir?: string } = {}, +): string[] { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.homedir ?? os.homedir(); + const join = platform === 'win32' ? path.win32.join : path.posix.join; + const roots = [join(home, '.gemini')]; + if (platform === 'win32') { + roots.push(join(env.LOCALAPPDATA ?? env.APPDATA ?? join(home, 'AppData', 'Local'), 'gemini')); + } + return roots; +} + const TOOL_MAP = { Read: ['view_file', 'list_dir'], Grep: ['grep_search'], @@ -9,7 +26,31 @@ const TOOL_MAP = { Bash: ['run_command', 'command_status', 'send_command_input'], Write: ['write_to_file'], Edit: ['replace_file_content', 'multi_replace_file_content', 'sed_file'], - WebFetch: ['read_url_content'], + WebFetch: [ + 'read_url_content', + 'open_browser_url', + 'read_browser_page', + 'list_browser_pages', + 'execute_browser_javascript', + 'capture_browser_console_logs', + 'capture_browser_screenshot', + 'click_browser_pixel', + 'browser_click_element', + 'browser_drag_pixel_to_pixel', + 'browser_get_dom', + 'browser_get_network_request', + 'browser_input', + 'browser_list_network_requests', + 'browser_mouse_down', + 'browser_mouse_up', + 'browser_move_mouse', + 'browser_press_key', + 'browser_refresh_page', + 'browser_resize_window', + 'browser_scroll', + 'browser_scroll_dom', + 'browser_select_option', + ], WebSearch: ['search_web'], NotebookEdit: ['notebook_edit'], } as const; @@ -25,10 +66,21 @@ const PLAN_READ_ONLY = [ ...TOOL_MAP.Read, ...TOOL_MAP.Grep, ...TOOL_MAP.Glob, - ...TOOL_MAP.WebFetch, + // Plan reads pages as text; driving a browser can submit forms and run scripts. + 'read_url_content', ...TOOL_MAP.WebSearch, ]; +/** Control tools agy needs to finish a turn; they act on nothing outside the conversation. */ +const CONTROL_TOOLS = ['finish', 'wait', 'wait_5_seconds']; + +/** Native tools whose parameters name files they write. */ +const WRITING_TOOLS = new Set([ + ...TOOL_MAP.Write, + ...TOOL_MAP.Edit, + ...TOOL_MAP.NotebookEdit, +]); + /** Native child-agent and MCP tools; agy runs with permissions skipped, so these are always denied. */ const ALWAYS_DENIED = [ 'invoke_subagent', @@ -37,6 +89,16 @@ const ALWAYS_DENIED = [ 'browser_subagent', 'call_mcp_tool', 'notebook_execution', + // Messaging, scheduling, task and resource tools are child-agent or MCP-like surfaces. + 'schedule', + 'manage_task', + 'send_message', + 'manage_inbox', + 'list_resources', + 'read_resource', + // No Claude tool maps to these side effects. + 'generate_image', + 'delete_knowledge', ]; export interface AntigravityPolicy { @@ -72,9 +134,11 @@ export function antigravityPermissionPolicy(context: PermissionContext): Antigra } } } + // Always an allowlist: a tool agy announces later, or one no Claude tool maps to, stays denied. + const mapped = plan ? PLAN_READ_ONLY : Object.values(TOOL_MAP).flat(); return { denied: [...denied], - ...(plan ? { allowed: PLAN_READ_ONLY.filter((tool) => !denied.has(tool)) } : {}), + allowed: [...mapped.filter((tool) => !denied.has(tool)), ...CONTROL_TOOLS], plan, notice: policyNotice(context.permissionMode), }; @@ -125,11 +189,51 @@ function toolList(serialized: string): string[] { return tools; } +function stringValues(value: unknown, depth = 0): string[] { + if (typeof value === 'string') { + return [value]; + } + if (depth > 4 || !value || typeof value !== 'object') { + return []; + } + return Object.values(value).flatMap((item) => stringValues(item, depth + 1)); +} + +function inside(parent: string, child: string, platform: NodeJS.Platform): boolean { + const lib = platform === 'win32' ? path.win32 : path.posix; + const fold = (value: string) => (platform === 'win32' ? value.toLowerCase() : value); + const relative = lib.relative(fold(parent), fold(child)); + return relative === '' || (!relative.startsWith('..') && !lib.isAbsolute(relative)); +} + +/** + * The permission hook lives in agy's user-writable config tree and is read once per turn, so a + * granted write tool must never target that tree. Shell commands are not path-checked here; + * Bash is a separate grant. + */ +function targetsProtectedPath( + call: { name: string; parameters?: unknown }, + options: { protectedRoots: string[]; platform?: NodeJS.Platform; cwd?: string }, +): boolean { + if (!WRITING_TOOLS.has(call.name)) { + return false; + } + const platform = options.platform ?? process.platform; + const lib = platform === 'win32' ? path.win32 : path.posix; + const cwd = options.cwd ?? process.cwd(); + return stringValues(call.parameters).some((value) => { + const expanded = value.startsWith('~') ? `${os.homedir()}${value.slice(1)}` : value; + const resolved = lib.resolve(cwd, expanded); + return options.protectedRoots.some((root) => inside(lib.resolve(root), resolved, platform)); + }); +} + /** No policy means an ordinary native CLI session, outside this gateway. */ export function antigravityToolDecision( input: unknown, serializedPolicy?: string, serializedAllowed?: string, + options: { protectedRoots?: string[] } = {}, ) { if (serializedPolicy === undefined) { return undefined; @@ -144,7 +248,15 @@ export function antigravityToolDecision( if (!call || typeof call !== 'object' || !('name' in call) || typeof call.name !== 'string') { throw new Error('Invalid native tool call'); } - if (denied.includes(call.name) || (allowed && !allowed.includes(call.name))) { + const parameters = 'parameters' in call ? call.parameters : undefined; + if ( + denied.includes(call.name) || + (allowed && !allowed.includes(call.name)) || + targetsProtectedPath( + { name: call.name, parameters }, + { protectedRoots: options.protectedRoots ?? antigravityProtectedRoots() }, + ) + ) { return { decision: 'deny', reason: 'Claude session policy excludes this native Antigravity tool.', diff --git a/test/unit/native-antigravity-hook.test.ts b/test/unit/native-antigravity-hook.test.ts index 5ac5ec4..c6446ab 100644 --- a/test/unit/native-antigravity-hook.test.ts +++ b/test/unit/native-antigravity-hook.test.ts @@ -103,7 +103,15 @@ test('plan policy allows only read-only native tools that Claude rules keep', () }); assert.deepEqual( new Set(policy.allowed), - new Set(['view_file', 'list_dir', 'grep_search', 'find_by_name', 'search_web']), + new Set([ + 'view_file', + 'list_dir', + 'grep_search', + 'find_by_name', + 'search_web', + 'finish', + 'wait', + 'wait_5_seconds', + ]), ); - assert.equal(antigravityPermissionPolicy({ permissionMode: 'auto' }).allowed, undefined); }); diff --git a/test/unit/native-antigravity-permissions.test.ts b/test/unit/native-antigravity-permissions.test.ts index 23f3f35..c7ffbe2 100644 --- a/test/unit/native-antigravity-permissions.test.ts +++ b/test/unit/native-antigravity-permissions.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { antigravityPermissionPolicy, + antigravityProtectedRoots, antigravityToolDecision, } from '../../plugins/multi-antigravity/src/permissions.ts'; @@ -12,6 +13,14 @@ const ALWAYS_DENIED = [ 'browser_subagent', 'call_mcp_tool', 'notebook_execution', + 'schedule', + 'manage_task', + 'send_message', + 'manage_inbox', + 'list_resources', + 'read_resource', + 'generate_image', + 'delete_knowledge', ]; test('Antigravity Auto denies excluded Claude tools and always denies delegation/MCP', () => { @@ -33,6 +42,7 @@ test('Bypass carries the same denylist shape as Auto', () => { assert.match(bypass.notice, /Claude Code rules take precedence/); assert.deepEqual(bypass.denied, ALWAYS_DENIED); assert.deepEqual(bypass.denied, auto.denied); + assert.deepEqual(bypass.allowed, auto.allowed); assert.equal(bypass.notice, auto.notice); }); @@ -70,6 +80,79 @@ test('an explicit tools allowlist denies natives for mapped tools left out of it assert(policy.denied.includes('notebook_edit')); }); +test('outside Plan the policy is an allowlist, so unmapped announced tools stay denied', () => { + const policy = antigravityPermissionPolicy({ + permissionMode: 'auto', + tools: ['Read', 'Grep', 'Glob'], + }); + const allowed = new Set(policy.allowed); + for (const name of ['view_file', 'list_dir', 'grep_search', 'find_by_name', 'finish', 'wait']) { + assert(allowed.has(name), `expected ${name} to be allowed`); + } + for (const name of [ + 'execute_browser_javascript', + 'open_browser_url', + 'read_browser_page', + 'schedule', + 'send_message', + 'manage_inbox', + 'manage_task', + 'delete_knowledge', + 'generate_image', + 'list_resources', + 'read_resource', + 'run_command', + 'write_to_file', + 'a_tool_agy_adds_later', + ]) { + assert(!allowed.has(name), `expected ${name} outside the allowlist`); + } + const decision = antigravityToolDecision( + { toolCall: { name: 'a_tool_agy_adds_later' } }, + JSON.stringify(policy.denied), + JSON.stringify(policy.allowed), + ); + assert.equal(decision?.decision, 'deny'); +}); + +test('browser tools follow the WebFetch grant', () => { + const withWeb = antigravityPermissionPolicy({ + permissionMode: 'auto', + tools: ['Read', 'WebFetch'], + }); + assert(withWeb.allowed?.includes('open_browser_url')); + assert(withWeb.allowed?.includes('execute_browser_javascript')); + const without = antigravityPermissionPolicy({ + permissionMode: 'auto', + disallowedTools: ['WebFetch'], + }); + assert(without.denied.includes('open_browser_url')); +}); + +test('write tools never target the agy hook and settings tree', () => { + const roots = antigravityProtectedRoots({ platform: 'linux', homedir: '/home/u' }); + const policyJson = JSON.stringify([]); + const deny = (name: string, parameters: unknown) => + antigravityToolDecision( + { toolCall: { name, parameters } }, + policyJson, + JSON.stringify([name]), + { protectedRoots: roots }, + ); + for (const [name, parameters] of [ + ['write_to_file', { TargetFile: '/home/u/.gemini/config/hooks.json' }], + [ + 'replace_file_content', + { TargetFile: '/home/u/.gemini/../.gemini/antigravity-cli/settings.json' }, + ], + ['sed_file', { AbsolutePath: '/work/../home/u/.gemini/config/x' }], + ] as const) { + assert.equal(deny(name, parameters)?.decision, 'deny', name); + } + assert.equal(deny('write_to_file', { TargetFile: '/work/project/a.txt' }), undefined); + assert.equal(deny('view_file', { AbsolutePath: '/home/u/.gemini/config/hooks.json' }), undefined); +}); + test('rejects unsupported permission modes and rule shapes', () => { assert.throws(() => antigravityPermissionPolicy({ permissionMode: 'default' }), /unsupported/); assert.throws(() => antigravityPermissionPolicy({ permissionMode: 'dontAsk' }), /unsupported/); From a88694ef7952ebd95e62c4eff3030522dbe7526d Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:44:25 -0400 Subject: [PATCH 10/36] Keep Antigravity interruption state, evict idle records, remove hook on uninstall Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-antigravity/src/harness.ts | 37 ++++--- plugins/multi-antigravity/src/models.ts | 14 ++- .../multi-core/src/install/installation.ts | 47 +++++++++ test/unit/native-antigravity-harness.test.ts | 97 ++++++++++++++++++- test/unit/native-antigravity-hooks.test.ts | 15 +++ test/unit/native-antigravity-models.test.ts | 11 +++ 6 files changed, 203 insertions(+), 18 deletions(-) diff --git a/plugins/multi-antigravity/src/harness.ts b/plugins/multi-antigravity/src/harness.ts index 28d4be8..a651aeb 100644 --- a/plugins/multi-antigravity/src/harness.ts +++ b/plugins/multi-antigravity/src/harness.ts @@ -20,6 +20,7 @@ import { terminalSuffix, writeNotices, } from '../../multi-core/src/gateway/harness-notices.ts'; +import { NativeCliError } from '../../multi-core/src/gateway/harness-process.ts'; import { NativeActionTracker, type NativeProgressObserver, @@ -276,6 +277,8 @@ export class AntigravityHarness { return await this.execute(turn, session, exchange, emit); } finally { await lease.release(); + // The durable record holds everything; an idle attachment only pins its lock and replay events. + await this.store.evictIdle(session); } } @@ -348,7 +351,6 @@ export class AntigravityHarness { signal.throwIfAborted(); let streamed = ''; let initSave: Promise | undefined; - const startedAt = performance.now(); // Each finished tool step becomes a display row in this reply, named after // agy's own tool; the terse summary is written when the run commits. const actions = new NativeActionTracker(TAG, turn.observe, (block) => @@ -393,10 +395,13 @@ export class AntigravityHarness { ); await initSave; const result = outcome.result; - appendDiagnostics(response, result, outcome.stderr, performance.now() - startedAt); + appendDiagnostics(response, result, outcome.stderr); if (result.status !== 'SUCCESS') { session.saved.conversationId = result.conversation_id; - session.saved.interrupted = false; + // A failed run may still have executed steps. Keep the interruption flag so a retry + // tells the native conversation what may already have happened. + session.saved.interrupted = + initConversationId !== undefined || streamed.length > 0 || calls.count > 0; await this.store.save(session); throw new AntigravityProviderError(result.error ?? `Antigravity run ${result.status}`); } @@ -502,15 +507,7 @@ export class AntigravityHarness { } } -function appendDiagnostics( - response: HarnessResponse, - result: AntigravityResult, - stderr: string, - elapsedMs: number, -) { - if (Number.isFinite(elapsedMs)) { - response.text(`[${TAG}] completed in ${(Math.max(0, elapsedMs) / 1000).toFixed(1)}s\n`); - } +function appendDiagnostics(response: HarnessResponse, result: AntigravityResult, stderr: string) { const denied = result.denied_actions; if (Array.isArray(denied) && denied.length) { const detail = denied @@ -595,6 +592,20 @@ const missingPermissions: CheckAntigravityPermissions = async () => { throw new Error('Antigravity native permission policy is not configured'); }; +/** A transient process shortage retries; a missing binary or a busy agent fails identically. */ +const TRANSIENT_SPAWN = new Set(['EAGAIN', 'EMFILE', 'ENFILE', 'ENOMEM', 'ETXTBSY']); + +function deterministicFailure(error: unknown): boolean { + if (error instanceof HarnessBusyError) { + return true; + } + return ( + error instanceof NativeCliError && + error.code === 'spawn' && + !TRANSIENT_SPAWN.has(error.systemCode ?? '') + ); +} + export class AntigravityProviderError extends Error { readonly failure: { status: number; message: string }; constructor(error: unknown) { @@ -607,6 +618,6 @@ export class AntigravityProviderError extends Error { this.name = 'AntigravityProviderError'; // A busy agent is a deterministic conflict, not a transient fault: a // retryable status turned one such refusal into ten paid attempts. - this.failure = { status: error instanceof HarnessBusyError ? 400 : 502, message }; + this.failure = { status: deterministicFailure(error) ? 400 : 502, message }; } } diff --git a/plugins/multi-antigravity/src/models.ts b/plugins/multi-antigravity/src/models.ts index 3528bdf..2c74bc2 100644 --- a/plugins/multi-antigravity/src/models.ts +++ b/plugins/multi-antigravity/src/models.ts @@ -101,14 +101,24 @@ export function antigravityDefaultWorkerModel(ids: readonly string[]): string | if (!match) { continue; } - const rank = [Number(match[1]), Number(match[2] ?? 0), id.includes('-pro') ? 1 : 0]; - if (!best || compareRank(rank, best.rank) > 0) { + // Pro before Flash, Flash before Flash-Lite; equal ranks fall to the greater id so the + // pick never depends on the order `agy models` lists. + const rank = [Number(match[1]), Number(match[2] ?? 0), modelTier(id)]; + const order = best ? compareRank(rank, best.rank) : 1; + if (!best || order > 0 || (order === 0 && id > best.id)) { best = { id, rank }; } } return best?.id; } +function modelTier(id: string): number { + if (id.includes('-pro')) { + return 2; + } + return id.includes('-lite') ? 0 : 1; +} + function compareRank(left: readonly number[], right: readonly number[]): number { for (let index = 0; index < left.length; index++) { const difference = (left[index] ?? 0) - (right[index] ?? 0); diff --git a/plugins/multi-core/src/install/installation.ts b/plugins/multi-core/src/install/installation.ts index ee833ef..d75c1b6 100644 --- a/plugins/multi-core/src/install/installation.ts +++ b/plugins/multi-core/src/install/installation.ts @@ -52,6 +52,7 @@ type DeferDeletion = (command: string, args: string[], options: SpawnOptions) => export interface UninstallOptions { platform?: Platform; + homedir?: string; env?: NodeJS.ProcessEnv; deferDeletion?: DeferDeletion; } @@ -396,6 +397,46 @@ export async function setup( return state; } +/** + * Remove the `multi-cli-antigravity` entry from agy's global hooks file. This file is copied + * beside the installed shims and must run after the plugin is gone, so it cannot import the + * plugin's own path helpers; the location logic mirrors `antigravityHookFile`. + */ +export async function removeAntigravityHook( + options: { platform?: Platform; env?: NodeJS.ProcessEnv; homedir?: string } = {}, +): Promise { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.homedir ?? os.homedir(); + const lib = platform === 'win32' ? path.win32 : path.posix; + const root = + platform === 'win32' + ? lib.join(env.LOCALAPPDATA ?? env.APPDATA ?? lib.join(home, 'AppData', 'Local'), 'gemini') + : lib.join(home, '.gemini'); + const file = lib.join(root, 'config', 'hooks.json'); + let hooks: unknown; + try { + hooks = JSON.parse(await readFile(file, 'utf8')); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return false; + } + throw error; + } + if ( + !hooks || + typeof hooks !== 'object' || + Array.isArray(hooks) || + !('multi-cli-antigravity' in hooks) + ) { + return false; + } + const remaining = { ...hooks } as Record; + delete remaining['multi-cli-antigravity']; + await writeFile(file, `${JSON.stringify(remaining, null, 2)}\n`, { mode: 0o600 }); + return true; +} + export async function uninstall( directory = installationDirectory(), options: UninstallOptions = {}, @@ -404,6 +445,12 @@ export async function uninstall( const state = await readInstallation(directory); const source = await readFile(state.shellFile, 'utf8'); await writeFile(state.shellFile, removeBlock(source, state.block)); + try { + // The Antigravity permission hook is global to the machine and points into this plugin. + await removeAntigravityHook({ platform, env: options.env, homedir: options.homedir }); + } catch (error) { + console.warn(`Could not remove the Antigravity hook: ${String(error)}`); + } const shimFiles = state.shims ?? [DEFAULT_COMMAND, MANAGEMENT_COMMAND]; const shimPaths = shimFiles.map((file) => path.join(directory, 'bin', file)); const deferred = platform === 'win32' ? shimPaths.filter((file) => /\.cmd$/i.test(file)) : []; diff --git a/test/unit/native-antigravity-harness.test.ts b/test/unit/native-antigravity-harness.test.ts index af07f7f..8c6d1bd 100644 --- a/test/unit/native-antigravity-harness.test.ts +++ b/test/unit/native-antigravity-harness.test.ts @@ -4,9 +4,10 @@ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promi import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import type { - AntigravityRunOptions, - AntigravityRunResult, +import { + AntigravityCliError, + type AntigravityRunOptions, + type AntigravityRunResult, } from '../../plugins/multi-antigravity/src/cli.ts'; import { AntigravityHarness, @@ -926,3 +927,93 @@ test('a first-load race is refused with 400, not a retryable gateway failure', a assert.match(error.message, /already (running|loading)/); assert.equal(calls.length, 1, 'the refused request must not start a native run'); }); + +test('a failed run that already started steps keeps the interruption notice for the retry', async (t) => { + const stateDirectory = await mkdtemp(path.join(os.tmpdir(), 'agy-failed-steps-')); + t.after(() => removeTemporary(stateDirectory)); + const calls: AntigravityRunOptions[] = []; + const run = async (options: AntigravityRunOptions) => { + calls.push(options); + if (calls.length === 1) { + options.onEvent?.({ event: 'init', conversation_id: 'partial-conversation', init: {} }); + return { + result: { + conversation_id: 'partial-conversation', + status: 'ERROR' as const, + response: '', + error: 'model failed after a step', + }, + exitCode: 1, + signal: null, + stderr: '', + }; + } + return { + result: { + conversation_id: 'partial-conversation', + status: 'SUCCESS' as const, + response: 'ok', + }, + exitCode: 0, + signal: null, + stderr: '', + }; + }; + const harness = new AntigravityHarness([model], { + stateDirectory, + checkPermissions: policy, + run, + }); + t.after(() => harness.close()); + const request = { model: model.model, messages: [{ role: 'user', content: 'effects' }] }; + await assert.rejects( + harness.handle(request, 'session/worker', new AbortController().signal, undefined, context), + /model failed/, + ); + await harness.handle(request, 'session/worker', new AbortController().signal, undefined, context); + assert.match(calls[1].prompt, /previous turn was interrupted/); +}); + +test('a missing agy binary is a 400, a transient process shortage stays retryable', () => { + const missing = new AntigravityCliError('Failed to start agy', 'spawn', { systemCode: 'ENOENT' }); + assert.equal(new AntigravityProviderError(missing).failure.status, 400); + const busy = new AntigravityCliError('Failed to start agy', 'spawn', { systemCode: 'EAGAIN' }); + assert.equal(new AntigravityProviderError(busy).failure.status, 502); +}); + +test('an idle record is evicted after its turn so its lock is not held until shutdown', async (t) => { + const fixture = await setup(); + t.after(() => removeTemporary(fixture.stateDirectory)); + const first = new AntigravityHarness([model], { ...fixture, checkPermissions: policy }); + t.after(() => first.close()); + const second = new AntigravityHarness([model], { ...fixture, checkPermissions: policy }); + t.after(() => second.close()); + const ask = (harness: AntigravityHarness, content: string) => + harness.handle( + { model: model.model, messages: [{ role: 'user', content }] }, + 'session/worker', + new AbortController().signal, + undefined, + context, + ); + await ask(first, 'one'); + // Another gateway process can take the record because the first released its lock. + await ask(second, 'two'); + assert.equal(fixture.calls.length, 2); +}); + +test('the success text carries no timing or billing decoration', async (t) => { + const fixture = await setup(); + t.after(() => removeTemporary(fixture.stateDirectory)); + const harness = new AntigravityHarness([model], { ...fixture, checkPermissions: policy }); + t.after(() => harness.close()); + const response = await harness.handle( + { model: model.model, messages: [{ role: 'user', content: 'plain' }] }, + 'plain', + new AbortController().signal, + undefined, + context, + ); + const text = response.content.map((block) => (block.type === 'text' ? block.text : '')).join(''); + assert(!/completed in|billed/.test(text)); +}); diff --git a/test/unit/native-antigravity-hooks.test.ts b/test/unit/native-antigravity-hooks.test.ts index 4475657..8c92628 100644 --- a/test/unit/native-antigravity-hooks.test.ts +++ b/test/unit/native-antigravity-hooks.test.ts @@ -9,6 +9,7 @@ import { antigravitySettingsFile, installAntigravityHook, } from '../../plugins/multi-antigravity/src/hooks.ts'; +import { removeAntigravityHook } from '../../plugins/multi-core/src/install/installation.ts'; import { removeTemporary } from '../temporary.ts'; interface InstalledPreToolUseHook { @@ -107,3 +108,17 @@ test('Antigravity replaces an existing Windows destination', async (t) => { assert.equal(installed.old, true); assert.ok(installed['multi-cli-antigravity']); }); + +test('uninstall removes only the Multi Antigravity hook entry and tolerates a missing file', async (t) => { + const home = await mkdtemp(path.join(os.tmpdir(), 'antigravity-uninstall-')); + t.after(() => removeTemporary(home)); + assert.equal(await removeAntigravityHook({ platform: 'linux', homedir: home }), false); + const file = antigravityHookFile({ platform: 'linux', homedir: home }); + await installAntigravityHook(file); + const other = { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'other' }] }] }; + const installed = JSON.parse(await readFile(file, 'utf8')) as Record; + await writeFile(file, `${JSON.stringify({ ...installed, other })}\n`); + assert.equal(await removeAntigravityHook({ platform: 'linux', homedir: home }), true); + assert.deepEqual(JSON.parse(await readFile(file, 'utf8')), { other }); + assert.equal(await removeAntigravityHook({ platform: 'linux', homedir: home }), false); +}); diff --git a/test/unit/native-antigravity-models.test.ts b/test/unit/native-antigravity-models.test.ts index ebd306c..0512a6f 100644 --- a/test/unit/native-antigravity-models.test.ts +++ b/test/unit/native-antigravity-models.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + antigravityDefaultWorkerModel, antigravityPickerOptions, discoverAntigravityModels, parseAntigravityModels, @@ -122,3 +123,13 @@ test('discovers Windows npm shims with injected platform and process execution', ['gemini-low'], ); }); + +test('the default worker model prefers Flash over Flash-Lite whatever order agy lists', () => { + const ids = ['gemini-3.8-flash-lite', 'gemini-3.8-flash']; + assert.equal(antigravityDefaultWorkerModel(ids), 'gemini-3.8-flash'); + assert.equal(antigravityDefaultWorkerModel([...ids].reverse()), 'gemini-3.8-flash'); + assert.equal( + antigravityDefaultWorkerModel(['gemini-3.8-flash', 'gemini-3.8-pro']), + 'gemini-3.8-pro', + ); +}); From 6ec314870c44af5057db60803e1f672626eedb8f Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:44:25 -0400 Subject: [PATCH 11/36] Fail closed on Grok toolsets and non-end_turn stops, evict idle records Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-grok/src/cli.ts | 22 ++++++++- plugins/multi-grok/src/harness.ts | 68 ++++++++++++++------------- test/unit/native-grok-cli.test.ts | 20 ++++++-- test/unit/native-grok-harness.test.ts | 36 +++++++++++++- 4 files changed, 108 insertions(+), 38 deletions(-) diff --git a/plugins/multi-grok/src/cli.ts b/plugins/multi-grok/src/cli.ts index 5dd7635..797a490 100644 --- a/plugins/multi-grok/src/cli.ts +++ b/plugins/multi-grok/src/cli.ts @@ -222,6 +222,8 @@ export async function runGrok(options: GrokRunOptions): Promise { type ParserState = { response: string; forbidden?: readonly string[]; + /** The allowlist this run requested; an announced tool outside it that is not MCP is a breach. */ + granted?: readonly string[]; tools?: readonly string[]; terminal?: GrokResult; failure?: NativeCliError; @@ -235,7 +237,11 @@ async function spawnGrok( platform: NodeJS.Platform, promptFile: string | undefined, ): Promise { - const parser: ParserState = { response: '', forbidden: options.forbiddenTools }; + const parser: ParserState = { + response: '', + forbidden: options.forbiddenTools, + granted: options.tools, + }; const run = await runNativeCli({ name: 'Grok', executable: 'grok', @@ -342,6 +348,14 @@ function parseEvent( } } +/** + * MCP tools are announced as `server__tool`, and `search_tool`/`use_tool` are the CLI's own + * broker for them. MCP execution is denied by rule, so their exposure is tolerated. + */ +function isMcpTool(name: string): boolean { + return name.includes('__') || name === 'search_tool' || name === 'use_tool'; +} + /** * The announced toolset is the only evidence that a removal took effect: the CLI * accepts unknown `--disallowed-tools` names and runs the tool anyway. Every @@ -354,7 +368,11 @@ function parseTools(value: Record, parser: ParserState, emit: E return; } const forbidden = new Set(parser.forbidden ?? []); - const breach = tools.filter((tool) => forbidden.has(tool)); + const granted = parser.granted === undefined ? undefined : new Set(parser.granted); + // Fail closed: a name this build adds later is neither granted nor MCP, so it is refused. + const breach = tools.filter( + (tool) => forbidden.has(tool) || (granted && !granted.has(tool) && !isMcpTool(tool)), + ); if (breach.length) { fail( new GrokCliError( diff --git a/plugins/multi-grok/src/harness.ts b/plugins/multi-grok/src/harness.ts index bd0a478..1962d08 100644 --- a/plugins/multi-grok/src/harness.ts +++ b/plugins/multi-grok/src/harness.ts @@ -229,6 +229,8 @@ export class GrokHarness { ); } finally { await lease.release(); + // The durable record holds everything; an idle attachment only pins its lock and replay events. + await this.store.evictIdle(session); } } @@ -304,7 +306,6 @@ export class GrokHarness { signal.throwIfAborted(); const sessionId = session.saved.sessionId ?? randomUUID(); let startSave: Promise | undefined; - const startedAt = performance.now(); // Each finished tool call becomes a display row in this reply, named after // Grok's own tool; the terse summary is written when the run settles. const actions = new NativeActionTracker(PROVIDER, observe, (block) => @@ -347,10 +348,17 @@ export class GrokHarness { 'Grok native run', ); await startSave; - return await this.settle( - { session, response, outcome, selection, key, exchange, emit, actions, calls }, - performance.now() - startedAt, - ); + return await this.settle({ + session, + response, + outcome, + selection, + key, + exchange, + emit, + actions, + calls, + }); } catch (error) { // Traced before the rethrow: a failure that never reached the CLI left no // other evidence at all, which cost one diagnosis already. @@ -369,20 +377,17 @@ export class GrokHarness { } } - private async settle( - run: { - session: GrokSession; - response: HarnessResponse; - outcome: GrokRunResult; - selection: ReturnType; - key: string; - exchange: HarnessExchange; - emit: Emit; - actions: NativeActionTracker; - calls: HarnessModelCalls; - }, - elapsedMs: number, - ): Promise { + private async settle(run: { + session: GrokSession; + response: HarnessResponse; + outcome: GrokRunResult; + selection: ReturnType; + key: string; + exchange: HarnessExchange; + emit: Emit; + actions: NativeActionTracker; + calls: HarnessModelCalls; + }): Promise { const { session, response, outcome, selection, key, exchange, emit, actions, calls } = run; const result = outcome.result; // The CLI owns the identity it reports; a mismatch would silently fork history. @@ -391,8 +396,17 @@ export class GrokHarness { `Grok answered on session ${result.sessionId} instead of ${session.saved.sessionId}`, ); } + if (result.stopReason !== 'end_turn') { + // Only end_turn is a finished answer. Cancelled, refused or truncated turns may have + // run steps, so the next request resumes the session with the interruption notice. + session.saved.sessionId = result.sessionId; + session.saved.interrupted = true; + session.saved.cost = (session.saved.cost ?? 0) + (result.costUsd ?? 0); + await this.store.save(session); + throw new GrokProviderError(`Grok stopped before finishing its turn (${result.stopReason})`); + } response.text(actions.text(calls.count || result.turns)); - appendDiagnostics(response, outcome, elapsedMs); + appendDiagnostics(response, outcome); const finished = response.finish( calls.turn(toHarnessUsage(result.usage), result.turns), selection.model.id, @@ -471,18 +485,8 @@ function toHarnessUsage(usage: GrokUsage | undefined): HarnessUsageFields | unde }; } -/** The CLI reports cost per invocation, so it is shown as billed for this run. */ -function appendDiagnostics(response: HarnessResponse, outcome: GrokRunResult, elapsedMs: number) { - const parts = []; - if (Number.isFinite(elapsedMs)) { - parts.push(`completed in ${(Math.max(0, elapsedMs) / 1000).toFixed(1)}s`); - } - if (outcome.result.costUsd !== undefined) { - parts.push(`$${outcome.result.costUsd.toFixed(4)} billed`); - } - if (parts.length) { - response.text(`\n[Grok] ${parts.join(' · ')}\n`); - } +/** Timing and cost reach the user through receipts; only warnings the model can act on stay. */ +function appendDiagnostics(response: HarnessResponse, outcome: GrokRunResult) { const diagnostics = stderrDiagnostics(outcome.stderr); if (diagnostics) { response.text(`[Grok] ${diagnostics}\n`); diff --git a/test/unit/native-grok-cli.test.ts b/test/unit/native-grok-cli.test.ts index 0b75c5b..7ac5c6c 100644 --- a/test/unit/native-grok-cli.test.ts +++ b/test/unit/native-grok-cli.test.ts @@ -180,6 +180,19 @@ test('tolerates the toolset growing as MCP servers connect', async (t) => { assert.equal(output.response, 'STORED'); }); +test('fails closed on an announced tool that is neither granted nor MCP', async (t) => { + const cli = await fakeCli(t); + // A native tool this build adds later is not on the forbidden list, but it was never granted. + await assert.rejects( + replay(cli, 'text-only.jsonl', { + tools: ['read_file', 'list_dir', 'grep'], + forbiddenTools: [], + }), + (error: unknown) => + isCliError(error) && error.code === 'policy' && /remains available/.test(error.message), + ); +}); + test('never infers a result when the stream stops without its terminal event', async (t) => { const cli = await fakeCli(t); await assert.rejects( @@ -194,13 +207,14 @@ test('sends explicit flags and refuses to both create and resume a session', asy const mode: GrokPermissionMode = 'plan'; await replay( cli, - 'text-only.jsonl', + // The announced toolset must be the requested one, so the fixture's own tools are granted. + 'session-create.jsonl', { model: 'grok-4.6', effort: 'high', mode, session: 'f6e4b375-8213-4b5e-993d-f546b91362e4', - tools: ['read_file', 'grep'], + tools: ['read_file', 'list_dir', 'grep'], disallowedTools: ['search_tool', 'use_tool'], allow: ['Read(**)'], deny: ['Bash(*)', 'MCPTool(*)'], @@ -217,7 +231,7 @@ test('sends explicit flags and refuses to both create and resume a session', asy assert.equal(args[args.indexOf('--permission-mode') + 1], 'plan'); assert.equal(args[args.indexOf('--session-id') + 1], 'f6e4b375-8213-4b5e-993d-f546b91362e4'); assert.equal(args.includes('--resume'), false); - assert.equal(args[args.indexOf('--tools') + 1], 'read_file,grep'); + assert.equal(args[args.indexOf('--tools') + 1], 'read_file,list_dir,grep'); assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'search_tool,use_tool'); assert.deepEqual( args.flatMap((value, index) => (args[index - 1] === '--deny' ? [value] : [])), diff --git a/test/unit/native-grok-harness.test.ts b/test/unit/native-grok-harness.test.ts index 17765b4..db4cb7d 100644 --- a/test/unit/native-grok-harness.test.ts +++ b/test/unit/native-grok-harness.test.ts @@ -240,7 +240,8 @@ test('summarizes native tool activity as text instead of replaying it', async (t // An ordinary tool error is not a permission decision and must not read as one. assert.match(text(response), /tool \(failed: Error: note\.txt does not exist\)/); assert.match(text(response), /I could not run it\./); - assert.match(text(response), /\$0\.0175 billed/); + // Timing and cost belong to receipts, not to the text the model reads back. + assert.doesNotMatch(text(response), /billed|completed in/); }); test('replays a completed identical request instead of running it twice', async (t) => { @@ -545,3 +546,36 @@ async function session(stateDirectory: string) { assert.notEqual(file, undefined); return JSON.parse(await readFile(path.join(stateDirectory, String(file)), 'utf8')); } + +test('only end_turn is a finished answer; a cancelled turn resumes with the interruption notice', async (t) => { + const { stateDirectory, calls } = await setup(t); + const harness = new GrokHarness([model], { + stateDirectory, + checkPermissions: policy, + run: async (options) => { + calls.push(options); + return echoSession(options, calls.length === 1 ? { stopReason: 'cancelled' } : {}); + }, + }); + t.after(() => harness.close()); + await assert.rejects(ask(harness, 'work'), /stopped before finishing.*cancelled/); + await ask(harness, 'work'); + assert.equal(calls.length, 2); + assert.match(calls[1].prompt, /previous turn was interrupted/); +}); + +test('an idle record is evicted after its turn so its lock is not held until shutdown', async (t) => { + const { stateDirectory } = await setup(t); + const make = () => + new GrokHarness([model], { + stateDirectory, + checkPermissions: policy, + run: async (options) => echoSession(options), + }); + const first = make(); + t.after(() => first.close()); + const second = make(); + t.after(() => second.close()); + await ask(first, 'one'); + await ask(second, 'two'); +}); From 6c625a6e4911090b41e5d408b3d5cb14a2324866 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:44:25 -0400 Subject: [PATCH 12/36] Forward recordedResponse through Cursor workspaces Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-cursor/src/workspaces.ts | 6 ++++ test/unit/native-cursor-workspaces.test.ts | 40 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/plugins/multi-cursor/src/workspaces.ts b/plugins/multi-cursor/src/workspaces.ts index f8d2ccd..8d13bb0 100644 --- a/plugins/multi-cursor/src/workspaces.ts +++ b/plugins/multi-cursor/src/workspaces.ts @@ -25,6 +25,12 @@ export class CursorWorkspaces { return this.harness(cwd).handle(...args); } + /** The scope's last reply, read from the workspace that `handle` would route it to. */ + async recordedResponse(scope: string, context?: PermissionContext) { + const cwd = await realpath(context?.cwd ?? this.cwd); + return this.harness(cwd).recordedResponse(scope); + } + async billedUsageForSession(sessionId: string) { const batches = await Promise.all( [...this.harnesses.values()].map((harness) => harness.billedUsageForSession(sessionId)), diff --git a/test/unit/native-cursor-workspaces.test.ts b/test/unit/native-cursor-workspaces.test.ts index 060345d..0ba3bc2 100644 --- a/test/unit/native-cursor-workspaces.test.ts +++ b/test/unit/native-cursor-workspaces.test.ts @@ -57,3 +57,43 @@ test('worktree workers route to their canonical workspace and close all SDK harn assert.deepEqual(closed, created); await assert.rejects(workspaces.handle(body, 'main', signal), /closed/); }); + +test('a recorded reply is read from the workspace that handled it, so a follow-up survives a restart', async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), 'cursor-workspaces-recorded-')); + t.after(() => removeTemporary(root)); + const worktree = path.join(root, 'worktree'); + await mkdir(worktree); + await symlink(worktree, path.join(root, 'alias')); + const reply: MessagesResponse = { + id: 'msg', + type: 'message', + role: 'assistant', + model: 'test', + content: [{ type: 'text', text: 'recorded' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }; + const asked: string[] = []; + const workspaces = new CursorWorkspaces( + (cwd) => { + const harness = new CursorHarness([], { cwd }); + t.mock.method(harness, 'recordedResponse', async (scope: string) => { + asked.push(`${cwd}:${scope}`); + return cwd === (await realpath(worktree)) ? reply : undefined; + }); + return harness; + }, + await realpath(root), + ); + t.after(() => workspaces.close()); + assert.equal( + await workspaces.recordedResponse('worker', { + permissionMode: 'plan', + cwd: path.join(root, 'alias'), + }), + reply, + ); + assert.equal(await workspaces.recordedResponse('worker'), undefined); + assert.deepEqual(asked, [`${await realpath(worktree)}:worker`, `${await realpath(root)}:worker`]); +}); From e52f9f259aee64fce371e1afacefbb1c7b2afb22 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:44:26 -0400 Subject: [PATCH 13/36] Emit sequential Zen Chat blocks and bill OpenAI stop-sequence replies Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-openai/src/responses.ts | 89 ++++++++++++++++++++------ plugins/multi-zen/src/chat.ts | 86 +++++++++++++++---------- test/unit/native-openai-stream.test.ts | 32 +++++++++ test/unit/native-zen-chat.test.ts | 61 ++++++++++++++++++ 4 files changed, 215 insertions(+), 53 deletions(-) diff --git a/plugins/multi-openai/src/responses.ts b/plugins/multi-openai/src/responses.ts index 4de82b3..c31a3ea 100644 --- a/plugins/multi-openai/src/responses.ts +++ b/plugins/multi-openai/src/responses.ts @@ -598,6 +598,8 @@ class ResponseStream { private message?: MessagesResponse; private cursor = 0; private stopped: string | null = null; + /** A stop sequence ended the content; the terminal event is awaited only for usage. */ + stopPending = false; completed = false; private model: string; private emit: Emit; @@ -633,14 +635,24 @@ class ResponseStream { if (!this.message) { throw new Error('OpenAI stream omitted response.created'); } + if (this.stopPending) { + return; + } this.update(event); this.drain(); if (this.stopped) { - this.finish('stop_sequence'); + this.stopPending = true; } } } + /** The terminal event never came in time; the answer is complete but its usage is unknown. */ + finishWithoutUsage() { + if (this.stopPending && !this.completed) { + this.finish('stop_sequence'); + } + } + private start(response: ResponsesResponse) { if (this.message) { return; @@ -706,6 +718,11 @@ class ResponseStream { { type: 'response.completed' | 'response.incomplete' | 'response.done' } >, ) { + if (this.stopPending) { + this.start(event.response); + this.finish('stop_sequence', event.response.usage); + return; + } if ( this.options.requireUsage && (event.response.usage?.input_tokens === undefined || @@ -714,16 +731,7 @@ class ResponseStream { throw new Error('Provider completed without token usage; cost accounting is unavailable'); } this.start(event.response); - if (event.response.status && !['completed', 'incomplete'].includes(event.response.status)) { - throw new Error(`OpenAI terminal response has status ${event.response.status}`); - } - const incomplete = - event.type === 'response.incomplete' || event.response.status === 'incomplete'; - if (incomplete && event.response.incomplete_details?.reason !== 'max_output_tokens') { - throw new Error( - `OpenAI response incomplete: ${event.response.incomplete_details?.reason ?? 'unknown'}`, - ); - } + const incomplete = this.terminalIncomplete(event); this.reconcile(event.response.output); if ([...this.slots.values()].some((slot) => !slot.done)) { throw new Error('OpenAI completed with an unfinished content block'); @@ -745,6 +753,26 @@ class ResponseStream { this.finish(stopReason, event.response.usage); } + /** Whether the terminal event is a max-output cut-off; any other failed state throws. */ + private terminalIncomplete( + event: Extract< + ResponseStreamEvent, + { type: 'response.completed' | 'response.incomplete' | 'response.done' } + >, + ): boolean { + if (event.response.status && !['completed', 'incomplete'].includes(event.response.status)) { + throw new Error(`OpenAI terminal response has status ${event.response.status}`); + } + const incomplete = + event.type === 'response.incomplete' || event.response.status === 'incomplete'; + if (incomplete && event.response.incomplete_details?.reason !== 'max_output_tokens') { + throw new Error( + `OpenAI response incomplete: ${event.response.incomplete_details?.reason ?? 'unknown'}`, + ); + } + return incomplete; + } + private reconcile(output?: ResponsesOutputItem[]) { // Some streams omit the redundant output array or send []; their finished // incremental items remain authoritative. A populated array must agree. @@ -965,6 +993,9 @@ export function prefixSafeLength(text: string, stops: readonly string[]): number return limit; } +/** How long a stream that already hit a stop sequence may take to report its usage. */ +const STOP_USAGE_WAIT_MS = 5000; + export async function fromResponses( stream: AsyncIterable, model: string, @@ -972,14 +1003,36 @@ export async function fromResponses( options: ResponseOptions = {}, ): Promise { const response = new ResponseStream(model, emit, options); - for await (const event of readSse(stream)) { - if (!isStreamEvent(event)) { - continue; - } - response.accept(event); - if (response.completed) { - break; + const events = readSse(stream)[Symbol.asyncIterator](); + let deadline: Promise<'late'> | undefined; + let timer: NodeJS.Timeout | undefined; + try { + while (!response.completed) { + const next = events.next(); + const step = deadline ? await Promise.race([next, deadline]) : await next; + if (step === 'late') { + response.finishWithoutUsage(); + break; + } + if (step.done) { + break; + } + if (!isStreamEvent(step.value)) { + continue; + } + response.accept(step.value); + if (response.stopPending && !deadline) { + // The text is final, but the provider bills the whole response: keep reading to its usage. + deadline = new Promise<'late'>((resolve) => { + timer = setTimeout(() => resolve('late'), STOP_USAGE_WAIT_MS); + }); + } } + } finally { + clearTimeout(timer); + // A stream abandoned early is released without waiting on its next chunk. + events.return?.(undefined).catch(() => undefined); } + response.finishWithoutUsage(); return response.result(); } diff --git a/plugins/multi-zen/src/chat.ts b/plugins/multi-zen/src/chat.ts index d084926..c0fa23c 100644 --- a/plugins/multi-zen/src/chat.ts +++ b/plugins/multi-zen/src/chat.ts @@ -121,7 +121,7 @@ function text(value: unknown): string { } return block.text; }) - .join(''); + .join('\n'); } function image(block: ContentBlock): ChatContent { @@ -156,7 +156,7 @@ function image(block: ContentBlock): ChatContent { function userContent(value: unknown): string | ChatContent[] { const content = blocks(value); if (content.every((block) => block.type === 'text')) { - return content.map((block) => block.text as string).join(''); + return content.map((block) => block.text as string).join('\n'); } return content.map((block) => { if (block.type === 'text') { @@ -299,7 +299,7 @@ function plainToolResult(content: ContentBlock[], isError: boolean | undefined): const value = content .filter((item) => item.type === 'text') .map((item) => item.text as string) - .join(''); + .join('\n'); const references = content .filter((item) => item.type === 'tool_reference') .map((item) => { @@ -403,7 +403,7 @@ function lowerUserBlocks(content: ContentBlock[], messages: ChatMessage[]) { previous?.role === 'user' && typeof previous.content === 'string' ) { - previous.content += value; + previous.content += `\n${value}`; } else { messages.push({ role: 'user', content: value }); } @@ -624,8 +624,8 @@ class ChatAccumulator { private stop?: StopReason; private started = false; private ended = false; - private textIndex?: number; - private reasoningIndex?: number; + /** The one content block currently open; blocks are strictly sequential. */ + private open?: { kind: 'text' | 'thinking'; index: number }; constructor(model: string, emit: Emit, options: ChatResponseOptions) { this.model = model; @@ -725,36 +725,60 @@ class ChatAccumulator { } private beginText(): number { - if (this.textIndex !== undefined) { - return this.textIndex; - } - this.textIndex = this.content.length; + if (this.open?.kind === 'text') { + return this.open.index; + } + this.closeOpen(); + this.textValue = ''; + this.textEmitted = 0; + const index = this.content.length; + this.open = { kind: 'text', index }; this.content.push({ type: 'text', text: '' }); - this.emit('content_block_start', { - index: this.textIndex, - content_block: { type: 'text', text: '' }, - }); - return this.textIndex; + this.emit('content_block_start', { index, content_block: { type: 'text', text: '' } }); + return index; } private beginReasoning(): number { - if (this.reasoningIndex !== undefined) { - return this.reasoningIndex; + if (this.open?.kind === 'thinking') { + return this.open.index; } - this.reasoningIndex = this.content.length; + this.closeOpen(); + this.reasoning = ''; + const index = this.content.length; + this.open = { kind: 'thinking', index }; this.content.push({ type: 'thinking', thinking: '', signature: '' }); this.emit('content_block_start', { - index: this.reasoningIndex, + index, content_block: { type: 'thinking', thinking: '', signature: '' }, }); - return this.reasoningIndex; + return index; + } + + /** Finish the open block before another begins, as the Messages stream requires. */ + private closeOpen() { + const open = this.open; + if (!open) { + return; + } + const block = this.content[open.index]; + if (block?.type === 'thinking') { + block.signature = signature(this.model, this.reasoning); + this.emit('content_block_delta', { + index: open.index, + delta: { type: 'signature_delta', signature: block.signature }, + }); + } else { + this.flushText(); + } + this.emit('content_block_stop', { index: open.index }); + this.open = undefined; } private addReasoning(value: unknown) { if (typeof value !== 'string') { return; } - if (this.stoppedSequence) { + if (this.stoppedSequence || (!value && this.open?.kind !== 'thinking')) { return; } const index = this.beginReasoning(); @@ -776,7 +800,7 @@ class ChatAccumulator { if (typeof value !== 'string') { return; } - if (!value && this.textIndex === undefined) { + if (!value && this.open?.kind !== 'text') { return; } if (this.stoppedSequence) { @@ -970,23 +994,15 @@ class ChatAccumulator { } private finishStreamedBlocks() { - for (const [index, block] of this.content.entries()) { - if (block.type === 'thinking') { - block.signature = signature(this.model, this.reasoning); - this.emit('content_block_delta', { - index, - delta: { type: 'signature_delta', signature: block.signature }, - }); - } - this.emit('content_block_stop', { index }); - } + this.closeOpen(); } private flushText() { - if (this.textIndex === undefined || this.stoppedSequence) { + if (this.open?.kind !== 'text' || this.stoppedSequence) { return; } - const block = this.content[this.textIndex]; + const textIndex = this.open.index; + const block = this.content[textIndex]; if (block?.type !== 'text') { throw new Error('Invalid text state'); } @@ -995,7 +1011,7 @@ class ChatAccumulator { this.textEmitted = this.textValue.length; if (remainder) { this.emit('content_block_delta', { - index: this.textIndex, + index: textIndex, delta: { type: 'text_delta', text: remainder }, }); } diff --git a/test/unit/native-openai-stream.test.ts b/test/unit/native-openai-stream.test.ts index 08d2fa4..9b98c01 100644 --- a/test/unit/native-openai-stream.test.ts +++ b/test/unit/native-openai-stream.test.ts @@ -386,3 +386,35 @@ test('an early null reasoning snapshot can be replaced by final encrypted state' assert.equal(result.content[0]?.type, 'thinking'); assert.equal(result.stop_reason, 'end_turn'); }); + +test('a stop sequence hit mid-stream still waits for the terminal event to record billed usage', async () => { + const delta = (value: string) => ({ + type: 'response.output_text.delta', + output_index: 0, + delta: value, + }); + const { events, emit } = capture(); + const result = await fromResponses( + stream([ + created, + itemEvent('added', { type: 'message', id: 'msg_a', content: [] }), + delta('prefix'), + delta(' suffix'), + delta(' ignored after the stop'), + terminal([text]), + ]), + model, + emit, + { stopSequences: [' suffix'] }, + ); + assert.equal(result.stop_reason, 'stop_sequence'); + assert.deepEqual(result.content, [{ type: 'text', text: 'prefix' }]); + assert.deepEqual(result.usage, { + input_tokens: 60, + cache_read_input_tokens: 40, + cache_creation_input_tokens: 0, + output_tokens: 20, + }); + assert.equal(result.multi_usage?.source, 'provider'); + assert.equal(events.filter((event) => event.type === 'message_stop').length, 1); +}); diff --git a/test/unit/native-zen-chat.test.ts b/test/unit/native-zen-chat.test.ts index 13f87e8..674812a 100644 --- a/test/unit/native-zen-chat.test.ts +++ b/test/unit/native-zen-chat.test.ts @@ -624,3 +624,64 @@ test('fromChat rejects a tool finish without calls and normalizes tools on stop' ); assert.equal(result.stop_reason, 'tool_use'); }); + +test('fromChat emits strictly sequential blocks, one per reasoning or text segment', async () => { + const seen = capture(); + const chunk = (delta: Record, finish: string | null = null) => ({ + id: 'chat-seq', + choices: [{ index: 0, delta, finish_reason: finish }], + }); + const result = await fromChat( + sse([ + chunk({ reasoning_content: 'one' }), + chunk({ content: 'say' }), + chunk({ reasoning_content: 'two' }), + chunk({ content: 'more' }, 'stop'), + { id: 'chat-seq', choices: [], usage: { prompt_tokens: 5, completion_tokens: 4 } }, + ]), + model, + seen.emit, + ); + assert.deepEqual( + result.content.map((block) => block.type), + ['thinking', 'text', 'thinking', 'text'], + ); + assert.equal(result.content[2]?.type === 'thinking' ? result.content[2].thinking : '', 'two'); + const order = seen.events + .filter((event) => event.type.startsWith('content_block_s')) + .map((event) => `${event.type}:${(event.value as { index: number }).index}`); + assert.deepEqual(order, [ + 'content_block_start:0', + 'content_block_stop:0', + 'content_block_start:1', + 'content_block_stop:1', + 'content_block_start:2', + 'content_block_stop:2', + 'content_block_start:3', + 'content_block_stop:3', + ]); +}); + +test('toChat joins separate system and user text blocks with a newline', () => { + const request = toChat( + { + model, + system: [ + { type: 'text', text: 'first rule' }, + { type: 'text', text: 'second rule' }, + ], + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'alpha' }, + { type: 'text', text: 'beta' }, + ], + }, + ], + } as never, + 'kimi-k3', + ) as { messages: { role: string; content: unknown }[] }; + assert.equal(request.messages[0]?.content, 'first rule\nsecond rule'); + assert.equal(request.messages[1]?.content, 'alpha\nbeta'); +}); From 4837d1d9c9bc27c28d5329146f27101b7da1a605 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:47:31 -0400 Subject: [PATCH 14/36] Add shared harness failure classifier; keep reply archives past exchange cap; document Windows behavior Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- docs/platform-support.md | 10 +++- .../src/gateway/harness-completion.ts | 8 +-- .../multi-core/src/gateway/harness-failure.ts | 52 +++++++++++++++++++ test/unit/native-harness-failure.test.ts | 27 ++++++++++ test/unit/native-harness-process.test.ts | 23 ++++++++ 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 plugins/multi-core/src/gateway/harness-failure.ts create mode 100644 test/unit/native-harness-failure.test.ts diff --git a/docs/platform-support.md b/docs/platform-support.md index 8c39cd7..26ed54e 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -99,10 +99,18 @@ registered with the interactive logon type. 1. Install Node 24.12+, Claude Code, PowerShell or cmd, and the Codex, `agy`, or Grok Build CLIs as needed. `npm ci` installs the Cursor SDK; Zen needs an API key, not the OpenCode executable. Sign in to the providers you will test. 2. In PowerShell or cmd, run the same commands listed for macOS. Use `npm.cmd` when the shell requires it. +A native CLI that resolves to a `.cmd` or `.bat` file that is not a standard npm +shim is started through `cmd.exe`, which expands `%VAR%` and has no safe escape +for quotes. Such a launcher is refused any argument containing `"`, `%`, `!`, +`^`, `&`, `|`, `<`, `>` or a line break; providers send prompt text on stdin +instead (`promptArgvSafe`). Cancelling a Windows run first asks the process tree +to close (`taskkill /T`) and forces it (`/F`) only after the grace period, and +nothing is killed by PID after the child has exited. + Managed Claude policy comes from `/etc/claude-code` on Linux and WSL, `/Library/Application Support/ClaudeCode` and macOS preferences on macOS, and `C:\Program Files\ClaudeCode` and the Windows policy keys on Windows. Windows -policy keys are read with `reg query`; when `reg.exe` reports a localized +policy keys are read with `reg query` (run from `System32` with a 10 s timeout, as are `powershell.exe` and `/usr/bin/defaults`); when `reg.exe` reports a localized failure, Windows PowerShell classifies the key as absent or failed. macOS preferences are read with `defaults read`; a missing domain is recognized from both wordings `defaults` uses for it, `does not exist` and the macOS 27 diff --git a/plugins/multi-core/src/gateway/harness-completion.ts b/plugins/multi-core/src/gateway/harness-completion.ts index eb4e59e..18c21fd 100644 --- a/plugins/multi-core/src/gateway/harness-completion.ts +++ b/plugins/multi-core/src/gateway/harness-completion.ts @@ -88,12 +88,12 @@ export async function archiveHarnessReply classifyHarnessFailure(error).status; + +test('configuration and deterministic failures are request errors', () => { + assert.equal(status(new HarnessBusyError('busy')), 400); + assert.equal(status(new NativeCliError('policy', 'policy')), 400); + assert.equal(status(new NativeCliError('gone', 'spawn', { systemCode: 'ENOENT' })), 400); + assert.equal(status(new NativeCliError('denied', 'spawn', { systemCode: 'EACCES' })), 400); + assert.equal(status(Object.assign(new Error('Executable not found'), { code: 'ENOENT' })), 400); + assert.equal(status(Object.assign(new Error('unsafe'), { code: 'EUNSAFEARG' })), 400); +}); + +test('transient and uncertain failures stay retryable 502s', () => { + for (const systemCode of ['EAGAIN', 'EMFILE', 'ENOMEM']) { + assert.equal(status(new NativeCliError('busy host', 'spawn', { systemCode })), 502); + } + for (const code of ['no_terminal_result', 'aborted', 'output_limit', 'parse']) { + assert.equal(status(new NativeCliError('x', code)), 502); + } + assert.equal(status(new Error('unknown')), 502); + assert.equal(classifyHarnessFailure(new Error('x')).deterministic, false); +}); diff --git a/test/unit/native-harness-process.test.ts b/test/unit/native-harness-process.test.ts index 254b0f5..cea5464 100644 --- a/test/unit/native-harness-process.test.ts +++ b/test/unit/native-harness-process.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import type { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; import test from 'node:test'; @@ -11,6 +13,7 @@ import { NativeCliError, nativeEnvironment, promptArgumentLimitBytes, + promptArgvSafe, redactStderr, runNativeCli, terminateGraceMs, @@ -323,3 +326,23 @@ test('after the child closes only the process group is signaled, never the bare await run.catch(() => undefined); assert.deepEqual(targets, [-4242]); }); + +test('promptArgvSafe routes unsafe prompts away from cmd launchers only', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'argv-safe-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const launcher = path.join(directory, 'agy.cmd'); + await writeFile(launcher, '@echo off\r\nrem not an npm shim\r\n'); + const shim = path.join(directory, 'shim.cmd'); + await writeFile(shim, '"%dp0%\\cli.js" %*\r\n'); + await writeFile(path.join(directory, 'cli.js'), ''); + const unsafe = '100% "quoted" & more'; + const win = { executable: 'agy', platform: 'win32' as const, env: {} }; + assert.equal(promptArgvSafe(unsafe, { ...win, configuredPath: launcher }), false); + assert.equal(promptArgvSafe('plain words', { ...win, configuredPath: launcher }), true); + assert.equal(promptArgvSafe(unsafe, { ...win, configuredPath: process.execPath }), true); + assert.equal(promptArgvSafe(unsafe, { ...win, env: { PATH: '' } }), true); + assert.equal( + promptArgvSafe(unsafe, { ...win, platform: 'linux', configuredPath: launcher }), + true, + ); +}); From d835c7ef1390126196df2904e24f4ee18e389e11 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 16:53:45 -0400 Subject: [PATCH 15/36] Route unsafe prompts off cmd argv and unify harness failure status Grok and Antigravity send prompts through a file or stdin when a non-shim .cmd launcher cannot carry them, and classify failures with the shared classifier. Codex app-server flag no longer embeds quotes. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- CHANGELOG.md | 6 ++++ plugins/multi-antigravity/src/cli.ts | 10 ++++++- plugins/multi-antigravity/src/harness.ts | 23 +++------------ plugins/multi-grok/src/cli.ts | 12 +++++++- plugins/multi-grok/src/harness.ts | 31 ++++---------------- plugins/multi-openai/src/auth.ts | 2 +- plugins/multi-openai/src/usage.ts | 2 +- test/unit/native-antigravity-cli.test.ts | 22 +++++++++++++++ test/unit/native-grok-cli.test.ts | 36 ++++++++++++++++++++++++ 9 files changed, 95 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8234871..8589641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and ## Unreleased +- **Windows `.cmd` launchers no longer receive unsafe prompts.** Grok and + Antigravity deliver prompts containing `"`, `%`, `&` and similar through a + file or stdin when the CLI is a non-shim `.cmd`/`.bat`, and both use the + shared failure classifier for request-versus-retryable status. The Codex + app-server flag no longer embeds quotes. + - **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` diff --git a/plugins/multi-antigravity/src/cli.ts b/plugins/multi-antigravity/src/cli.ts index 15fe4df..72d72f3 100644 --- a/plugins/multi-antigravity/src/cli.ts +++ b/plugins/multi-antigravity/src/cli.ts @@ -3,6 +3,7 @@ import { NativeCliError, nativeEnvironment, promptArgumentLimitBytes, + promptArgvSafe, runNativeCli, } from '../../multi-core/src/gateway/harness-process.ts'; import { isRecord } from '../../multi-core/src/gateway/harness-session.ts'; @@ -194,8 +195,15 @@ export async function runAntigravity( function spawnAntigravity(options: AntigravityRunOptions): Promise { const platform = options.platform ?? process.platform; - const promptOnStdin = Buffer.byteLength(options.prompt) >= promptArgumentLimitBytes(platform); const environment = antigravityEnvironment(options.env); + const promptOnStdin = + Buffer.byteLength(options.prompt) >= promptArgumentLimitBytes(platform) || + !promptArgvSafe(options.prompt, { + executable: 'agy', + configuredPath: options.executable, + platform, + env: environment, + }); return runNativeCli({ name: 'Antigravity', executable: 'agy', diff --git a/plugins/multi-antigravity/src/harness.ts b/plugins/multi-antigravity/src/harness.ts index a651aeb..4cb96cb 100644 --- a/plugins/multi-antigravity/src/harness.ts +++ b/plugins/multi-antigravity/src/harness.ts @@ -11,6 +11,7 @@ import { type HarnessExchange, replayPersisted, } from '../../multi-core/src/gateway/harness-exchange.ts'; +import { classifyHarnessFailure } from '../../multi-core/src/gateway/harness-failure.ts'; import { continuation, historyRewound, @@ -20,7 +21,6 @@ import { terminalSuffix, writeNotices, } from '../../multi-core/src/gateway/harness-notices.ts'; -import { NativeCliError } from '../../multi-core/src/gateway/harness-process.ts'; import { NativeActionTracker, type NativeProgressObserver, @@ -31,7 +31,6 @@ import { type HarnessUsageFields, } from '../../multi-core/src/gateway/harness-response.ts'; import { - HarnessBusyError, type HarnessSession, type HarnessSessionBase, HarnessSessionStore, @@ -592,20 +591,6 @@ const missingPermissions: CheckAntigravityPermissions = async () => { throw new Error('Antigravity native permission policy is not configured'); }; -/** A transient process shortage retries; a missing binary or a busy agent fails identically. */ -const TRANSIENT_SPAWN = new Set(['EAGAIN', 'EMFILE', 'ENFILE', 'ENOMEM', 'ETXTBSY']); - -function deterministicFailure(error: unknown): boolean { - if (error instanceof HarnessBusyError) { - return true; - } - return ( - error instanceof NativeCliError && - error.code === 'spawn' && - !TRANSIENT_SPAWN.has(error.systemCode ?? '') - ); -} - export class AntigravityProviderError extends Error { readonly failure: { status: number; message: string }; constructor(error: unknown) { @@ -616,8 +601,8 @@ export class AntigravityProviderError extends Error { .slice(0, 500); super(message, { cause: error }); this.name = 'AntigravityProviderError'; - // A busy agent is a deterministic conflict, not a transient fault: a - // retryable status turned one such refusal into ten paid attempts. - this.failure = { status: deterministicFailure(error) ? 400 : 502, message }; + // Deterministic refusals are request errors: a retryable status turned one + // into ten paid attempts. + this.failure = { status: classifyHarnessFailure(error).status, message }; } } diff --git a/plugins/multi-grok/src/cli.ts b/plugins/multi-grok/src/cli.ts index 797a490..c54ffe5 100644 --- a/plugins/multi-grok/src/cli.ts +++ b/plugins/multi-grok/src/cli.ts @@ -6,6 +6,7 @@ import { NativeCliError, nativeEnvironment, promptArgumentLimitBytes, + promptArgvSafe, runNativeCli, } from '../../multi-core/src/gateway/harness-process.ts'; @@ -202,7 +203,16 @@ export async function runGrok(options: GrokRunOptions): Promise { throw new GrokCliError('Grok resumes a session or creates one, never both', 'spawn'); } const platform = options.platform ?? process.platform; - const oversized = Buffer.byteLength(options.prompt) >= promptArgumentLimitBytes(platform); + // A prompt too long for argv, or one a cmd.exe launcher cannot carry safely, + // travels in a private file instead. + const oversized = + Buffer.byteLength(options.prompt) >= promptArgumentLimitBytes(platform) || + !promptArgvSafe(options.prompt, { + executable: 'grok', + configuredPath: options.executable, + platform, + env: grokEnvironment(options.env), + }); const directory = oversized ? await mkdtemp(path.join(os.tmpdir(), 'multi-grok-')) : undefined; const promptFile = directory ? path.join(directory, 'prompt.txt') : undefined; if (promptFile) { diff --git a/plugins/multi-grok/src/harness.ts b/plugins/multi-grok/src/harness.ts index 1962d08..e2ea1c2 100644 --- a/plugins/multi-grok/src/harness.ts +++ b/plugins/multi-grok/src/harness.ts @@ -11,6 +11,7 @@ import { ExchangeRegistry, replayPersisted, } from '../../multi-core/src/gateway/harness-exchange.ts'; +import { classifyHarnessFailure } from '../../multi-core/src/gateway/harness-failure.ts'; import { continuation, historyRewound, @@ -29,10 +30,7 @@ import type { HarnessSession, HarnessSessionBase, } from '../../multi-core/src/gateway/harness-session.ts'; -import { - HarnessBusyError, - HarnessSessionStore, -} from '../../multi-core/src/gateway/harness-session.ts'; +import { HarnessSessionStore } from '../../multi-core/src/gateway/harness-session.ts'; import type { Emit, MessagesRequest, @@ -41,7 +39,7 @@ import type { import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; import { abortGraceMs, settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import type { GrokRunOptions, GrokRunResult, GrokStreamEvent, GrokUsage } from './cli.ts'; -import { GrokCliError, runGrok } from './cli.ts'; +import { runGrok } from './cli.ts'; import { grokFailureAdvice } from './errors.ts'; import type { GrokModel } from './models.ts'; import { selectGrokModel } from './models.ts'; @@ -515,29 +513,10 @@ const missingPermissions: CheckGrokPermissions = async () => { throw new Error('Grok native permission policy is not configured'); }; -/** - * A machine momentarily out of processes, file handles or memory starts the CLI on - * the next attempt; a missing binary or a denied path never does. Only the second - * kind is answered as a request error. - */ -const TRANSIENT_SPAWN = new Set(['EAGAIN', 'EMFILE', 'ENFILE', 'ENOMEM', 'ETXTBSY']); - -function permanentFailure(error: GrokCliError): boolean { - if (error.code === 'policy') { - return true; - } - return error.code === 'spawn' && !TRANSIENT_SPAWN.has(error.systemCode ?? ''); -} - export class GrokProviderError extends Error { readonly failure: { status: number; message: string }; constructor(error: unknown) { - // A busy agent, a policy the CLI did not apply, or a CLI that will not start, - // fails the same way on every attempt. Reporting them as 502 had Claude retry a - // paid run ten times over one prompt, so they are answered as a request error. - const deterministic = - error instanceof HarnessBusyError || - (error instanceof GrokCliError && permanentFailure(error)); + // Deterministic failures are request errors; retrying them repeats a paid run. const detail = error && typeof error === 'object' && 'message' in error ? error.message : error; const reported = String(detail ?? 'unknown Grok failure'); const advice = grokFailureAdvice(reported); @@ -547,6 +526,6 @@ export class GrokProviderError extends Error { .slice(0, 500); super(message, { cause: error }); this.name = 'GrokProviderError'; - this.failure = { status: deterministic ? 400 : 502, message }; + this.failure = { status: classifyHarnessFailure(error).status, message }; } } diff --git a/plugins/multi-openai/src/auth.ts b/plugins/multi-openai/src/auth.ts index 1622b9c..3c490f2 100644 --- a/plugins/multi-openai/src/auth.ts +++ b/plugins/multi-openai/src/auth.ts @@ -163,7 +163,7 @@ async function refreshIfUnchanged( env: environment, configuredPath: options.executable, }), - ['app-server', '-c', 'cli_auth_credentials_store="file"'], + ['app-server', '-c', 'cli_auth_credentials_store=file'], options.platform, environment, ); diff --git a/plugins/multi-openai/src/usage.ts b/plugins/multi-openai/src/usage.ts index 63a7b30..e9db52e 100644 --- a/plugins/multi-openai/src/usage.ts +++ b/plugins/multi-openai/src/usage.ts @@ -43,7 +43,7 @@ export async function readCodexUsage( env: environment, configuredPath: options.executable, }), - ['app-server', '-c', 'cli_auth_credentials_store="file"'], + ['app-server', '-c', 'cli_auth_credentials_store=file'], platform, environment, ); diff --git a/test/unit/native-antigravity-cli.test.ts b/test/unit/native-antigravity-cli.test.ts index 385bbdd..3939b45 100644 --- a/test/unit/native-antigravity-cli.test.ts +++ b/test/unit/native-antigravity-cli.test.ts @@ -300,6 +300,28 @@ test('a mid-sized prompt takes the stdin path on Windows but stays an argument o assert.deepEqual(short.spawnArgs.slice(0, 2), ['-p', 'hello']); }); +test('a prompt with cmd metacharacters rides stdin through a non-shim .cmd launcher on Windows', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'agy-cmd-')); + t.after(() => removeTemporary(directory)); + const launcher = path.join(directory, 'agy.cmd'); + await writeFile(launcher, '@echo off\r\nrem not an npm shim\r\n'); + for (const prompt of ['say "hi"', '100% done', 'a & b']) { + const child = fakeWindowsChild(); + const run = await runAntigravity({ + cwd: process.cwd(), + executable: launcher, + prompt, + platform: 'win32', + signal: AbortSignal.timeout(5000), + spawn: child.spawn, + }); + assert.equal(run.result.status, 'SUCCESS'); + assert.equal(child.spawnArgs.join(' ').includes(prompt), false); + assert.equal(child.spawnArgs.join(' ').includes('--input-format stream-json'), true); + assert.equal(child.stdin().includes(JSON.stringify(prompt).slice(1, -1)), true); + } +}); + test('escalates cancellation and reports missing terminal evidence as aborted', async (t) => { const cli = await fakeCli(t); const controller = new AbortController(); diff --git a/test/unit/native-grok-cli.test.ts b/test/unit/native-grok-cli.test.ts index 7ac5c6c..36d97c7 100644 --- a/test/unit/native-grok-cli.test.ts +++ b/test/unit/native-grok-cli.test.ts @@ -293,6 +293,42 @@ test('passes an oversized prompt through a file and removes it afterwards', asyn await assert.rejects(stat(args[1])); }); +async function grokSpawnArguments( + launcher: string, + prompt: string, + platform: NodeJS.Platform, +): Promise { + let spawned: readonly string[] = []; + await runGrok({ + cwd: path.dirname(launcher), + executable: launcher, + prompt, + platform, + signal: AbortSignal.timeout(2000), + spawn: ((_command: string, args?: readonly string[]) => { + spawned = args ?? []; + throw Object.assign(new Error('stop'), { code: 'ENOENT' }); + }) as unknown as GrokRunOptions['spawn'], + }).catch(() => undefined); + return spawned; +} + +test('a prompt with cmd metacharacters goes through a file on a non-shim .cmd launcher on Windows', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'grok-cmd-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const launcher = path.join(directory, 'grok.cmd'); + await writeFile(launcher, '@echo off\r\nrem not an npm shim\r\n'); + for (const prompt of ['say "hi"', '100% done', 'a & b']) { + const spawned = await grokSpawnArguments(launcher, prompt, 'win32'); + assert.equal(spawned.join(' ').includes('--prompt-file'), true); + assert.equal(spawned.join(' ').includes(prompt), false); + } + const plain = await grokSpawnArguments(launcher, 'plain', 'win32'); + assert.equal(plain.join(' ').includes('-p plain'), true); + const posix = await grokSpawnArguments(launcher, 'say "hi"', 'linux'); + assert.deepEqual(posix.slice(0, 2), ['-p', 'say "hi"']); +}); + test('refuses unreadable output and bounds what it buffers', async (t) => { const cli = await fakeCli(t); await assert.rejects( From 49f0a81a8d5b3f5ce13fc1462475b07020a400bd Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:01:33 -0400 Subject: [PATCH 16/36] Rework the Mod hooks around $state, $clock and one gateway client Session cleanup moves from session.detach (any client leaving the roster) to session.end, which covers /clear, exit and resume; detach is no longer hooked. Tool-call reviewer attribution moves to tool.call, which carries the loop's agentId, so provider workers in a Claude session are attributed with the mode from the prompt snapshot. Agent types are classified native only on a Claude model, built-ins locally, and a definition pinned to a provider model keeps gateway registration; the Agent description names provider models only while a provider worker is offered. Display tools register lazily (at a harness prompt, before a harness worker spawns, before a harness step) and re-register after a /clear. The streamed Agent input rewrite is gone. Hook state (policy snapshot, agent and spawn models, offer classification, display tools, usage panes) lives in $state and survives a hot reload, and drawings subscribe to it instead of invalidating. Every gateway call goes through one client with an explicit method, a byte bound and a $clock deadline; the status poll is a $clock timer cancelled at turn.complete and session.end; the policy wait is one held request. An unchanged Claude prompt snapshot is not reposted, Claude steps send no telemetry, and the usage pane honours isPlaced. Types build against .claude-plugin/types and include the hook tests; test:mod forces the Mods rollout flag locally. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .claude-plugin/plugin.json | 3 +- package.json | 2 +- plugins/multi-core/hooks/compact.ts | 110 ++--- plugins/multi-core/hooks/gateway.ts | 156 +++++++ plugins/multi-core/hooks/lifecycle.ts | 380 +++++++----------- plugins/multi-core/hooks/policy.ts | 98 +++-- plugins/multi-core/hooks/provider.ts | 5 + plugins/multi-core/hooks/register.ts | 282 +++++++------ plugins/multi-core/hooks/rows.ts | 186 ++++----- plugins/multi-core/hooks/state.ts | 45 +++ .../multi-core/hooks/tests/compact.test.ts | 86 +++- .../multi-core/hooks/tests/lifecycle.test.ts | 178 +++++++- .../hooks/tests/policy-resync.test.ts | 29 +- .../multi-core/hooks/tests/register.test.ts | 68 +++- plugins/multi-core/hooks/tests/rows.test.ts | 140 ++++++- plugins/multi-core/hooks/tests/usage.test.ts | 164 +++++++- .../hooks/tests/worker-rows.test.ts | 146 ++----- .../multi-core/hooks/tests/workers.test.ts | 280 +++++++++++-- plugins/multi-core/hooks/usage.ts | 201 ++++----- plugins/multi-core/hooks/worker-rows.ts | 31 +- plugins/multi-core/hooks/workers.ts | 329 ++++++++------- plugins/multi-core/src/gateway/mod-policy.ts | 2 +- plugins/multi-core/tsconfig.json | 3 +- plugins/multi-core/types/multi-core.d.ts | 53 +++ plugins/multi-core/types/timers.d.ts | 4 - test/unit/native-usage-menu.test.ts | 163 +------- 26 files changed, 1902 insertions(+), 1242 deletions(-) create mode 100644 plugins/multi-core/hooks/gateway.ts create mode 100644 plugins/multi-core/hooks/state.ts create mode 100644 plugins/multi-core/types/multi-core.d.ts delete mode 100644 plugins/multi-core/types/timers.d.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c109a53..080c63a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -8,5 +8,6 @@ "name": "multi-core", "description": "Shared Multi gateway, setup, status and reversible Claude startup integration.", "skills": "./plugins/multi-core/skills", - "hooks": "./plugins/multi-core/hooks/hooks.json" + "hooks": "./plugins/multi-core/hooks/hooks.json", + "types": "./plugins/multi-core/types/multi-core.d.ts" } diff --git a/package.json b/package.json index b600b62..ede0c9e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "tsc --noEmit", "test": "npm run typecheck && node --test --test-timeout=120000 \"test/unit/**/*.test.ts\"", "test:e2e": "node --test --test-timeout=120000 \"test/e2e/**/*.test.ts\"", - "test:mod": "npm run typecheck:mod && node --input-type=module -e \"import { spawn } from 'node:child_process'; const child = spawn('claude', ['plugin', 'test', 'plugins/multi-core'], { stdio: 'inherit', env: { ...process.env, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: '1' } }); child.on('exit', (code) => { process.exitCode = code ?? 1; });\"", + "test:mod": "npm run typecheck:mod && node --input-type=module -e \"import { spawn } from 'node:child_process'; const child = spawn('claude', ['plugin', 'test', 'plugins/multi-core'], { stdio: 'inherit', env: { ...process.env, CLAUDE_INTERNAL_FC_OVERRIDES: JSON.stringify({ tengu_plugin_hooks_modules: true }) } }); child.on('exit', (code) => { process.exitCode = code ?? 1; });\"", "test:live:compaction": "node test/live/native-compaction.ts", "test:live:zen": "node test/live/native-zen.ts", "test:live:cursor": "node test/live/native-cursor-harness.ts", diff --git a/plugins/multi-core/hooks/compact.ts b/plugins/multi-core/hooks/compact.ts index d23ede3..79e2fe5 100644 --- a/plugins/multi-core/hooks/compact.ts +++ b/plugins/multi-core/hooks/compact.ts @@ -1,35 +1,36 @@ import type { EngineInterface, Register } from 'claude-code'; +import { atom, read } from 'claude-code'; +import { accepted, getJson, isActive, postJson, type Wire } from './gateway.ts'; import { isHarnessModel } from './provider.ts'; -const maxBody = 32000; +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); -type GatewayResponse = { - accepted?: boolean; - error?: string; - generation?: number; - precomputeId?: string; - allow?: boolean; - messages?: readonly import('claude-code').SessionMessage[]; -}; +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), +}); -export const register = ( - on: Parameters[0], - _options: Parameters[1], - agentModels: ReadonlyMap = new Map(), -) => { +export const register = (on: Parameters[0], _options: Parameters[1]) => { on('session.compact', async ($, event, next) => { // session.model() describes only the main loop. A Claude child must never // inherit its external parent's compaction policy (or the reverse). - const model = await compactionModel($, event.agentId, agentModels); + const model = await compactionModel($, event.agentId); if (!isHarnessModel(model)) { return next(event); } - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } const sessionId = await $.session.id(); - const mode = await request($, {}, `/multi/mod/mode?sessionId=${encodeURIComponent(sessionId)}`); - if (mode?.generation === undefined) { + const mode = accepted(await getJson(wire($), '/multi/mod/mode', { sessionId })); + if (typeof mode?.generation !== 'number') { return { skip: 'Multi compaction policy generation is unavailable.' }; } const payload = { @@ -44,7 +45,7 @@ export const register = ( await precompute($, payload); return { skip: 'Multi summary preparation runs outside the hook budget.' }; } - const result = await request($, payload, '/multi/mod/compact/authorize'); + const result = accepted(await postJson(wire($), '/multi/mod/compact/authorize', payload)); if (result?.messages) { return { messages: result.messages }; } @@ -52,14 +53,12 @@ export const register = ( return next(event); } // Oversized transcripts still require a separate, small tool-free authorization. - const fallback = await request( - $, - { + const fallback = accepted( + await postJson(wire($), '/multi/mod/compact/authorize', { sessionId, agentId: event.agentId, generation: mode.generation, - }, - '/multi/mod/compact/authorize', + }), ); if (!fallback?.allow) { return { skip: 'Multi tool-free compaction authorization was not acknowledged.' }; @@ -69,67 +68,22 @@ export const register = ( }; async function precompute($: EngineInterface, payload: Record) { - const prepared = await request($, payload, '/multi/mod/compact/precompute'); + const prepared = accepted(await postJson(wire($), '/multi/mod/compact/precompute', payload)); if (prepared?.accepted && prepared.precomputeId) { - void request( - $, - { - sessionId: payload.sessionId, - agentId: payload.agentId, - generation: payload.generation, - precomputeId: prepared.precomputeId, - }, - '/multi/mod/compact/run', - ); - } -} - -async function active($: EngineInterface): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - return Boolean(base && token); -} - -async function request( - $: EngineInterface, - payload: Record, - route = '/multi/mod/session', -) { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - const body = JSON.stringify(payload); - if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const response = $.http.fetch(`${base}${route}`, { - method: route.includes('?') ? 'GET' : 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(route.includes('?') ? {} : { body }), - }); - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500); + // The gateway starts the summary and answers at once, so the hook can await it. + await postJson(wire($), '/multi/mod/compact/run', { + sessionId: payload.sessionId, + agentId: payload.agentId, + generation: payload.generation, + precomputeId: prepared.precomputeId, }); - const result = await Promise.race([response, timeout]); - return result.ok ? (JSON.parse(result.text) as GatewayResponse) : undefined; - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } } } /** The main model is never evidence of a child's provider. */ -export async function compactionModel( +async function compactionModel( $: EngineInterface, agentId: string | undefined, - agentModels: ReadonlyMap, ): Promise { - return agentId ? agentModels.get(agentId) : $.session.model(); + return agentId ? (await read($, agentModels))[agentId] : $.session.model(); } diff --git a/plugins/multi-core/hooks/gateway.ts b/plugins/multi-core/hooks/gateway.ts new file mode 100644 index 0000000..4b024cc --- /dev/null +++ b/plugins/multi-core/hooks/gateway.ts @@ -0,0 +1,156 @@ +import type { HttpInit, HttpResponse, SessionMessage } from 'claude-code'; + +/** + * The one client for the authenticated loopback `/multi/mod/*` control plane. + * + * Every hook reaches the gateway through `getJson` or `postJson`: the method is + * explicit, a body is bounded in bytes, and the wait is bounded by `$.clock` (a hooks + * module has no timers). `undefined` means the gateway is dormant, unreachable, slow or + * answered something that is not JSON; a refused reply (any non-2xx status) comes back + * with `refused` set and the gateway's reason, never with its body, so a caller that + * checks only `accepted` still fails closed. + */ +const maxBody = 32000; +const defaultTimeoutMs = 1500; + +export type GatewayResponse = { + refused?: true; + httpStatus?: number; + error?: string; + accepted?: boolean; + generation?: number | string; + status?: string; + stale?: boolean; + isOffered?: boolean; + execution?: 'claude' | 'harness'; + known?: boolean; + model?: string; + precomputeId?: string; + allow?: boolean; + messages?: readonly SessionMessage[]; + [field: string]: unknown; +}; + +export type GatewayOptions = { + /** How long to wait; 0 waits on the gateway's own bound (a held long poll). */ + timeoutMs?: number; + /** Ends the wait early, as `next.signal` ends a hook's with its dispatch. */ + signal?: AbortSignal; +}; + +/** + * What the client needs of the engine, as four calls. The engine follows `$` only into + * functions of the file that hooks, never across an import, and wants each `$.env.get` + * name and `$.noun.event(...)` spelled at its call site, so each hooks file builds this + * from its own `$` and hands it here: + * + * const wire = ($: EngineInterface): Wire => ({ + * url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + * token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + * fetch: (url, init) => $.http.fetch(url, init), + * sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + * }); + */ +export type Wire = { + url: () => Promise; + token: () => Promise; + fetch: (url: string, init: HttpInit) => Promise; + sleep: (ms: number, signal: AbortSignal) => Promise; +}; + +type Environment = { base: string; token: string }; + +async function environment(wire: Wire): Promise { + const base = await wire.url(); + const token = await wire.token(); + return base && token ? { base, token } : undefined; +} + +/** Whether this session was launched with the gateway's Mod control plane. */ +export async function isActive(wire: Wire): Promise { + return (await environment(wire)) !== undefined; +} + +export function getJson( + wire: Wire, + route: string, + query: Record = {}, + options: GatewayOptions = {}, +): Promise { + const search = new URLSearchParams(query).toString(); + return send(wire, 'GET', search ? `${route}?${search}` : route, undefined, options); +} + +export function postJson( + wire: Wire, + route: string, + payload: object, + options: GatewayOptions = {}, +): Promise { + return send(wire, 'POST', route, JSON.stringify(payload), options); +} + +/** The reply only when the gateway accepted the request. */ +export function accepted(reply: T | undefined): T | undefined { + return reply && !reply.refused ? reply : undefined; +} + +async function send( + wire: Wire, + method: 'GET' | 'POST', + route: string, + body: string | undefined, + options: GatewayOptions, +): Promise { + const env = await environment(wire); + if (!env || (body !== undefined && new TextEncoder().encode(body).length > maxBody)) { + return undefined; + } + const waiting = new AbortController(); + const cancel = () => waiting.abort(); + options.signal?.addEventListener('abort', cancel, { once: true }); + try { + const fetched = wire.fetch(`${env.base}${route}`, { + method, + headers: { 'content-type': 'application/json', 'x-multi-gateway-token': env.token }, + ...(body === undefined ? {} : { body }), + }); + // A late failure of a fetch that lost the race to its deadline is not reported. + fetched.catch(() => undefined); + const timeoutMs = options.timeoutMs ?? defaultTimeoutMs; + const result = + timeoutMs > 0 + ? await Promise.race([fetched, deadline(wire, timeoutMs, waiting.signal)]) + : await fetched; + return result.ok ? (JSON.parse(result.text) as T) : (refusal(result) as T); + } catch { + return undefined; + } finally { + options.signal?.removeEventListener('abort', cancel); + waiting.abort(); + } +} + +async function deadline(wire: Wire, ms: number, signal: AbortSignal): Promise { + await wire.sleep(ms, signal); + throw new Error('gateway request timeout'); +} + +function refusal(result: { text: string; status: number }): GatewayResponse { + let reason: string | undefined; + try { + const parsed: unknown = JSON.parse(result.text); + if (parsed && typeof parsed === 'object') { + const value = (parsed as { error?: unknown }).error; + reason = typeof value === 'string' && value ? value : undefined; + } + } catch { + // A non-JSON body still names the status below. + } + const detail = result.text.trim().slice(0, 200); + return { + refused: true, + httpStatus: result.status, + error: reason ?? (detail ? `gateway ${result.status}: ${detail}` : `gateway ${result.status}`), + }; +} diff --git a/plugins/multi-core/hooks/lifecycle.ts b/plugins/multi-core/hooks/lifecycle.ts index 445ded6..8fdc579 100644 --- a/plugins/multi-core/hooks/lifecycle.ts +++ b/plugins/multi-core/hooks/lifecycle.ts @@ -1,8 +1,58 @@ -import type { EngineInterface, Register, TurnStepInputChunk } from 'claude-code'; -import { isHarnessModel } from './provider.ts'; +import type { EngineInterface, Register, Timer } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { + MultiCoreDisplayTools, + MultiCorePolicy, + MultiCoreUsagePane, +} from '../types/multi-core.d.ts'; +import { getJson, isActive, postJson, type Wire } from './gateway.ts'; +import { isHarnessModel, isMultiModel } from './provider.ts'; import { type RowsClient, syncDisplayTools } from './rows.ts'; -import { forgetUsageSession } from './usage.ts'; -import { appendLabel, providerName, workerLabel } from './worker-rows.ts'; +import { withBounded } from './state.ts'; + +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy); +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); +const spawnModels = atom( + { plugin: 'multi-core', key: 'spawnModels' } as const, + {} as Record, +); +const claudeTypes = atom({ plugin: 'multi-core', key: 'claudeTypes' } as const, [] as string[]); +const offeredProviders = atom( + { plugin: 'multi-core', key: 'offeredProviders' } as const, + [] as string[], +); +const displayTools = atom( + { plugin: 'multi-core', key: 'displayTools' } as const, + { registered: [] } as MultiCoreDisplayTools, +); +const usagePanes = atom( + { plugin: 'multi-core', key: 'usagePanes' } as const, + {} as Record, +); +const advisorySessions = atom( + { plugin: 'multi-core', key: 'advisorySessions' } as const, + [] as string[], +); + +const rowsClient = ($: EngineInterface): RowsClient => ({ + wire: wire($), + sessionId: () => $.session.id(), + held: () => read($, displayTools), + save: (change) => update($, displayTools, change), + register: (tool) => $.tool.register(tool), +}); + +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), +}); type Status = { model?: string; @@ -11,271 +61,152 @@ type Status = { error?: string; elapsedMs?: number; startedAt?: number; + [field: string]: unknown; }; -function rememberBounded(entries: Map, key: string, value: T, limit = 256) { - entries.delete(key); - const oldest = entries.keys().next(); - if (entries.size >= limit && !oldest.done) { - entries.delete(oldest.value); +/** A harness loop's status poll: a timer on `$.clock`, so it ends with its environment. */ +type Poll = { timer: Timer; since: number; failures: number; isBusy: boolean }; + +const pollMs = 500; +const maximumPolls = 128; +const maximumFailures = 5; +/** The polls of this module instance; a hot reload cancels the timers with it. */ +const polls = new Map(); + +function stopPoll(key: string): boolean { + const poll = polls.get(key); + poll?.timer.cancel(); + return polls.delete(key); +} + +async function startPoll($: EngineInterface, key: string, agentId: string | undefined) { + if (polls.has(key) || polls.size >= maximumPolls) { + return; } - entries.set(key, value); + const poll: Poll = { + since: await $.clock.now(), + failures: 0, + isBusy: false, + timer: $.clock.every(pollMs, () => { + void tick($, key, agentId); + }), + }; + polls.set(key, poll); } -async function resolveWorkerModel( - $: EngineInterface, - subagentType: string, - model: string | undefined, - parentModel: string, - agentId?: string, -): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; +/** One status read; a tick still in flight when the next is due is skipped. */ +async function tick($: EngineInterface, key: string, agentId: string | undefined) { + const poll = polls.get(key); + if (!poll || poll.isBusy) { + return; } - let timer: ReturnType | undefined; + poll.isBusy = true; try { - const payload = { + const status = await getJson(wire($), '/multi/mod/lifecycle', { sessionId: await $.session.id(), - parentAgentId: agentId, - subagentType, - cwd: await $.session.cwd(), - model, - parentModel, - }; - const response = await Promise.race([ - $.http.fetch(`${base}/multi/mod/worker-model`, { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - body: JSON.stringify(payload), - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('gateway timeout')), 1500); - }), - ]); - if (!response.ok) { - return undefined; + agentId: agentId ?? 'main', + }); + if (polls.get(key) !== poll) { + return; } - const parsed: unknown = JSON.parse(response.text); - if (!parsed || typeof parsed !== 'object' || (parsed as { refused?: boolean }).refused) { - return undefined; + poll.failures = status && !status.refused ? 0 : poll.failures + 1; + if (status?.state && (status.startedAt ?? 0) >= poll.since) { + $.ui.status(statusText(status, agentId)); + if (status.state !== 'running') { + stopPoll(key); + } } - const resolved = (parsed as { model?: unknown }).model; - if (typeof resolved === 'string') { - return resolved; + if (poll.failures >= maximumFailures) { + stopPoll(key); } - return undefined; - } catch { - return undefined; } finally { - if (timer) { - clearTimeout(timer); - } + poll.isBusy = false; } } -async function rewriteAgentInput( - $: EngineInterface, - chunk: TurnStepInputChunk, - parentModel: string, - agentId: string | undefined, - toolUseId: string | undefined, - spawnModels: Map, -): Promise { - let parsed: Record | undefined; - try { - const value: unknown = JSON.parse(chunk.json); - if (value && typeof value === 'object' && !Array.isArray(value)) { - parsed = value as Record; - } - } catch { - return chunk; - } - if ( - !parsed || - typeof parsed.subagent_type !== 'string' || - !parsed.subagent_type.startsWith('multi-') - ) { - return chunk; - } - const explicitModel = typeof parsed.model === 'string' ? parsed.model : undefined; - const resolvedModel = await resolveWorkerModel( - $, - parsed.subagent_type, - explicitModel, - parentModel, - agentId, - ); - if (resolvedModel && toolUseId) { - rememberBounded(spawnModels, toolUseId, resolvedModel); - } - const label = - (resolvedModel ? workerLabel(resolvedModel) : undefined) ?? providerName(parsed.subagent_type); - if (!label || typeof parsed.description !== 'string') { - return chunk; - } - const description = appendLabel(parsed.description, label); - return { - ...chunk, - json: JSON.stringify({ ...parsed, description }), - }; -} - async function prepareStep( $: EngineInterface, - event: { agentId?: string; model: string }, - models: Map, - running: Map, + event: { agentId?: string; model: string; effort?: unknown }, ) { const key = event.agentId ?? 'main'; - models.set(key, event.model); - const native = isHarnessModel(event.model); - if (native && !running.has(key) && running.size < 128) { - const token = {}; - running.set(key, token); - void poll($, event.agentId, () => running.get(key) === token); + const known = (await read($, agentModels))[key]; + if (known !== event.model) { + await update($, agentModels, (held) => withBounded(held, key, event.model)); + } + if (isMultiModel(event.model)) { + // The gateway reads a Multi request's model and effort from this record, so it is + // sent before the request; a Claude step costs the gateway nothing. + await postJson(wire($), '/multi/mod/telemetry', { + ...event, + sessionId: await $.session.id(), + }); } - void postStep($, event); - if (native) { + if (isHarnessModel(event.model)) { + await startPoll($, key, event.agentId); // A harness may announce a tool the mod has not registered; register it // before the step's request, so the rows of later runs can anchor. await syncDisplayTools(rowsClient($)); } } -export const register = ( - on: Parameters[0], - _options: Parameters[1], - onDetach: () => void = () => {}, - models: Map = new Map(), - spawnModels: Map = new Map(), -) => { - const running = new Map(); +export const register = (on: Parameters[0], _options: Parameters[1]) => { on('turn.step', async function* ($, event, next) { - await prepareStep($, event, models, running); - const agentCalls = new Map(); - for await (const chunk of next(event)) { - if (chunk.kind === 'tool') { - if (chunk.name === 'Agent') { - agentCalls.set(chunk.index, chunk.id); - } - yield chunk; - continue; - } - if (chunk.kind === 'input' && agentCalls.has(chunk.index)) { - const rewritten = await rewriteAgentInput( - $, - chunk, - event.model, - event.agentId, - agentCalls.get(chunk.index), - spawnModels, - ); - yield rewritten; - continue; - } - yield chunk; - } + await prepareStep($, event); + return yield* next(event); }); on('turn.complete', async ($, event, next) => { const key = event.agentId ?? 'main'; - const model = models.get(key); - if (model?.startsWith('multi/')) { - await request($, '/multi/mod/usage/complete', { + const model = (await read($, agentModels))[key]; + if (isMultiModel(model)) { + await postJson(wire($), '/multi/mod/usage/complete', { sessionId: await $.session.id(), agentId: event.agentId, turnId: event.turnId, outcome: event.reason, }); } - const wasRunning = running.delete(key); + const wasRunning = stopPoll(key); // Retain a child's identity between turns: compaction can precede its next step. if (event.isAborted && isHarnessModel(model)) { - void cancelCompaction($, event.agentId); + await postJson(wire($), '/multi/mod/compact/cancel', { + sessionId: await $.session.id(), + agentId: event.agentId, + }); } - if (wasRunning && running.size === 0) { - void $.ui.status(undefined); + if (wasRunning && polls.size === 0) { + $.ui.status(undefined); } return next(event); }); - on('session.detach', async ($, event, next) => { - onDetach(); - forgetUsageSession(await $.session.id()); - running.clear(); - models.clear(); - void detach($); + // The session is over for good: a `/clear` or a resume ends one and starts another in + // the same process (`session.start` fires for neither), and the gateway forgets the + // ended one. `session.detach` fires when any client leaves the roster, a session still + // running, so it is not a place to forget anything. + on('session.end', async ($, event, next) => { + for (const key of [...polls.keys()]) { + stopPoll(key); + } + await forgetSession($, event.sessionId, next.signal); return next(event); }); }; -function rowsClient($: EngineInterface): RowsClient { - return { - sessionId: () => $.session.id(), - catalog: () => request($, '/multi/mod/display-tools'), - register: (tool) => $.tool.register(tool), - acknowledge: (payload) => request($, '/multi/mod/display-tools', payload), - }; -} - -async function postStep($: EngineInterface, event: object) { - await request($, '/multi/mod/telemetry', { ...event, sessionId: await $.session.id() }); -} - -async function detach($: EngineInterface) { - await request($, '/multi/mod/detach', { sessionId: await $.session.id() }); -} - -async function poll($: EngineInterface, agentId: string | undefined, active: () => boolean) { - const since = Date.now(); - const sessionId = await $.session.id(); - const query = `sessionId=${encodeURIComponent(sessionId)}&agentId=${encodeURIComponent(agentId ?? 'main')}`; - let failures = 0; - while (active() && failures < 5) { - const status = await request($, `/multi/mod/lifecycle?${query}`); - if (!active()) { - return; - } - failures = status ? 0 : failures + 1; - if (status?.state && (status.startedAt ?? 0) >= since) { - await $.ui.status(statusText(status, agentId)); - if (status.state !== 'running') { - return; - } - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } -} - -async function request( - $: EngineInterface, - route: string, - payload?: object, -): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const response = await Promise.race([ - $.http.fetch(`${base}${route}`, { - method: payload ? 'POST' : 'GET', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(payload ? { body: JSON.stringify(payload) } : {}), - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Gateway timeout')), 1500); - }), - ]); - return response.ok ? (JSON.parse(response.text) as Status) : undefined; - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } +/** Forgets everything the hooks held for the session that ended, here and in the gateway. */ +async function forgetSession($: EngineInterface, sessionId: string, signal: AbortSignal) { + await update($, policy, () => ({})); + await update($, agentModels, () => ({})); + await update($, spawnModels, () => ({})); + await update($, claudeTypes, () => []); + await update($, offeredProviders, () => []); + await update($, displayTools, () => ({ registered: [] })); + await update($, advisorySessions, (held) => held.filter((id) => id !== sessionId)); + await update($, usagePanes, (held) => { + const { [sessionId]: _closed, ...others } = held; + return others; + }); + $.ui.status(undefined); + if (await isActive(wire($))) { + await postJson(wire($), '/multi/mod/detach', { sessionId }, { timeoutMs: 1000, signal }); } } @@ -284,6 +215,3 @@ function statusText(status: Status, agentId: string | undefined) { // A failed or refused run names its reason, so the line says why, not only that. return `${status.model} · ${agentId ?? 'main'} · ${status.state} · ${elapsed}s ${status.error ?? status.detail ?? ''}`; } -async function cancelCompaction($: EngineInterface, agentId?: string) { - await request($, '/multi/mod/compact/cancel', { sessionId: await $.session.id(), agentId }); -} diff --git a/plugins/multi-core/hooks/policy.ts b/plugins/multi-core/hooks/policy.ts index 3a22e96..cd44820 100644 --- a/plugins/multi-core/hooks/policy.ts +++ b/plugins/multi-core/hooks/policy.ts @@ -1,3 +1,5 @@ +import type { MultiCorePolicy, MultiCorePrompt } from '../types/multi-core.d.ts'; +import { type GatewayOptions, getJson, postJson, type Wire } from './gateway.ts'; import { isHarnessModel } from './provider.ts'; export type PolicyResponse = { @@ -8,32 +10,58 @@ export type PolicyResponse = { status?: string; accepted?: boolean; }; +/** The policy's gateway for one session model; `wire` is the calling file's own. */ +export function policyClient(wire: Wire, model: string): PolicyClient { + return { + model, + post: (route, payload, options) => postJson(wire, route, payload, options), + get: (route, query) => getJson(wire, route, query), + }; +} + +/** The gateway as the policy needs it: explicit methods, as `gateway.ts` offers them. */ export type PolicyClient = { model: string; - request: (route: string, payload: Record) => Promise; -}; -export type PromptSnapshot = { sessionId: string; cwd: string; permissionMode: unknown }; -export type PolicyState = { - generation?: number; - prompt?: PromptSnapshot; - harnessReady?: boolean; - preparing?: Promise; + post: ( + route: string, + payload: Record, + options?: GatewayOptions, + ) => Promise; + get: (route: string, query: Record) => Promise; }; +export type PromptSnapshot = MultiCorePrompt; +export type PolicyState = MultiCorePolicy; -/** Share one admission across helpers spawned from the same prompt. */ +/** The admission each prompt is running, shared by every helper spawned from that prompt. */ +const preparing = new Map>(); + +/** A prompt's identity as the gateway holds it: what, with the model, decides a repost. */ +export function snapshotKey(snapshot: PromptSnapshot, model: string): string { + return JSON.stringify([snapshot.sessionId, snapshot.cwd, snapshot.permissionMode ?? null, model]); +} + +/** + * Share one admission across helpers spawned from the same prompt. It updates `state`, + * a copy the caller persists; only a state still holding the admitted prompt is changed. + */ export async function ensureHarnessPolicy(client: PolicyClient, state: PolicyState) { const snapshot = state.prompt; if (!snapshot || state.harnessReady) { return; } - const preparing = state.preparing ?? admitPrompt(client, snapshot, state.generation); - state.preparing = preparing; - const generation = await preparing; - if (state.prompt === snapshot) { - state.generation = generation; - state.harnessReady = generation !== undefined; - state.preparing = undefined; + const key = snapshotKey(snapshot, client.model); + const admission = preparing.get(key) ?? admitPrompt(client, snapshot, state.generation); + preparing.set(key, admission); + let generation: number | undefined; + try { + generation = await admission; + } finally { + if (preparing.get(key) === admission) { + preparing.delete(key); + } } + state.generation = generation; + state.harnessReady = generation !== undefined; } /** Prepare translated settings only for a harness prompt or a requested harness worker. */ @@ -46,7 +74,7 @@ export async function admitPrompt( if (!prepared) { return undefined; } - const response = await client.request('/multi/mod/session', { + const response = await client.post('/multi/mod/session', { policyGeneration: prepared.policyGeneration, sessionId: snapshot.sessionId, cwd: snapshot.cwd, @@ -71,7 +99,7 @@ export async function recordPrompt( if (isHarnessModel(model)) { return admitPrompt(client, snapshot, generation); } - const response = await client.request('/multi/mod/session', { + const response = await client.post('/multi/mod/session', { ...snapshot, model, event: 'prompt', @@ -91,7 +119,7 @@ export async function preparePolicy( sourceGeneration: number | undefined, ): Promise { let generation = sourceGeneration; - let started = await client.request('/multi/mod/policy', { sessionId, cwd, sourceGeneration }); + let started = await client.post('/multi/mod/policy', { sessionId, cwd, sourceGeneration }); if (started?.refused) { // A reloaded hooks module keeps a mode generation the gateway no longer agrees // with, and `/clear` detaches the session so the gateway holds none at all; @@ -102,7 +130,7 @@ export async function preparePolicy( return undefined; } generation = resynced.generation; - started = await client.request('/multi/mod/policy', { + started = await client.post('/multi/mod/policy', { sessionId, cwd, sourceGeneration: generation, @@ -115,29 +143,23 @@ export async function preparePolicy( return policyGeneration === undefined ? undefined : { policyGeneration, generation }; } +/** + * One request: the gateway holds the reply until discovery ends (`claude plugin list` + * and settings admission take seconds on a cold Windows start) or its own bound passes. + * The wait is the gateway's, so it needs no polling loop in the hook's budget. + */ async function awaitPolicy(client: PolicyClient, sessionId: string, generation: string) { - // Policy discovery runs `claude plugin list` and settings admission; on a - // cold Windows start that takes several seconds. Stay under the 10 s hook budget. - const deadline = Date.now() + 8000; - while (Date.now() < deadline) { - const result = await client.request('/multi/mod/policy', { sessionId, generation }); - if (result?.status === 'ready') { - return generation; - } - if (!result || result.refused || result.status === 'failed') { - return undefined; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return undefined; + const result = await client.post( + '/multi/mod/policy', + { sessionId, generation, wait: true }, + { timeoutMs: 0 }, + ); + return result?.status === 'ready' && !result.refused ? generation : undefined; } /** The gateway's own mode generation, or `{ generation: undefined }` when it holds none. */ async function modeGeneration(client: PolicyClient, sessionId: string) { - const mode = await client.request( - `/multi/mod/mode?sessionId=${encodeURIComponent(sessionId)}`, - {}, - ); + const mode = await client.get('/multi/mod/mode', { sessionId }); if (typeof mode?.generation === 'number') { return { generation: mode.generation }; } diff --git a/plugins/multi-core/hooks/provider.ts b/plugins/multi-core/hooks/provider.ts index 7dd8a81..4d3bd75 100644 --- a/plugins/multi-core/hooks/provider.ts +++ b/plugins/multi-core/hooks/provider.ts @@ -6,3 +6,8 @@ export function isHarnessModel(model: string | undefined): boolean { model?.startsWith('multi/grok/'), ); } + +/** Any Multi model: a harness, or a provider whose tool loop Claude Code runs. */ +export function isMultiModel(model: string | undefined): boolean { + return Boolean(model?.startsWith('multi/')); +} diff --git a/plugins/multi-core/hooks/register.ts b/plugins/multi-core/hooks/register.ts index fa747ed..3a01823 100644 --- a/plugins/multi-core/hooks/register.ts +++ b/plugins/multi-core/hooks/register.ts @@ -1,49 +1,70 @@ import type { EngineInterface, Register } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { MultiCoreDisplayTools, MultiCorePolicy } from '../types/multi-core.d.ts'; import { register as registerCompaction } from './compact.ts'; +import { isActive, postJson, type Wire } from './gateway.ts'; import { register as registerLifecycle } from './lifecycle.ts'; -import { type PolicyClient, type PolicyState, recordPrompt } from './policy.ts'; -import { isHarnessModel } from './provider.ts'; -import { type RowsClient, register as registerRows, syncDisplayTools } from './rows.ts'; +import { type PromptSnapshot, policyClient, recordPrompt, snapshotKey } from './policy.ts'; +import { isHarnessModel, isMultiModel } from './provider.ts'; +import { + callDisplayRow, + isDisplayTool, + type RowsClient, + register as registerRows, + syncDisplayTools, +} from './rows.ts'; +import { defined } from './state.ts'; import { register as registerUsage } from './usage.ts'; import { register as registerWorkers } from './workers.ts'; -const maxBody = 32000; +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), +}); -type GatewayResponse = { - refused?: true; - httpStatus?: number; - error?: string; - accepted?: boolean; - generation?: number | string; - status?: string; - stale?: boolean; -}; +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy); +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); +const displayTools = atom( + { plugin: 'multi-core', key: 'displayTools' } as const, + { registered: [] } as MultiCoreDisplayTools, +); + +const rowsClient = ($: EngineInterface): RowsClient => ({ + wire: wire($), + sessionId: () => $.session.id(), + held: () => read($, displayTools), + save: (change) => update($, displayTools, change), + register: (tool) => $.tool.register(tool), +}); + +type PromptEvent = { session_id: string; cwd: string; permission_mode?: string }; // Claude Code 2.1.272 loads exactly one entry from hooks.json `modules`; compose here. +// What the hooks keep lives in `$.state` (`state.ts`), so a hot reload loses none of it. export const register: Register = (on, options) => { - const policyState: PolicyState = {}; - // 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(); - const spawnModels = new Map(); - registerUsage(on, options, agentModels); - registerLifecycle( - on, - options, - () => { - policyState.generation = undefined; - policyState.prompt = undefined; - policyState.harnessReady = false; - policyState.preparing = undefined; - }, - agentModels, - spawnModels, - ); - registerCompaction(on, options, agentModels); - registerWorkers(on, options, agentModels, policyState, spawnModels); + registerUsage(on, options); + registerLifecycle(on, options); + registerCompaction(on, options); + registerWorkers(on, options); registerRows(on); + // One hook for every tool call (a module hooks an event once without a matcher): a + // display row is answered here, any other call is attributed to its provider's reviewer. + on('tool.call', async ($, event, next) => { + if (isDisplayTool(event.tool)) { + return callDisplayRow(rowsClient($), event); + } + await attributeToolCall($, event); + return next(event); + }); on('session.start', async ($, event, next) => { - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } await $.command.register({ @@ -51,122 +72,123 @@ export const register: Register = (on, options) => { description: 'Open provider quotas, spend, and session receipts.', immediate: true, }); - const response = await request($, '/multi/mod/session', { - sessionId: await $.session.id(), - cwd: await $.session.cwd(), - model: await $.session.model(), - event: 'start', - }); - policyState.generation = - typeof response?.generation === 'number' ? response.generation : undefined; - // Display rows for native harness actions; awaited so the first run's rows anchor. - await syncDisplayTools(rowsClient($)); + await greet($, await $.session.id(), await $.session.cwd()); return next(event); }); on('classic.UserPromptSubmit', async ($, event, next) => { - if (await active($)) { - policyState.prompt = snapshotOf(event); - policyState.harnessReady = false; - policyState.preparing = undefined; - policyState.generation = await recordPrompt( - await policyClient($), - policyState.prompt, - policyState.generation, - ); - policyState.harnessReady = - policyState.generation !== undefined && isHarnessModel(await $.session.model()); - } + await recordSnapshot($, event); return next(event); }); on('classic.SessionStart', async ($, event, next) => { - if ((await active($)) && typeof event.permission_mode === 'string') { - policyState.prompt = snapshotOf(event); - policyState.harnessReady = false; - policyState.preparing = undefined; - policyState.generation = await recordPrompt( - await policyClient($), - policyState.prompt, - policyState.generation, - ); - policyState.harnessReady = - policyState.generation !== undefined && isHarnessModel(await $.session.model()); + if (typeof event.permission_mode === 'string') { + await recordSnapshot($, event); } return next(event); }); }; -async function active($: EngineInterface): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - return Boolean(base && token); +/** + * The gateway's first record of a session (which also tells the launcher the mod is + * live). `/clear` ends a session and starts another in the process without a + * `session.start`, and `session.end` made the gateway forget the first, so the next + * prompt greets it again before recording its snapshot. + */ +async function greet($: EngineInterface, sessionId: string, cwd: string) { + const response = await postJson(wire($), '/multi/mod/session', { + sessionId, + cwd, + model: await $.session.model(), + event: 'start', + }); + await update($, policy, (held) => + defined({ + ...held, + generation: typeof response?.generation === 'number' ? response.generation : undefined, + handshake: sessionId, + }), + ); } -async function request($: EngineInterface, route: string, payload: Record) { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; +/** + * Records the prompt-boundary snapshot: the permission mode and workspace every + * provider reads. A harness prompt waits for its translated settings policy; any other + * model's snapshot is posted only when it differs from the one the gateway holds, so + * ordinary Claude turns cost no round trip. The snapshot is always kept here, because a + * harness worker spawned later admits against it. + */ +async function recordSnapshot($: EngineInterface, event: PromptEvent) { + if (!(await isActive(wire($)))) { + return; } - const body = JSON.stringify(payload); - if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) { - return undefined; + const held = await read($, policy); + if (held.handshake !== event.session_id) { + await greet($, event.session_id, event.cwd); } - const isGet = route.includes('?'); - let timer: ReturnType | undefined; - try { - const response = $.http.fetch(`${base}${route}`, { - method: isGet ? 'GET' : 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(isGet ? {} : { body }), - }); - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500); - }); - const result = await Promise.race([response, timeout]); - return result.ok ? (JSON.parse(result.text) as GatewayResponse) : refusal(result); - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } + const prompt = snapshotOf(event); + const model = await $.session.model(); + const current = await read($, policy); + const harness = isHarnessModel(model); + const key = snapshotKey(prompt, model); + let generation = current.generation; + let posted: string | undefined; + if (harness || current.posted !== key || generation === undefined) { + generation = await recordPrompt(policyClient(wire($), model), prompt, generation); + posted = harness || generation === undefined ? undefined : key; + } else { + posted = key; + } + await update($, policy, (latest) => + defined({ + ...latest, + prompt, + generation, + posted, + harnessReady: harness && generation !== undefined, + }), + ); + if (harness) { + // The display rows of this prompt's native run anchor only once their tools exist. + await syncDisplayTools(rowsClient($)); } } -// A refused reply keeps the gateway's reason; `refused` stops polling callers. -function refusal(result: { text: string; status: number }): GatewayResponse { - let error: string | undefined; +function snapshotOf(event: PromptEvent): PromptSnapshot { + return defined({ + sessionId: event.session_id, + cwd: event.cwd, + permissionMode: event.permission_mode, + }); +} + +/** + * 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. It runs + * from `tool.call`, which carries the calling loop's agentId (`classic.PreToolUse` does + * not, so it could not tell a provider worker's call from the session's own). + */ +async function attributeToolCall( + $: EngineInterface, + event: { tool: string; tool_use_id?: string; agentId?: string }, +) { try { - const parsed: unknown = JSON.parse(result.text); - if (parsed && typeof parsed === 'object') { - error = (parsed as { error?: unknown }).error as string | undefined; + const model = + event.agentId === undefined + ? await $.session.model() + : (await read($, agentModels))[event.agentId]; + if (!isMultiModel(model) || !(await isActive(wire($)))) { + return; } + // The engine hands a tool call no permission mode: the prompt-boundary snapshot has it. + const mode = (await read($, policy)).prompt?.permissionMode; + await postJson(wire($), '/multi/permission', { + session_id: await $.session.id(), + tool_use_id: event.tool_use_id, + tool_name: event.tool, + cwd: await $.session.cwd(), + ...(mode === undefined ? {} : { permission_mode: mode }), + }); } catch { - // A non-JSON body still names the status below. + // Attribution is best effort; Claude's own checks decide the call. } - return { - refused: true, - httpStatus: result.status, - error: error ?? `gateway ${result.status}`, - }; -} - -function snapshotOf(event: { session_id: string; cwd: string; permission_mode?: string }) { - return { sessionId: event.session_id, cwd: event.cwd, permissionMode: event.permission_mode }; -} - -function rowsClient($: EngineInterface): RowsClient { - return { - sessionId: () => $.session.id(), - catalog: () => request($, '/multi/mod/display-tools?', {}), - register: (tool) => $.tool.register(tool), - acknowledge: (payload) => request($, '/multi/mod/display-tools', payload), - }; -} - -async function policyClient($: EngineInterface): Promise { - return { - model: await $.session.model(), - request: (route, payload) => request($, route, payload), - }; } diff --git a/plugins/multi-core/hooks/rows.ts b/plugins/multi-core/hooks/rows.ts index 31f27e2..4364360 100644 --- a/plugins/multi-core/hooks/rows.ts +++ b/plugins/multi-core/hooks/rows.ts @@ -1,4 +1,6 @@ import type { EngineInterface, Register, ToolSpec } from 'claude-code'; +import type { MultiCoreDisplayTools } from '../types/multi-core.d.ts'; +import { accepted, getJson, postJson, type Wire } from './gateway.ts'; import { errorBody, headerSummary, @@ -8,6 +10,7 @@ import { toolHeader, unconfirmedBody, } from './rows-view.ts'; +import { rememberBounded } from './state.ts'; /** * Native harness actions as Claude Code tool rows. @@ -21,14 +24,15 @@ import { * the gateway did not originate, answers the gateway's own with the native output, * and draws the row as Claude Code draws the built-in the action mirrors * (`rows-view.ts`), under the native tool's name. - * `register.ts` syncs the names at session start and `lifecycle.ts` before each - * harness step (a module hooks each event once), both through a `RowsClient`. + * The names are registered lazily, so a session that never runs a harness model lists + * none: `register.ts` syncs them at a harness prompt, `workers.ts` before a harness + * worker spawns, and `lifecycle.ts` before each harness step (a module hooks each + * event once). What is registered, and the catalog revision it answers, is `$.state`. */ const prefix = 'mcp__multi-core__'; const tokenKey = 'multi_row'; const namePattern = /^[A-Za-z0-9_-]{1,47}$/; const maximumTools = 160; -const maximumBody = 32000; const maximumRemembered = 512; const reminder = /[\s\S]*?<\/system-reminder>/g; const description = @@ -41,10 +45,38 @@ const refusal = type On = Parameters[0]; type Row = { output: string; isError: boolean }; -const registered = new Set(); -/** Row inputs by tool_use id: a `ToolResult` carries no input, and its drawing needs it. */ +const rowGateway = ($: EngineInterface): RowGateway => ({ + wire: { + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + }, + sessionId: () => $.session.id(), +}); + +/** What answering a display row needs: the gateway and this session's id. */ +export type RowGateway = { wire: Wire; sessionId: () => Promise }; + +/** + * What syncing needs of the engine. The engine follows `$` only into functions of the + * file that hooks, so each caller (`register.ts`, `lifecycle.ts`, `workers.ts`) builds + * this from its own `$`: the state calls are `read`/`update` on `displayTools`. + */ +export type RowsClient = RowGateway & { + held: () => Promise; + save: ( + change: (held: MultiCoreDisplayTools) => MultiCoreDisplayTools, + ) => Promise; + register: (tool: ToolSpec) => Promise; +}; + +/** + * Row inputs by tool_use id: a `ToolResult` carries no input, and its drawing needs it. + * A render memo (every draw of a row's `ToolUse` refills it), not state a reload loses, + * and never written from a render hook, which `$.state` refuses. + */ const inputs = new Map>(); -let revision: number | undefined; export function isDisplayTool(tool: unknown): tool is string { return typeof tool === 'string' && tool.startsWith(prefix); @@ -62,24 +94,11 @@ export const register = (on: On) => { if (!isDisplayTool(event.tool)) { return next(event); } - const row = await issuedRow($, event.input, event.tool_use_id); + const row = await issuedRow(rowGateway($), event.input, event.tool_use_id); return row ? { decision: 'allow' as const, reason: 'A row the Multi gateway issued for this call.' } : { decision: 'deny' as const, reason: refusal }; }); - on('tool.call', async ($, event, next) => { - if (!isDisplayTool(event.tool)) { - return next(event); - } - const row = await issuedRow($, event, event.tool_use_id); - if (!row) { - return { deny: refusal }; - } - remember(event.tool_use_id, event); - return row.isError - ? { isError: true as const, result: row.output } - : { result: [{ type: 'text', text: row.output }] }; - }); on('ui.render', { component: 'ToolUse' }, async ($, event, next) => { if (!isDisplayTool(event.props.tool)) { return next(event); @@ -116,12 +135,24 @@ export const register = (on: On) => { }); }; -function remember(toolUseId: string, input: Record) { - inputs.delete(toolUseId); - inputs.set(toolUseId, input); - while (inputs.size > maximumRemembered) { - inputs.delete(inputs.keys().next().value as string); +/** + * A display row's call, answered with the native output when the gateway issued its + * token for this call; any other call to a display name is refused. `register.ts` + * routes display tools here from the module's one `tool.call` hook. + */ +export async function callDisplayRow(client: RowGateway, event: { tool_use_id?: string }) { + const row = await issuedRow(client, event, event.tool_use_id); + if (!row) { + return { deny: refusal }; } + remember(String(event.tool_use_id), event as Record); + return row.isError + ? { isError: true as const, result: row.output } + : { result: [{ type: 'text', text: row.output }] }; +} + +function remember(toolUseId: string, input: Record) { + rememberBounded(inputs, toolUseId, input, maximumRemembered); } async function sessionCwd($: EngineInterface) { @@ -132,33 +163,20 @@ async function sessionCwd($: EngineInterface) { } } -/** - * What syncing needs from the engine and the gateway. The engine passes `$` only - * into functions of the module that hooks, so each caller builds this from its - * own `$` (as `policy.ts` takes a `PolicyClient`). - */ -export type RowsClient = { - sessionId: () => Promise; - /** `GET /multi/mod/display-tools`: the gateway's offered names and their revision. */ - catalog: () => Promise; - register: (tool: ToolSpec) => Promise; - /** `POST /multi/mod/display-tools`: the names now registered. */ - acknowledge: (payload: Record) => Promise; -}; - /** * Registers every display tool the gateway offers that is not registered yet, * then tells the gateway which are, since it only emits rows for those. */ export async function syncDisplayTools(client: RowsClient) { - const catalog = record(await client.catalog()); - if (!catalog || typeof catalog.revision !== 'number' || catalog.revision === revision) { + const catalog = record(accepted(await getJson(client.wire, '/multi/mod/display-tools'))); + const known = await client.held(); + if (!catalog || typeof catalog.revision !== 'number' || catalog.revision === known.revision) { return; } const names = (Array.isArray(catalog.names) ? catalog.names : []) .filter((name): name is string => typeof name === 'string' && namePattern.test(name)) .slice(0, maximumTools) - .filter((name) => !registered.has(name)); + .filter((name) => !known.registered.includes(name)); const results = await Promise.allSettled( names.map((name) => client.register({ @@ -168,50 +186,52 @@ export async function syncDisplayTools(client: RowsClient) { }), ), ); - let complete = true; - for (const [index, result] of results.entries()) { - if (result.status === 'fulfilled') { - registered.add(names[index] as string); - } else { - // A toolless session cannot register tools; its gateway emits no rows. - complete = false; - } - } + // A toolless session cannot register tools; its gateway emits no rows. + const complete = results.every((result) => result.status === 'fulfilled'); + const registered = [ + ...known.registered, + ...names.filter((_name, index) => results[index]?.status === 'fulfilled'), + ]; // The revision advances only once the gateway confirmed the registered set; a // timeout or an HTTP failure (which the client returns as no reply) is retried // on the next sync, with the names registered so far kept. - const confirmed = await acknowledged(client); - if (complete && confirmed) { - revision = catalog.revision; - } + const confirmed = await acknowledged(client, registered); + const revision = complete && confirmed ? (catalog.revision as number) : undefined; + await client.save((latest) => + revision === undefined ? { ...latest, registered } : { registered, revision }, + ); } /** Tells the gateway which names are registered; true only for its validated reply. */ -async function acknowledged(client: RowsClient): Promise { - if (!registered.size) { +async function acknowledged(client: RowsClient, registered: readonly string[]): Promise { + if (!registered.length) { return true; } const reply = record( - await client.acknowledge({ - sessionId: await client.sessionId(), - registered: [...registered], - }), + accepted( + await postJson(client.wire, '/multi/mod/display-tools', { + sessionId: await client.sessionId(), + registered, + }), + ), ); return typeof reply?.registered === 'number'; } /** The native output of a row, only when the gateway issued its token for this call. */ -async function issuedRow($: EngineInterface, input: unknown, toolUseId: unknown) { +async function issuedRow(client: RowGateway, input: unknown, toolUseId: unknown) { const token = record(input)?.[tokenKey]; if (typeof token !== 'string' || typeof toolUseId !== 'string') { return undefined; } const reply = record( - await gateway($, '/multi/mod/display', { - sessionId: await $.session.id(), - token, - toolUseId, - }), + accepted( + await postJson(client.wire, '/multi/mod/display', { + sessionId: await client.sessionId(), + token, + toolUseId, + }), + ), ); if (!reply || typeof reply.output !== 'string') { return undefined; @@ -246,35 +266,3 @@ export function outputText(output: unknown): string { } return text.replaceAll(reminder, '').replaceAll('\r', '').trimEnd(); } - -async function gateway($: EngineInterface, route: string, payload?: Record) { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - const body = payload ? JSON.stringify(payload) : undefined; - if (body && body.length > maximumBody) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const response = await Promise.race([ - $.http.fetch(`${base}${route}`, { - method: body ? 'POST' : 'GET', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(body ? { body } : {}), - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Gateway timeout')), 1500); - }), - ]); - return response.ok ? (JSON.parse(response.text) as unknown) : undefined; - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } - } -} diff --git a/plugins/multi-core/hooks/state.ts b/plugins/multi-core/hooks/state.ts new file mode 100644 index 0000000..554a420 --- /dev/null +++ b/plugins/multi-core/hooks/state.ts @@ -0,0 +1,45 @@ +/** + * What the hooks keep across a hot reload of this module is `$.state` (declared in + * `types/multi-core.d.ts`): read with `read($, atom)`, changed with `update($, atom, change)`. + * Each hooks file names its atoms itself, since the engine's scan reads a state value's + * plugin and key from the file that uses it. Never mutate a value read: copy it. A + * `ui.render` hook that reads one is drawn again when it changes. + */ +const defaultLimit = 256; + +/** A copy of `entries` with `key` newest, the oldest dropped past `limit`. */ +export function withBounded( + entries: Readonly>, + key: string, + value: T, + limit = defaultLimit, +): Record { + const { [key]: _replaced, ...others } = entries; + const kept = Object.entries(others).slice(-(limit - 1)); + return Object.fromEntries([...kept, [key, value]]); +} + +/** A copy of `items` with `item` newest and at most `limit` kept. */ +export function withItem(items: readonly string[], item: string, limit = defaultLimit): string[] { + return [...items.filter((held) => held !== item), item].slice(-limit); +} + +/** Bounded like the other per-call records: the oldest entry makes room. */ +export function rememberBounded( + entries: Map, + key: string, + value: T, + limit = defaultLimit, +) { + entries.delete(key); + const oldest = entries.keys().next(); + if (entries.size >= limit && !oldest.done) { + entries.delete(oldest.value); + } + entries.set(key, value); +} + +/** An object without its undefined fields, which the state's JSON data does not hold. */ +export function defined(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as T; +} diff --git a/plugins/multi-core/hooks/tests/compact.test.ts b/plugins/multi-core/hooks/tests/compact.test.ts index d5170c5..72cd3ea 100644 --- a/plugins/multi-core/hooks/tests/compact.test.ts +++ b/plugins/multi-core/hooks/tests/compact.test.ts @@ -1,11 +1,12 @@ -import { expect, test } from 'claude-code/testing'; -import { compactionModel, register } from '../compact.ts'; +import { expect, mock, test } from 'claude-code/testing'; +import { register } from '../compact.ts'; test('registers the compaction boundary hook', () => { expect(typeof register).toBe('function'); }); test('compaction with an unknown generation skips without calling core', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.model', () => ({ value: 'multi/cursor/auto' })); on('session.id', () => ({ value: 's' })); @@ -15,12 +16,13 @@ test('compaction with an unknown generation skips without calling core', async ( core = true; return { skip: 'core' }; }); - const result = await $.session.compact({}); + const result = await $.session.compact({} as never); expect(result.skip).toBe('Multi compaction policy generation is unavailable.'); expect(core).toBe(false); }); test('a stale digest falls through to core compaction', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.model', () => ({ value: 'multi/cursor/auto' })); on('session.id', () => ({ value: 's' })); @@ -33,11 +35,12 @@ test('a stale digest falls through to core compaction', async ($, on) => { }, })); on('session.compact', () => ({ skip: 'core fallback' })); - const result = await $.session.compact({}); + const result = await $.session.compact({} as never); expect(result.skip).toBe('core fallback'); }); test('failed fallback authorization skips instead of running core with ordinary tools', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.model', () => ({ value: 'multi/cursor/auto' })); on('session.id', () => ({ value: 's' })); @@ -49,12 +52,13 @@ test('failed fallback authorization skips instead of running core with ordinary core = true; return { skip: 'core' }; }); - const result = await $.session.compact({}); + const result = await $.session.compact({} as never); expect(result.skip).toBe('Multi tool-free compaction authorization was not acknowledged.'); expect(core).toBe(false); }); test('ready authenticated summary replaces the transcript without calling core', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.model', () => ({ value: 'multi/cursor/auto' })); on('session.id', () => ({ value: 's' })); @@ -73,8 +77,8 @@ test('ready authenticated summary replaces the transcript without calling core', core = true; return { skip: 'core' }; }); - const result = await $.session.compact({}); - expect(result.messages?.[0].text).toBe('summary'); + const result = await $.session.compact({} as never); + expect(result.messages?.[0]?.text).toBe('summary'); expect(core).toBe(false); }); @@ -87,6 +91,7 @@ for (const model of [ 'multi/zen/glm-5', ]) { test(`native ${model} compacts without consulting the gateway`, async ($, on) => { + mock.clock(on); on('session.model', () => ({ value: model })); on('env.get', () => ({ value: 'configured' })); let fetches = 0; @@ -95,19 +100,74 @@ for (const model of [ return { value: { ok: false, status: 409, headers: {}, text: '{}' } }; }); on('session.compact', () => ({ skip: 'native compaction reached' })); - const result = await $.session.compact({}); + const result = await $.session.compact({} as never); expect(result.skip).toBe('native compaction reached'); expect(fetches).toBe(0); }); } test('compaction uses the child model without borrowing its parent model', async ($, on) => { + mock.clock(on); + mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000' }); on('session.model', () => ({ value: 'multi/cursor/auto' })); - const models = new Map([ + on('session.id', () => ({ value: 's' })); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'ok' }; + return { + turnId: event.turnId, + index: 0, + answer: 'ok', + toolUses: [], + stopReason: 'end_turn', + usage: null, + }; + }); + on('session.compact', () => ({ skip: 'native compaction reached' })); + on('http.fetch', () => ({ value: { ok: false, status: 409, headers: {}, text: '{}' } })); + // The hooks record each loop's model as it steps; that record is what compaction reads. + for (const [agentId, model] of [ ['claude-child', 'claude-sonnet-5'], ['external-child', 'multi/cursor/auto'], - ]); - expect(await compactionModel($, 'claude-child', models)).toBe('claude-sonnet-5'); - expect(await compactionModel($, 'external-child', models)).toBe('multi/cursor/auto'); - expect(await compactionModel($, 'unknown-child', models)).toBe(undefined); + ] as const) { + for await (const _chunk of $.turn.step({ + turnId: agentId, + agentId, + index: 0, + model, + messageCount: 1, + })) { + // Observe the step, as the engine does. + } + } + const transcript = [{ role: 'user' as const, text: 'hello', toolUses: [] }]; + // A Claude child compacts natively, whatever the main loop runs on. + expect( + ( + await $.session.compact({ + agentId: 'claude-child', + trigger: 'auto', + messages: transcript, + } as never) + ).skip, + ).toBe('native compaction reached'); + // A harness child is held to the gateway's generation, which this gateway does not have. + expect( + ( + await $.session.compact({ + agentId: 'external-child', + trigger: 'auto', + messages: transcript, + } as never) + ).skip, + ).toBe('Multi compaction policy generation is unavailable.'); + // An unrecorded child's model is unknown, never its parent's. + expect( + ( + await $.session.compact({ + agentId: 'unknown-child', + trigger: 'auto', + messages: transcript, + } as never) + ).skip, + ).toBe('native compaction reached'); }); diff --git a/plugins/multi-core/hooks/tests/lifecycle.test.ts b/plugins/multi-core/hooks/tests/lifecycle.test.ts index 897d773..78630db 100644 --- a/plugins/multi-core/hooks/tests/lifecycle.test.ts +++ b/plugins/multi-core/hooks/tests/lifecycle.test.ts @@ -1,6 +1,7 @@ import { expect, mock, test } from 'claude-code/testing'; test('turn.step telemetry preserves core model, effort and streamed chunks', async ($, on) => { + mock.clock(on); mock.env(on, {}); on('session.id', () => ({ value: 's' })); on('turn.step', async function* (_$, event) { @@ -32,6 +33,7 @@ test('turn.step telemetry preserves core model, effort and streamed chunks', asy }); test('Claude completion does not wait on Multi accounting or clear another provider status', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', @@ -82,6 +84,7 @@ test('Claude completion does not wait on Multi accounting or clear another provi }); test('a completed harness child retains its provider for compaction between turns', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000' }); on('session.id', () => ({ value: 's' })); on('session.model', () => ({ value: 'claude-sonnet-5' })); @@ -115,9 +118,180 @@ test('a completed harness child retains its provider for compaction between turn answer: 'done', durationMs: 1, isAborted: false, - reason: 'end_turn', + reason: 'answer', }); - expect((await $.session.compact({ agentId: 'child' })).skip).toBe( + expect((await $.session.compact({ agentId: 'child' } as never)).skip).toBe( 'Multi compaction policy generation is unavailable.', ); }); + +const gatewayEnv = { + MULTI_GATEWAY_TOKEN: 'secret', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', +}; +const stepResult = (event: { turnId: string }) => ({ + turnId: event.turnId, + index: 0, + answer: 'done', + toolUses: [], + stopReason: 'end_turn' as const, + usage: null, +}); + +test('a harness step polls the run status on the clock until its turn completes', async ($, on) => { + const clock = mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 's' })); + const lifecycleReads: string[] = []; + on('http.fetch', (_$, event) => { + const reply = (value: unknown) => ({ + value: { ok: true, status: 200, headers: {}, text: JSON.stringify(value) }, + }); + if (event.url.includes('/multi/mod/lifecycle')) { + lifecycleReads.push(event.url); + return reply({ + model: 'multi/cursor/auto', + state: 'running', + detail: 'reading', + elapsedMs: 3000, + startedAt: 10, + }); + } + return reply({ revision: 1, names: [], accepted: true }); + }); + const lines: Array = []; + on('ui.status', (_$, event) => { + lines.push(event.text); + return { value: undefined }; + }); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + on('turn.complete', (_$, event) => ({ text: event.answer })); + for await (const _chunk of $.turn.step({ + turnId: 't', + index: 0, + model: 'multi/cursor/auto', + messageCount: 1, + })) { + // The run is under way once its step began. + } + // Nothing polls outside the clock: the first read comes when the clock reaches it. + expect(lifecycleReads.length).toBe(0); + await clock.advance(500); + expect(lifecycleReads.length).toBe(1); + expect(lifecycleReads[0]).toContain('sessionId=s&agentId=main'); + expect(lines.at(-1)).toBe('multi/cursor/auto · main · running · 3s reading'); + await clock.advance(1000); + expect(lifecycleReads.length).toBe(3); + await $.turn.complete({ + turnId: 't', + answer: 'done', + durationMs: 1, + isAborted: false, + reason: 'answer', + }); + expect(lines.at(-1)).toBe(undefined); + await clock.advance(5000); + expect(lifecycleReads.length).toBe(3); +}); + +test('a Claude step costs the gateway no telemetry and starts no poll', async ($, on) => { + const clock = mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 's' })); + const urls: string[] = []; + on('http.fetch', (_$, event) => { + urls.push(event.url); + return { value: { ok: true, status: 200, headers: {}, text: '{}' } }; + }); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + for await (const _chunk of $.turn.step({ + turnId: 't', + index: 0, + model: 'claude-sonnet-5', + messageCount: 1, + })) { + // A native step. + } + await clock.advance(5000); + expect(urls).toEqual([]); + // A provider-loop (not harness) step reports its model and effort, and polls nothing. + for await (const _chunk of $.turn.step({ + turnId: 't2', + index: 0, + model: 'multi/openai/gpt-6-astra', + effort: 'high', + messageCount: 1, + })) { + // The gateway reads this step's model and effort before its request. + } + await clock.advance(5000); + expect(urls).toEqual(['http://127.0.0.1:4000/multi/mod/telemetry']); +}); + +test('a request that outlasts its bound gives up on the clock, not on a timer', async ($, on) => { + const clock = mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 's' })); + on('http.fetch', () => new Promise(() => undefined)); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + let stepped = false; + const step = (async () => { + for await (const _chunk of $.turn.step({ + turnId: 't', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // The step waits on its telemetry post. + } + stepped = true; + })(); + await clock.advance(1499); + expect(stepped).toBe(false); + await clock.advance(1); + await step; + expect(stepped).toBe(true); +}); + +test('session end forgets the gateway session, while a client leaving does not', async ($, on) => { + mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 'ending' })); + on('session.end', (_$, event) => ({ sessionId: event.sessionId })); + on('ui.status', () => ({ value: undefined })); + const posts: Array<{ url: string; body: Record }> = []; + on('http.fetch', (_$, event) => { + posts.push({ + url: event.url, + body: event.init?.body ? JSON.parse(String(event.init.body)) : {}, + }); + return { value: { ok: true, status: 200, headers: {}, text: '{"accepted":true}' } }; + }); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + for await (const _chunk of $.turn.step({ + turnId: 't', + agentId: 'worker', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // A provider worker's model is recorded. + } + posts.length = 0; + await $.session.end({ reason: 'clear', sessionId: 'ending', resume: { id: 'ending' } } as never); + expect(posts).toEqual([ + { url: 'http://127.0.0.1:4000/multi/mod/detach', body: { sessionId: 'ending' } }, + ]); +}); diff --git a/plugins/multi-core/hooks/tests/policy-resync.test.ts b/plugins/multi-core/hooks/tests/policy-resync.test.ts index 73aa099..518e33c 100644 --- a/plugins/multi-core/hooks/tests/policy-resync.test.ts +++ b/plugins/multi-core/hooks/tests/policy-resync.test.ts @@ -14,15 +14,18 @@ type Call = { url: string; body: Record }; function gateway(held: number | undefined, calls: Call[]): PolicyClient { return { model: 'multi/cursor/auto', - request: async (route, body) => { + get: async (route, query) => { + calls.push({ url: `${route}?${new URLSearchParams(query)}`, body: {} }); + return held === undefined ? { refused: true, httpStatus: 409 } : { generation: held }; + }, + post: async (route, body) => { calls.push({ url: route, body }); - if (route.startsWith('/multi/mod/mode?')) { - return held === undefined ? { refused: true, httpStatus: 409 } : { generation: held }; - } if (route === '/multi/mod/session') { return { accepted: true, generation: 11 }; } if (typeof body.generation === 'string') { + // One held request: the gateway answers once discovery has ended. + expect(body.wait).toBe(true); return { generation: body.generation, status: 'ready' }; } if (body.sourceGeneration !== held) { @@ -33,7 +36,11 @@ function gateway(held: number | undefined, calls: Call[]): PolicyClient { }; } function unreachable(): PolicyClient { - return { model: 'multi/cursor/auto', request: async () => undefined }; + return { + model: 'multi/cursor/auto', + get: async () => undefined, + post: async () => undefined, + }; } test('concurrent harness helpers share admission and later helpers reuse it', async () => { @@ -100,7 +107,15 @@ for (const model of ['claude-sonnet-5', 'multi/openai/gpt-6-astra', 'multi/zen/g const snapshot = { sessionId: 'session', cwd: '/workspace', permissionMode: 'plan' }; expect(await recordPrompt(client, snapshot, 7)).toBe(11); expect(calls.map((call) => call.url)).toEqual(['/multi/mod/session']); - expect(calls[0].body.policyGeneration).toBe(undefined); - expect(calls[0].body.permissionMode).toBe('plan'); + expect(calls[0]?.body.policyGeneration).toBe(undefined); + expect(calls[0]?.body.permissionMode).toBe('plan'); }); } + +test('policy readiness is one held request rather than a polling loop', async () => { + const calls: Call[] = []; + await preparePolicy(gateway(undefined, calls), 'session', '/workspace', undefined); + const waits = calls.filter((call) => call.body.wait === true); + expect(waits.length).toBe(1); + expect(waits[0]?.body.generation).toBe('policy-1'); +}); diff --git a/plugins/multi-core/hooks/tests/register.test.ts b/plugins/multi-core/hooks/tests/register.test.ts index 5ad575f..3695a51 100644 --- a/plugins/multi-core/hooks/tests/register.test.ts +++ b/plugins/multi-core/hooks/tests/register.test.ts @@ -1,6 +1,7 @@ import { expect, mock, test } from 'claude-code/testing'; test('posts a session snapshot and registers no model-callable tools', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'test-token', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', @@ -26,13 +27,14 @@ test('posts a session snapshot and registers no model-callable tools', async ($, value: { status: 200, ok: true, headers: {}, text: JSON.stringify({ accepted: true }) }, }; }); - await $.session.start({ cwd: '/tmp', model: 'multi/cursor/auto' }); + await $.session.start({ cwd: '/tmp', model: 'multi/cursor/auto' } as never); expect(requests).toContain('http://127.0.0.1:4000/multi/mod/session'); expect(commands).toEqual(['multi-usage']); expect(tools).toEqual([]); }); test('mod is dormant without launcher environment', async ($, on) => { + mock.clock(on); mock.env(on, {}); on('session.start', () => ({ cwd: '/tmp' })); on('command.register', (_$, event) => ({ value: { command: event.name } })); @@ -44,6 +46,68 @@ test('mod is dormant without launcher environment', async ($, on) => { fetches += 1; return { value: { status: 200, ok: true, headers: {}, text: '{}' } }; }); - await $.session.start({ cwd: '/tmp', model: 'claude-sonnet' }); + await $.session.start({ cwd: '/tmp', model: 'claude-sonnet' } as never); expect(fetches).toBe(0); }); + +test('an unchanged Claude prompt posts nothing, and a changed mode posts again', async ($, on) => { + mock.clock(on); + mock.env(on, { + MULTI_GATEWAY_TOKEN: 'test-token', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', + }); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + on('classic.UserPromptSubmit', () => ({})); + const posts: Array> = []; + on('http.fetch', (_$, event) => { + posts.push(event.init?.body ? JSON.parse(String(event.init.body)) : {}); + return { + value: { + ok: true, + status: 200, + headers: {}, + text: JSON.stringify({ accepted: true, generation: posts.length }), + }, + }; + }); + const prompt = (mode: string) => + $.classic.UserPromptSubmit({ + prompt: 'go', + permission_mode: mode, + session_id: 's', + cwd: '/workspace', + }); + await prompt('default'); + // The first prompt greets the gateway (`start`) and records its snapshot. + expect(posts.map((post) => post.event)).toEqual(['start', 'prompt']); + await prompt('default'); + await prompt('default'); + expect(posts.length).toBe(2); + await prompt('plan'); + expect(posts.map((post) => post.permissionMode)).toEqual([undefined, 'default', 'plan']); +}); + +test('a harness prompt always admits its policy, even when nothing changed', async ($, on) => { + mock.clock(on); + mock.env(on, { + MULTI_GATEWAY_TOKEN: 'test-token', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', + }); + on('session.model', () => ({ value: 'multi/cursor/auto' })); + on('classic.UserPromptSubmit', () => ({})); + const routes: string[] = []; + on('http.fetch', (_$, event) => { + routes.push(event.url.replace(/^.*\/multi/, '/multi')); + return { value: { ok: true, status: 200, headers: {}, text: '{"revision":0,"names":[]}' } }; + }); + const send = () => + $.classic.UserPromptSubmit({ + prompt: 'go', + permission_mode: 'default', + session_id: 's', + cwd: '/workspace', + }); + await send(); + await send(); + expect(routes.filter((route) => route === '/multi/mod/policy').length).toBeGreaterThanOrEqual(2); +}); diff --git a/plugins/multi-core/hooks/tests/rows.test.ts b/plugins/multi-core/hooks/tests/rows.test.ts index c850a89..3e11eb8 100644 --- a/plugins/multi-core/hooks/tests/rows.test.ts +++ b/plugins/multi-core/hooks/tests/rows.test.ts @@ -1,5 +1,8 @@ +import type { On as EngineOn } from 'claude-code'; +import type { Engine as TestEngine } from 'claude-code/testing'; import { expect, mock, test } from 'claude-code/testing'; -import { syncDisplayTools } from '../rows.ts'; +import type { MultiCoreDisplayTools } from '../../types/multi-core.d.ts'; +import { type RowsClient, syncDisplayTools } from '../rows.ts'; const gatewayEnv = { MULTI_GATEWAY_TOKEN: 'test-token', @@ -11,12 +14,18 @@ const tool = 'mcp__multi-core__run_command'; type Fetched = { url: string; body: Record | undefined }; /** A gateway that offers two native tools and answers only the token it issued. */ -function gateway(on: Parameters[1]>[1], rows: Record) { +function gateway( + on: EngineOn, + rows: Record, + model = 'multi/antigravity/gemini-3.8-flash', +) { const fetched: Fetched[] = []; mock.env(on, gatewayEnv); on('session.id', () => ({ value: 'rows-session' })); on('session.cwd', () => ({ value: '/workspace' })); - on('session.model', () => ({ value: 'multi/antigravity/gemini-3.8-flash' })); + on('session.model', () => ({ value: model })); + on('classic.UserPromptSubmit', () => ({})); + on('session.end', (_$, event) => ({ sessionId: event.sessionId })); on('command.register', (_$, event) => ({ value: { command: event.name } })); on('http.fetch', (_$, event) => { const sent = event.init?.body; @@ -39,15 +48,47 @@ function gateway(on: Parameters[1]>[1], rows: Record { - const fetched = gateway(on, {}); +function registrations(on: EngineOn) { const registered: string[] = []; on('tool.register', (_$, event) => { registered.push(event.name); return { value: { tool: `mcp__multi-core__${event.name}` } }; }); + return registered; +} + +test('a session registers no display tools until a harness could run one', async ($, on) => { + mock.clock(on); + gateway(on, {}); + const registered = registrations(on); on('session.start', () => ({ cwd: '/workspace' })); - await $.session.start({ cwd: '/workspace' }); + await $.session.start({ cwd: '/workspace' } as never); + expect(registered).toEqual([]); +}); + +test('a Claude prompt registers no display tools either', async ($, on) => { + mock.clock(on); + gateway(on, {}, 'claude-sonnet-5'); + const registered = registrations(on); + await $.classic.UserPromptSubmit({ + prompt: 'hello', + permission_mode: 'default', + session_id: 'rows-session', + cwd: '/workspace', + }); + expect(registered).toEqual([]); +}); + +test('a harness prompt registers the native tools the gateway offers and acknowledges them', async ($, on) => { + mock.clock(on); + const fetched = gateway(on, {}); + const registered = registrations(on); + await $.classic.UserPromptSubmit({ + prompt: 'hello', + permission_mode: 'default', + session_id: 'rows-session', + cwd: '/workspace', + }); expect(registered.sort()).toEqual(['run_command', 'view_file']); const acknowledgement = fetched.find( (item) => item.url.endsWith('/multi/mod/display-tools') && item.body, @@ -59,9 +100,40 @@ test('session start registers the native tools the gateway offers and acknowledg sessionId: 'rows-session', registered: ['run_command', 'view_file'], }); + // The unchanged catalog registers nothing again. + registered.length = 0; + await $.classic.UserPromptSubmit({ + prompt: 'again', + permission_mode: 'default', + session_id: 'rows-session', + cwd: '/workspace', + }); + expect(registered).toEqual([]); +}); + +test('a session end forgets what was registered, so the next session registers again', async ($, on) => { + mock.clock(on); + gateway(on, {}); + const registered = registrations(on); + const prompt = { + prompt: 'hello', + permission_mode: 'default', + session_id: 'rows-session', + cwd: '/workspace', + }; + await $.classic.UserPromptSubmit(prompt); + expect(registered.length).toBe(2); + await $.session.end({ + reason: 'clear', + sessionId: 'rows-session', + resume: { id: 'rows-session' }, + } as never); + await $.classic.UserPromptSubmit({ ...prompt, session_id: 'rows-session-2' }); + expect(registered.length).toBe(4); }); test('tool.describe keeps display rows behind ToolSearch and leaves other tools alone', async ($, on) => { + mock.clock(on); gateway(on, {}); on('tool.describe', (_$, event) => ({ description: event.description })); const provider = { plugin: 'multi-core', tier: 'user' as const }; @@ -76,6 +148,7 @@ test('tool.describe keeps display rows behind ToolSearch and leaves other tools }); test('a model-originated call to a display row is denied at check and at call', async ($, on) => { + mock.clock(on); gateway(on, { toolu_row: { output: 'hi', isError: false } }); on('tool.check', () => ({ decision: 'allow' as const })); on('tool.call', () => ({ result: 'core ran it' })); @@ -100,6 +173,7 @@ test('a model-originated call to a display row is denied at check and at call', }); test('a gateway-originated row is allowed and answered with the native output', async ($, on) => { + mock.clock(on); gateway(on, { toolu_row: { output: 'hi\n', isError: false } }); const check = await $.tool.check({ tool, @@ -110,6 +184,7 @@ test('a gateway-originated row is allowed and answered with the native output', }); test('a gateway-originated row answers its native output, and a failed action as an error', async ($, on) => { + mock.clock(on); gateway(on, { '*': { output: 'cat: missing: No such file', isError: true } }); const called = await $.tool.call({ tool, @@ -177,7 +252,7 @@ function relabelled(tree: unknown, native: string, builtin: string): unknown { return JSON.parse(JSON.stringify(tree).replaceAll(`"${native}"`, `"${builtin}"`)); } -type Mount = Parameters[1]>[0]; +type Mount = TestEngine; async function header( $: Mount, @@ -229,7 +304,7 @@ async function result( } /** The engine beneath the mod: what it was asked to draw, and a stand-in for its drawing. */ -function engine(on: Parameters[1]>[1]) { +function engine(on: EngineOn) { const drawn: Array> = []; on('ui.render', (_$, event) => { drawn.push(event.props as Record); @@ -239,6 +314,7 @@ function engine(on: Parameters[1]>[1]) { } test('an agy view_file row draws exactly as Claude draws Read, but for its name', async ($, on) => { + mock.clock(on); gateway(on, {}); const input = { kind: 'Read', @@ -259,6 +335,7 @@ test('an agy view_file row draws exactly as Claude draws Read, but for its name' }); test('a shell row draws as Bash and leaves its output to the engine, compact and under ctrl+o', async ($, on) => { + mock.clock(on); gateway(on, {}); const asked = engine(on); for (const surface of surfaces) { @@ -279,6 +356,7 @@ test('a shell row draws as Bash and leaves its output to the engine, compact and }); test('a search row draws as Grep: its pattern and path, then what it found', async ($, on) => { + mock.clock(on); gateway(on, {}); for (const surface of surfaces) { const drawn = await header($, surface, 'grep_search', { @@ -297,6 +375,7 @@ test('a search row draws as Grep: its pattern and path, then what it found', asy }); test('a failed action draws as a failed built-in: error dot, `Error:` in the error colour', async ($, on) => { + mock.clock(on); gateway(on, {}); const text = (value: string) => ({ type: 'Text', props: { color: 'error' }, children: [value] }); for (const surface of surfaces) { @@ -335,6 +414,7 @@ test('a failed action draws as a failed built-in: error dot, `Error:` in the err }); test('writes and edits draw as Write and Update do: counts, numbered preview, diff', async ($, on) => { + mock.clock(on); gateway(on, {}); for (const surface of surfaces) { await header($, surface, 'write_to_file', { @@ -395,6 +475,7 @@ test('writes and edits draw as Write and Update do: counts, numbered preview, di }); test('Cursor edits draw from their result: a created file as Write, a diff or counts as Update', async ($, on) => { + mock.clock(on); gateway(on, {}); // The output a Cursor edit row carries is its `EditSuccess.diffString`, as a real run reported it. const createdDiff = @@ -456,6 +537,7 @@ test('Cursor edits draw from their result: a created file as Write, a diff or co }); test('a native tool with no built-in keeps its own argument and output', async ($, on) => { + mock.clock(on); gateway(on, {}); const asked = engine(on); for (const surface of surfaces) { @@ -469,6 +551,7 @@ test('a native tool with no built-in keeps its own argument and output', async ( }); test('an action the run never confirmed draws neither green nor red, with an unconfirmed outcome', async ($, on) => { + mock.clock(on); gateway(on, {}); for (const surface of surfaces) { const drawn = await header($, surface, 'write_to_file', { @@ -505,19 +588,38 @@ test('an action the run never confirmed draws neither green nor red, with an unc test('a failed display tool acknowledgement is retried while the catalog is unchanged', async () => { const acknowledged: unknown[] = []; let failures = 1; - const client = { + let held: MultiCoreDisplayTools = { registered: [] }; + const reply = (status: number, value: unknown) => ({ + ok: status === 200, + status, + headers: {}, + text: JSON.stringify(value), + }); + const client: RowsClient = { + wire: { + url: async () => 'http://127.0.0.1:4000', + token: async () => 'token', + sleep: () => new Promise(() => undefined), + fetch: async (_url, init) => { + if (!init.body) { + return reply(200, { revision: 41, names: ['ack_probe'] }); + } + acknowledged.push(JSON.parse(init.body).registered); + if (failures > 0) { + failures--; + // The gateway client turns an HTTP failure into a refusal, not an acknowledgement. + return reply(503, {}); + } + return reply(200, { registered: 1 }); + }, + }, sessionId: async () => 'rows-session', - catalog: async () => ({ revision: 41, names: ['ack_probe'] }), - register: async () => ({ tool: 'mcp__multi-core__ack_probe' }), - // The gateway client turns a timeout or an HTTP failure into `undefined`. - acknowledge: async (payload: Record) => { - acknowledged.push(payload.registered); - if (failures > 0) { - failures--; - return undefined; - } - return { registered: 1 }; + held: async () => held, + save: async (change) => { + held = change(held); + return held; }, + register: async () => ({ tool: 'mcp__multi-core__ack_probe' }), }; await syncDisplayTools(client); await syncDisplayTools(client); diff --git a/plugins/multi-core/hooks/tests/usage.test.ts b/plugins/multi-core/hooks/tests/usage.test.ts index b058a71..70c0582 100644 --- a/plugins/multi-core/hooks/tests/usage.test.ts +++ b/plugins/multi-core/hooks/tests/usage.test.ts @@ -1,12 +1,13 @@ import { expect, mock, test } from 'claude-code/testing'; test('usage command reads only the current session without model dispatch', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', }); on('session.id', () => ({ value: 'session/one' })); - on('ui.open', () => ({ value: undefined })); + on('ui.open', () => ({ value: { isPlaced: true as const } })); on('ui.invalidate', () => ({ value: undefined })); on('http.fetch', (_$, event) => { expect(event.url).toBe( @@ -49,19 +50,20 @@ test('usage command reads only the current session without model dispatch', asyn }, }; }); - const result = await $.command.run({ command: 'multi-usage', args: '' }); + const result = await $.command.run({ command: 'multi-usage', args: '' } as never); expect(result.text).toBeUndefined(); const rendered = await $.ui.render({ surface: 'terminal', component: 'Pane', requestId: 'multi-usage', - props: { title: 'Multi usage', isFocused: true, bodyColumns: 80 }, + props: { title: 'Multi usage', isFocused: true, bodyColumns: 80 } as never, }); expect(JSON.stringify(rendered)).toContain('usage-view.ts'); expect(JSON.stringify(rendered)).toContain('OpenAI'); }); test('worker completion awaits accounting and preserves the engine answer', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', @@ -119,26 +121,32 @@ test('worker completion awaits accounting and preserves the engine answer', asyn }); test('usage without a gateway reports unavailable without opening a pane', async ($, on) => { + mock.clock(on); mock.env(on, {}); on('session.id', () => ({ value: 'session' })); - const result = await $.command.run({ command: 'multi-usage', args: '' }); + const result = await $.command.run({ command: 'multi-usage', args: '' } as never); expect(result.text).toContain('unavailable'); }); test('ordinary prompt submission no longer runs the quota advisory', async ($, on) => { + mock.clock(on); on('prompt.submit', (_$, event) => ({ text: event.text, context: event.context })); - const result = await $.prompt.submit({ text: 'Choose a worker', context: ['Existing guidance'] }); + const result = await $.prompt.submit({ + text: 'Choose a worker', + context: ['Existing guidance'], + } as never); expect(result.text).toBe('Choose a worker'); expect(result.context).toEqual(['Existing guidance']); }); test('receipts show a worker context beside what it consumed', async ($, on) => { + mock.clock(on); mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', }); on('session.id', () => ({ value: 'session' })); - on('ui.open', () => ({ value: undefined })); + on('ui.open', () => ({ value: { isPlaced: true as const } })); on('ui.invalidate', () => ({ value: undefined })); on('http.fetch', (_$, event) => { const body = event.url.includes('/multi/mod/receipts') @@ -168,13 +176,13 @@ test('receipts show a worker context beside what it consumed', async ($, on) => : { updatedAt: '2026-09-22T12:00:00Z', providers: [] }; return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify(body) } }; }); - await $.command.run({ command: 'multi-usage', args: '' }); + await $.command.run({ command: 'multi-usage', args: '' } as never); const pane = await $.ui.mount({ plugin: 'multi-core', surface: 'terminal', component: 'Pane', requestId: 'multi-usage', - props: { title: 'Multi usage', isFocused: true, bodyColumns: 120 }, + props: { title: 'Multi usage', isFocused: true, bodyColumns: 120 } as never, }); await pane.resize({ columns: 120, rows: 20, in: 'usage' }); await pane.press({ key: 'receipts' }); @@ -186,3 +194,143 @@ test('receipts show a worker context beside what it consumed', async ($, on) => ); await pane.unmount(); }); + +test('a pane that waits undrawn is announced and its numbers are shown as text', async ($, on) => { + mock.clock(on); + mock.env(on, { + MULTI_GATEWAY_TOKEN: 'secret', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000', + }); + on('session.id', () => ({ value: 'session' })); + on('ui.open', () => ({ value: { isPlaced: false as const, reason: 'no surface places panes' } })); + const toasts: string[] = []; + on('ui.toast', (_$, event) => { + toasts.push(event.text); + return { value: undefined }; + }); + on('http.fetch', () => ({ + value: { + ok: true, + status: 200, + headers: {}, + text: JSON.stringify({ + updatedAt: '2026-10-01T00:00:00Z', + providers: [ + { id: 'openai', name: 'OpenAI', status: 'ready', summary: '10 tokens', details: [] }, + ], + }), + }, + })); + const result = await $.command.run({ command: 'multi-usage', args: '' } as never); + expect(result.text).toBe('OpenAI: 10 tokens'); + expect(toasts).toEqual(['Multi usage: no surface places panes']); +}); + +const dashboard = { + updatedAt: 'today', + providers: [ + { + id: 'cursor', + name: 'Cursor', + status: 'ready', + summary: '$0.00 charged', + details: ['native spend'], + }, + ], +}; + +/** A gateway serving the dashboard, and receipts for one worker, recording every URL read. */ +function usageGateway(on: Parameters[0], reads: string[]) { + mock.clock(on); + mock.env(on, { MULTI_GATEWAY_TOKEN: 'secret', MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:4000' }); + on('session.id', () => ({ value: 'session/one' })); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + on('ui.open', () => ({ value: { isPlaced: true as const } })); + on('ui.render', () => ({ type: 'engine', ref: 0 }) as never); + on('http.fetch', (_$, event) => { + reads.push(event.url.replace(/^.*\/multi/, '/multi')); + const body = event.url.includes('/receipts') + ? { + receipts: [ + { + agentId: 'worker', + outcome: 'completed', + time: 'today', + requests: 1, + usage: { input_tokens: 2, output_tokens: 3 }, + }, + ], + } + : dashboard; + return { value: { ok: true, status: 200, headers: {}, text: JSON.stringify(body) } }; + }); +} + +test('refresh and receipts keep the providers, and another pane is not answered', async ($, on) => { + const reads: string[] = []; + usageGateway(on, reads); + await $.command.run({ command: 'multi-usage', args: '' } as never); + const pane = await $.ui.mount({ + plugin: 'multi-core', + surface: 'terminal', + component: 'Pane', + requestId: 'multi-usage', + props: { title: 'Multi usage', isFocused: true, bodyColumns: 100 } as never, + }); + await pane.resize({ columns: 100, rows: 20, in: 'usage' }); + await pane.press({ key: 'refresh' }); + expect(reads.at(-1)).toBe('/multi/mod/usage?sessionId=session%2Fone&view=providers&refresh=true'); + expect(await pane.find({ type: 'Text', text: /native spend|Cursor/, in: 'usage' })).toBeDefined(); + await pane.press({ key: 'receipts' }); + expect(reads.at(-1)).toBe('/multi/mod/receipts?sessionId=session%2Fone'); + expect((await pane.find({ type: 'Text', text: /^worker/, in: 'usage' }))?.text).toContain( + 'completed', + ); + // The providers survive both messages. + await pane.press({ key: 'cursor' }); + expect(await pane.find({ type: 'Text', text: /native spend/, in: 'usage' })).toBeDefined(); + await pane.unmount(); +}); + +test('quota advice is opt-in, session scoped and ends with the session', async ($, on) => { + const reads: string[] = []; + usageGateway(on, reads); + on('tool.call', () => ({ result: 'launched' })); + on('session.end', (_$, event) => ({ sessionId: event.sessionId })); + on('ui.status', () => ({ value: undefined })); + const agentCall = { + tool: 'Agent', + tool_use_id: 'call-1', + prompt: 'work', + subagent_type: 'cursor', + }; + const advice = async () => { + reads.length = 0; + const result = await $.tool.call(agentCall as never); + return { reads: reads.filter((url) => url.startsWith('/multi/mod/usage')), result }; + }; + // Off by default: delegating reads no quota. + expect((await advice()).reads).toEqual([]); + await $.command.run({ command: 'multi-usage', args: '' } as never); + const pane = await $.ui.mount({ + plugin: 'multi-core', + surface: 'terminal', + component: 'Pane', + requestId: 'multi-usage', + props: { title: 'Multi usage', isFocused: true, bodyColumns: 100 } as never, + }); + await pane.press({ key: 'quota-advice' }); + expect((await advice()).reads.length).toBe(1); + // Only an Agent call is advised. + reads.length = 0; + await $.tool.call({ tool: 'Read', tool_use_id: 'call-2', file_path: '/a' } as never); + expect(reads.filter((url) => url.startsWith('/multi/mod/usage'))).toEqual([]); + await pane.press({ key: 'quota-advice' }); + expect((await advice()).reads).toEqual([]); + await pane.press({ key: 'quota-advice' }); + expect((await advice()).reads.length).toBe(1); + // The session's end forgets the opt-in. + await $.session.end({ reason: 'clear', sessionId: 'session/one', resume: { id: 'x' } } as never); + expect((await advice()).reads).toEqual([]); + await pane.unmount(); +}); diff --git a/plugins/multi-core/hooks/tests/worker-rows.test.ts b/plugins/multi-core/hooks/tests/worker-rows.test.ts index 5e58c1a..9383f46 100644 --- a/plugins/multi-core/hooks/tests/worker-rows.test.ts +++ b/plugins/multi-core/hooks/tests/worker-rows.test.ts @@ -1,7 +1,9 @@ -import { expect, test } from 'claude-code/testing'; +import type { On as EngineOn } from 'claude-code'; +import type { Engine as TestEngine } from 'claude-code/testing'; +import { expect, mock, test } from 'claude-code/testing'; -type On = Parameters[1]>[1]; -type Engine = Parameters[1]>[0]; +type On = EngineOn; +type Engine = TestEngine; const surfaces = ['terminal', 'desktop'] as const; @@ -92,6 +94,7 @@ const input = { }; test('a running provider worker row shows its provider and resolved model', async ($, on) => { + mock.clock(on); gateway(on); const drawn = engine(on); await $.agent.spawn({ @@ -113,6 +116,7 @@ test('a running provider worker row shows its provider and resolved model', asyn }); test('a completed worker row reads the model from its result after a reload', async ($, on) => { + mock.clock(on); gateway(on); const drawn = engine(on); for (const surface of surfaces) { @@ -127,6 +131,7 @@ test('a completed worker row reads the model from its result after a reload', as }); test('Claude agents and other tools keep their rows unlabelled', async ($, on) => { + mock.clock(on); gateway(on); const drawn = engine(on); await agentRow($, 'terminal', { @@ -146,6 +151,7 @@ test('Claude agents and other tools keep their rows unlabelled', async ($, on) = }); test('a worker notification shows its provider and model, and ctrl+o keeps the full row', async ($, on) => { + mock.clock(on); gateway(on); const drawn = engine(on); await $.agent.spawn({ @@ -174,118 +180,20 @@ test('a worker notification shows its provider and model, and ctrl+o keeps the f } }); -test('turn.step rewrites complete Agent tool inputs with provider and model while preserving other chunks', async ($, on) => { +test("turn.step leaves an Agent call's streamed input exactly as the model wrote it", async ($, on) => { + mock.clock(on); gateway(on); - on('turn.step', async function* (_$, event) { - yield { kind: 'text', index: 0, text: 'launching workers' }; - yield { kind: 'tool', index: 1, id: 'toolu_cursor', name: 'Agent' }; - yield { - kind: 'input', - index: 1, - json: JSON.stringify({ - description: 'Fix tests', - prompt: 'task 1', - subagent_type: 'multi-cursor', - model: 'composer-2.5', - }), - }; - yield { kind: 'tool', index: 2, id: 'toolu_agy', name: 'Agent' }; - yield { - kind: 'input', - index: 2, - json: JSON.stringify({ - description: 'Review code', - prompt: 'task 2', - subagent_type: 'multi-antigravity', - model: 'gemini-3.8-flash[1m]', - }), - }; - yield { kind: 'tool', index: 3, id: 'toolu_builtin', name: 'Agent' }; - yield { - kind: 'input', - index: 3, - json: JSON.stringify({ - description: 'Search repo', - prompt: 'search', - subagent_type: 'Explore', - }), - }; - yield { kind: 'tool', index: 4, id: 'toolu_read', name: 'Read' }; - yield { - kind: 'input', - index: 4, - json: JSON.stringify({ - file_path: '/workspace/src/index.ts', - }), - }; - yield { kind: 'input', index: 1, json: '{"partial' }; - return { - turnId: event.turnId, - index: event.index, - answer: 'launching workers', - toolUses: [], - stopReason: 'end_turn', - usage: null, - }; + const written = JSON.stringify({ + description: 'Fix tests', + prompt: 'task 1', + subagent_type: 'multi-cursor', + model: 'composer-2.5', }); - - const chunks: Array> = []; - for await (const chunk of $.turn.step({ - turnId: 't1', - index: 0, - model: 'claude-sonnet-5', - messageCount: 1, - })) { - chunks.push(chunk as Record); - } - - expect(chunks[0]).toEqual({ kind: 'text', index: 0, text: 'launching workers' }); - expect(chunks[1]).toEqual({ kind: 'tool', index: 1, id: 'toolu_cursor', name: 'Agent' }); - - const cursorInput = JSON.parse(String(chunks[2]?.json)) as Record; - expect(cursorInput.description).toBe('Fix tests · Cursor · composer-2.5'); - expect(cursorInput.prompt).toBe('task 1'); - expect(cursorInput.subagent_type).toBe('multi-cursor'); - expect(cursorInput.model).toBe('composer-2.5'); - - expect(chunks[3]).toEqual({ kind: 'tool', index: 2, id: 'toolu_agy', name: 'Agent' }); - - const agyInput = JSON.parse(String(chunks[4]?.json)) as Record; - expect(agyInput.description).toBe('Review code · Antigravity · gemini-3.8-flash'); - expect(agyInput.prompt).toBe('task 2'); - expect(agyInput.subagent_type).toBe('multi-antigravity'); - - expect(chunks[5]).toEqual({ kind: 'tool', index: 3, id: 'toolu_builtin', name: 'Agent' }); - const exploreInput = JSON.parse(String(chunks[6]?.json)) as Record; - expect(exploreInput.description).toBe('Search repo'); - expect(exploreInput.prompt).toBe('search'); - - expect(chunks[7]).toEqual({ kind: 'tool', index: 4, id: 'toolu_read', name: 'Read' }); - expect(JSON.parse(String(chunks[8]?.json))).toEqual({ file_path: '/workspace/src/index.ts' }); - - expect(chunks[9]?.json).toBe('{"partial'); -}); - -test('turn.step uses provider name only when the worker model is unresolvable', async ($, on) => { - 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', () => ({ - value: { ok: false, status: 503, headers: {}, text: 'Gateway unavailable' }, - })); on('turn.step', async function* (_$, event) { - yield { kind: 'tool', index: 0, id: 'toolu_grok', name: 'Agent' }; - yield { - kind: 'input', - index: 0, - json: JSON.stringify({ - description: 'Audit codebase', - prompt: 'audit', - subagent_type: 'multi-grok', - }), - }; + yield { kind: 'tool', index: 1, id: 'toolu_cursor', name: 'Agent' }; + // Partial JSON, as a stream delivers it: nothing parses or rewrites it. + yield { kind: 'input', index: 1, json: written.slice(0, 20) }; + yield { kind: 'input', index: 1, json: written.slice(20) }; return { turnId: event.turnId, index: event.index, @@ -295,18 +203,16 @@ test('turn.step uses provider name only when the worker model is unresolvable', usage: null, }; }); - - const chunks: Array> = []; + const json: string[] = []; for await (const chunk of $.turn.step({ - turnId: 't2', + turnId: 't1', index: 0, model: 'claude-sonnet-5', messageCount: 1, })) { - chunks.push(chunk as Record); + if (chunk.kind === 'input') { + json.push(chunk.json); + } } - - const grokInput = JSON.parse(String(chunks[1]?.json)) as Record; - expect(grokInput.description).toBe('Audit codebase · Grok'); - expect(grokInput.prompt).toBe('audit'); + expect(json.join('')).toBe(written); }); diff --git a/plugins/multi-core/hooks/tests/workers.test.ts b/plugins/multi-core/hooks/tests/workers.test.ts index d62dff5..25f136d 100644 --- a/plugins/multi-core/hooks/tests/workers.test.ts +++ b/plugins/multi-core/hooks/tests/workers.test.ts @@ -1,4 +1,6 @@ -import { expect, test } from 'claude-code/testing'; +import type { On as EngineOn } from 'claude-code'; +import type { Engine as TestEngine } from 'claude-code/testing'; +import { expect, mock, test } from 'claude-code/testing'; import { register } from '../workers.ts'; test('registers the worker admission hook', () => { @@ -6,6 +8,7 @@ test('registers the worker admission hook', () => { }); test('agent.offer hides an unsupported worker before dispatch', async ($, on) => { + mock.clock(on); on('env.get', (_$, event) => ({ value: event.name === 'MULTI_GATEWAY_TOKEN' ? 'token' : 'http://127.0.0.1:4000', })); @@ -31,6 +34,7 @@ test('agent.offer hides an unsupported worker before dispatch', async ($, on) => }); test('agent.offer preserves a known catalog worker', async ($, on) => { + mock.clock(on); on('env.get', (_$, event) => ({ value: event.name === 'MULTI_GATEWAY_TOKEN' ? 'token' : 'http://127.0.0.1:4000', })); @@ -51,6 +55,7 @@ test('agent.offer preserves a known catalog worker', async ($, on) => { }); test('worker spawn is denied when gateway admission is unavailable', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -64,12 +69,13 @@ test('worker spawn is denied when gateway admission is unavailable', async ($, o prompt: 'task', subagentType: 'cursor', model: 'multi/cursor/auto', - }); + } as never); expect(typeof result.deny).toBe('string'); expect(started).toBe(false); }); test('harness spawn remains dormant when gateway is not configured', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: undefined })); let started = false; on('agent.spawn', () => { @@ -80,35 +86,38 @@ test('harness spawn remains dormant when gateway is not configured', async ($, o prompt: 'task', subagentType: 'cursor-auto', model: 'multi/cursor/auto', - }); + } as never); expect(result.agentId).toBe('worker'); expect(started).toBe(true); }); test('Claude-loop spawn proceeds when gateway is not configured', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: undefined })); on('agent.spawn', () => ({ model: 'multi/openai/gpt-6-luna', agentId: 'worker' })); const result = await $.agent.spawn({ prompt: 'task', subagentType: 'openai-luna', model: 'multi/openai/gpt-6-luna', - }); + } as never); expect(result.agentId).toBe('worker'); }); for (const model of ['multi/openai/gpt-6-luna', 'multi/zen/gpt-6-luna']) { test(`${model} spawn survives an active gateway outage`, async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); on('http.fetch', () => ({ value: { ok: false, status: 503, headers: {}, text: '' } })); on('agent.spawn', () => ({ model, agentId: 'worker' })); - const result = await $.agent.spawn({ prompt: 'task', subagentType: 'direct', model }); + const result = await $.agent.spawn({ prompt: 'task', subagentType: 'direct', model } as never); expect(result.agentId).toBe('worker'); }); } test('known catalog harness with omitted event model is admitted through worker-model', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -129,11 +138,12 @@ test('known catalog harness with omitted event model is admitted through worker- return { value: { ok: true, status: 200, headers: {}, text: '{"accepted":true}' } }; }); on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'worker' })); - const result = await $.agent.spawn({ prompt: 'task', subagentType: 'cursor-auto' }); + const result = await $.agent.spawn({ prompt: 'task', subagentType: 'cursor-auto' } as never); expect(result.agentId).toBe('worker'); }); test('a refused spawn shows the gateway reason instead of the generic denial', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -156,12 +166,13 @@ test('a refused spawn shows the gateway reason instead of the generic denial', a prompt: 'task', subagentType: 'cursor', model: 'multi/cursor/auto', - }); + } as never); expect(result.deny).toContain('Claude permission mode is unavailable; submit a new prompt'); expect(started).toBe(false); }); test('a non-JSON gateway refusal still names the status in the denial', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -173,11 +184,12 @@ test('a non-JSON gateway refusal still names the status in the denial', async ($ prompt: 'task', subagentType: 'cursor', model: 'multi/cursor/auto', - }); + } as never); expect(result.deny).toContain('gateway 502: upstream failure'); }); test('a refused reply cannot acknowledge a spawn through its body', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -201,13 +213,14 @@ test('a refused reply cannot acknowledge a spawn through its body', async ($, on prompt: 'task', subagentType: 'cursor', model: 'multi/cursor/auto', - }); + } as never); expect(result.deny).toContain('policy refused'); expect(result.deny).toContain('issues/new?template=bug_report.yml'); expect(started).toBe(false); }); test('an unclassified refused offer remains available for the engine to decide', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -225,7 +238,7 @@ test('an unclassified refused offer remains available for the engine to decide', expect(result.isOffered).toBe(true); }); -type On = Parameters[1]>[1]; +type On = EngineOn; /** A gateway whose catalog runs Cursor's default and Composer 2.5, and refuses the rest. */ function catalogGateway(on: On) { @@ -263,6 +276,7 @@ function catalogGateway(on: On) { } test('an Agent call names the model: tool.call takes it out before the schema check', async ($, on) => { + mock.clock(on); catalogGateway(on); const reached: Array> = []; on('tool.call', (_$, event) => { @@ -293,6 +307,7 @@ test('an Agent call names the model: tool.call takes it out before the schema ch }); test('agent.spawn resolves a provider worker model and runs the rewrite', async ($, on) => { + mock.clock(on); const sent = catalogGateway(on); const spawned: Array = []; const descriptions: string[] = []; @@ -306,7 +321,7 @@ test('agent.spawn resolves a provider worker model and runs the rewrite', async description: 'Fix tests', subagentType: 'multi-cursor', model: 'composer-2.5', - }); + } as never); expect(named.agentId).toBe('agent-1'); expect(spawned[0]).toBe('multi/cursor/composer-2.5'); // The running-agents list and the notification show the task's description. @@ -317,11 +332,12 @@ test('agent.spawn resolves a provider worker model and runs the rewrite', async const admission = sent.filter((item) => item.url.endsWith('/multi/mod/worker')).at(-1); expect(admission?.body?.model).toBe('multi/cursor/composer-2.5'); // No model: the provider default. - await $.agent.spawn({ prompt: 'task', subagentType: 'multi-cursor' }); + await $.agent.spawn({ prompt: 'task', subagentType: 'multi-cursor' } as never); expect(spawned[1]).toBe('multi/cursor/default'); }); test('agent.spawn refuses an unknown provider model with the provider models named', async ($, on) => { + mock.clock(on); catalogGateway(on); let started = false; on('agent.spawn', () => { @@ -332,7 +348,7 @@ test('agent.spawn refuses an unknown provider model with the provider models nam prompt: 'task', subagentType: 'multi-cursor', model: 'kimi-k3', - }); + } as never); expect(result.deny).toBe( 'multi-cursor has no model "kimi-k3". Cursor models: default, composer-2.5. Omit model for the default, default.', ); @@ -340,6 +356,7 @@ test('agent.spawn refuses an unknown provider model with the provider models nam }); test('a provider worker is refused when the gateway cannot resolve its model', async ($, on) => { + mock.clock(on); on('env.get', () => ({ value: 'configured' })); on('session.id', () => ({ value: 's' })); on('session.cwd', () => ({ value: '/workspace' })); @@ -355,29 +372,42 @@ test('a provider worker is refused when the gateway cannot resolve its model', a prompt: 'task', subagentType: 'multi-zen', model: 'kimi-k3', - }); + } as never); expect(result.deny).toContain('The Multi gateway did not resolve the multi-zen model.'); expect(started).toBe(false); }); -test('the Agent tool description tells every model how to pick a provider model', async ($, on) => { +const provider = { plugin: 'engine', tier: 'core' } as const; + +test('the Agent tool description names provider models only while a provider worker is offered', async ($, on) => { + mock.clock(on); catalogGateway(on); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + on('ui.invalidate', () => ({ value: undefined })); on('tool.describe', (_$, event) => ({ description: event.description })); - const described = await $.tool.describe({ - tool: 'Agent', - description: 'Launch a new agent.', - provider: { plugin: 'engine', tier: 'core' }, - }); - expect(described.description).toContain('For a multi-* agent type'); - expect(described.description.startsWith('Launch a new agent.')).toBe(true); + let offered = true; + on('agent.offer', () => ({ isOffered: offered })); + const describe = async () => + (await $.tool.describe({ tool: 'Agent', description: 'Launch a new agent.', provider })) + .description; + const offer = (agent: string) => + $.agent.offer({ agent, description: agent, source: 'plugin', provider }); + // No provider type offered: nothing to explain, so the description is the engine's own. + expect(await describe()).toBe('Launch a new agent.'); + await offer('Explore'); + expect(await describe()).toBe('Launch a new agent.'); + await offer('multi-cursor'); + const described = await describe(); + expect(described).toContain('For a multi-* agent type'); + expect(described.startsWith('Launch a new agent.')).toBe(true); + // The offer is withdrawn (settings the harness cannot honour): the paragraph goes too. + offered = false; + await offer('multi-cursor'); + expect(await describe()).toBe('Launch a new agent.'); }); /** The gateway classifies the type at offer, as it does before the model can name it. */ -async function offerClaudeType( - $: Parameters[1]>[0], - on: On, - agent: string, -) { +async function offerClaudeType($: TestEngine, on: On, agent: string) { on('agent.offer', () => ({ isOffered: true })); on('session.model', () => ({ value: 'claude-sonnet-5' })); await $.agent.offer({ @@ -411,6 +441,7 @@ function claudeGateway(on: On) { } test('a native Claude subagent spawns without any gateway call', async ($, on) => { + mock.clock(on); const sent = claudeGateway(on); on('agent.spawn', () => ({ model: 'haiku', agentId: 'native' })); await offerClaudeType($, on, 'Explore'); @@ -420,12 +451,13 @@ test('a native Claude subagent spawns without any gateway call', async ($, on) = subagentType: 'Explore', model: 'haiku', parentModel: 'claude-sonnet-5', - }); + } as never); expect(result.agentId).toBe('native'); expect(sent.length).toBe(before); }); test('a subagent of a Multi model session still reaches the gateway', async ($, on) => { + mock.clock(on); const sent = claudeGateway(on); on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'inherited' })); await offerClaudeType($, on, 'Explore'); @@ -434,18 +466,24 @@ test('a subagent of a Multi model session still reaches the gateway', async ($, prompt: 'task', subagentType: 'Explore', parentModel: 'multi/cursor/auto', - }); + } as never); expect(sent.length).toBeGreaterThan(before); }); test('a type not offered as Claude-loop keeps gateway admission', async ($, on) => { + mock.clock(on); const sent = catalogGateway(on); on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'worker' })); - await $.agent.spawn({ prompt: 'task', subagentType: 'cursor-auto', parentModel: 'claude' }); + await $.agent.spawn({ + prompt: 'task', + subagentType: 'cursor-auto', + parentModel: 'claude', + } as never); 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) => { + mock.clock(on); const sent = catalogGateway(on); on('session.model', () => ({ value: 'claude-sonnet-5' })); on('tool.call', () => ({ result: 'ran' })); @@ -454,6 +492,7 @@ test('a Claude tool call posts nothing to the gateway', async ($, on) => { }); test('a provider model tool call is attributed to the gateway reviewer', async ($, on) => { + mock.clock(on); const sent = catalogGateway(on); on('session.model', () => ({ value: 'multi/openai/gpt-6-astra' })); on('tool.call', () => ({ result: 'ran' })); @@ -466,3 +505,184 @@ test('a provider model tool call is attributed to the gateway reviewer', async ( cwd: '/workspace', }); }); + +/** A gateway whose offer answers name the model each agent type runs on. */ +function pinnedGateway( + on: On, + models: Record, + bodies: Array> = [], +) { + 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('session.model', () => ({ value: 'claude-sonnet-5' })); + on('ui.invalidate', () => ({ value: undefined })); + on('http.fetch', (_$, event) => { + sent.push(event.url); + const body = event.init?.body ? JSON.parse(String(event.init.body)) : {}; + bodies.push(body); + const model = models[String(body.agent ?? body.subagentType)] ?? 'claude-sonnet-5'; + return { + value: { + ok: true, + status: 200, + headers: {}, + text: JSON.stringify({ + execution: 'claude', + known: true, + isOffered: true, + accepted: true, + model, + }), + }, + }; + }); + return sent; +} + +test('a custom agent pinned to a provider model keeps gateway registration', async ($, on) => { + mock.clock(on); + const sent = pinnedGateway(on, { reviewer: 'multi/openai/gpt-6-astra' }); + on('agent.offer', () => ({ isOffered: true })); + on('agent.spawn', () => ({ model: 'multi/openai/gpt-6-astra', agentId: 'pinned' })); + on('classic.SubagentStart', () => ({})); + await $.agent.offer({ agent: 'reviewer', description: 'r', source: 'projectSettings', provider }); + await $.agent.offer({ agent: 'scout', description: 's', source: 'projectSettings', provider }); + // A definition on a Claude model stays native. + const native = sent.length; + await $.classic.SubagentStart({ + agent_id: 'plain', + agent_type: 'scout', + session_id: 's', + cwd: '/workspace', + }); + expect(sent.length).toBe(native); + const before = sent.length; + // The spawn event carries no model for the definition's pin: the offer classified it. + await $.agent.spawn({ + prompt: 'task', + subagentType: 'reviewer', + parentModel: 'claude-sonnet-5', + } as never); + expect(sent.slice(before).some((url) => url.endsWith('/multi/mod/worker'))).toBe(true); + const registered = sent.length; + await $.classic.SubagentStart({ + agent_id: 'pinned', + agent_type: 'reviewer', + session_id: 's', + cwd: '/workspace', + }); + expect(sent.length).toBeGreaterThan(registered); +}); + +test('a built-in agent on a Claude session is classified without a gateway call', async ($, on) => { + mock.clock(on); + const sent = pinnedGateway(on, {}); + on('agent.offer', () => ({ isOffered: true })); + on('agent.spawn', () => ({ model: 'haiku', agentId: 'native' })); + await $.agent.offer({ agent: 'Explore', description: 'e', source: 'built-in', provider }); + expect(sent).toEqual([]); + await $.agent.spawn({ + prompt: 'task', + subagentType: 'Explore', + parentModel: 'claude-sonnet-5', + } as never); + expect(sent).toEqual([]); +}); + +test('a subagent registers with the gateway while any loop of the session runs a Multi model', async ($, on) => { + mock.clock(on); + const sent = pinnedGateway(on, {}); + on('agent.offer', () => ({ isOffered: true })); + on('classic.SubagentStart', () => ({})); + on('ui.status', () => ({ value: undefined })); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'ok' }; + return { + turnId: event.turnId, + index: 0, + answer: 'ok', + toolUses: [], + stopReason: 'end_turn', + usage: null, + }; + }); + await $.agent.offer({ agent: 'Explore', description: 'e', source: 'built-in', provider }); + const start = { + agent_id: 'child', + agent_type: 'Explore', + session_id: 's', + cwd: '/workspace', + }; + const before = sent.length; + await $.classic.SubagentStart(start); + expect(sent.length).toBe(before); + // A provider worker is stepping: a nested subagent may inherit its model. + for await (const _chunk of $.turn.step({ + turnId: 't', + agentId: 'provider-worker', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // Observe the step. + } + const afterStep = sent.length; + await $.classic.SubagentStart(start); + expect(sent.slice(afterStep).some((url) => url.endsWith('/multi/mod/worker'))).toBe(true); +}); + +test('a provider worker tool call is attributed to the reviewer with its agent id', async ($, on) => { + mock.clock(on); + const bodies: Array> = []; + const sent = pinnedGateway(on, {}, bodies); + on('tool.call', () => ({ result: 'ran' })); + on('ui.status', () => ({ value: undefined })); + on('classic.UserPromptSubmit', () => ({})); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'ok' }; + return { + turnId: event.turnId, + index: 0, + answer: 'ok', + toolUses: [], + stopReason: 'end_turn', + usage: null, + }; + }); + await $.classic.UserPromptSubmit({ + prompt: 'go', + permission_mode: 'auto', + session_id: 's', + cwd: '/workspace', + }); + for await (const _chunk of $.turn.step({ + turnId: 't', + agentId: 'provider-worker', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // The hooks learn the worker's model from its step. + } + sent.length = 0; + bodies.length = 0; + await $.tool.call({ tool: 'Bash', tool_use_id: 'toolu_w', agentId: 'provider-worker' } as never); + const index = sent.findIndex((url) => url.endsWith('/multi/permission')); + expect(index).toBeGreaterThanOrEqual(0); + // The Claude session's own mode (the engine hands a tool call none) comes from its snapshot. + expect(bodies[index]).toEqual({ + session_id: 's', + tool_use_id: 'toolu_w', + tool_name: 'Bash', + cwd: '/workspace', + permission_mode: 'auto', + }); + // The main loop is on Claude: its own call costs the gateway nothing. + sent.length = 0; + await $.tool.call({ tool: 'Bash', tool_use_id: 'toolu_main' } as never); + expect(sent).toEqual([]); +}); diff --git a/plugins/multi-core/hooks/usage.ts b/plugins/multi-core/hooks/usage.ts index 3d50248..b6377f1 100644 --- a/plugins/multi-core/hooks/usage.ts +++ b/plugins/multi-core/hooks/usage.ts @@ -1,40 +1,48 @@ import type { EngineInterface, Register } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { MultiCoreUsagePane } from '../types/multi-core.d.ts'; +import { accepted, getJson, type Wire } from './gateway.ts'; import { quotaAdvice } from './quota-advice.ts'; +import { withBounded } from './state.ts'; import type { UsagePaneProps } from './usage-view.ts'; -const panes = new Map(); -const advisorySessions = new Set(); +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const advisorySessions = atom( + { plugin: 'multi-core', key: 'advisorySessions' } as const, + [] as string[], +); +const usagePanes = atom( + { plugin: 'multi-core', key: 'usagePanes' } as const, + {} as Record, +); -export function forgetUsageSession(session: string) { - advisorySessions.delete(session); - panes.delete(session); -} +/** A provider dashboard can take a few seconds; the command's own budget is 10 s. */ +const usageTimeoutMs = 8500; +const maximumPanes = 16; + +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), +}); -export const register = ( - on: Parameters[0], - _options?: Parameters[1], - agentModels: ReadonlyMap = new Map(), -) => { +export const register = (on: Parameters[0], _options?: Parameters[1]) => { 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. + // `Task` is the Agent tool's legacy name. const tool: string = event.tool; - if ( - (tool !== 'Agent' && tool !== 'Task') || - (event as { agentId?: string }).agentId !== undefined - ) { + if (tool !== 'Agent' && tool !== 'Task') { return next(event); } const session = await $.session.id(); - if (!advisorySessions.has(session)) { + if (!(await read($, advisorySessions)).includes(session)) { return next(event); } - const snapshot = dashboard( - await request($, `/multi/mod/usage?sessionId=${encodeURIComponent(session)}&view=providers`), - ); + const snapshot = dashboard(accepted(await fetchUsage($, session))); const result = await next(event); // Preserve all permission decisions and tool arguments, including on lookup failure. - if (!advisorySessions.has(session)) { + if (!(await read($, advisorySessions)).includes(session)) { return result; } return { @@ -47,39 +55,46 @@ export const register = ( return { text: 'Use /multi-usage without arguments.' }; } const session = await $.session.id(); - const response = dashboard( - await request($, `/multi/mod/usage?sessionId=${encodeURIComponent(session)}&view=providers`), - ); + const response = dashboard(accepted(await fetchUsage($, session))); if (!response) { return { text: 'Multi usage is unavailable. Launch this session with claude-multi.' }; } - if (panes.size >= 16) { - panes.clear(); - } - panes.set(session, { ...response, quotaAdviceEnabled: advisorySessions.has(session) }); + const quotaAdviceEnabled = (await read($, advisorySessions)).includes(session); + // The pane draws from this state: writing it redraws an open pane without an invalidate. + await update($, usagePanes, (held) => + withBounded( + held, + session, + { ...response, quotaAdviceEnabled }, + maximumPanes, + ), + ); + const summary = response.providers + .map((provider) => `${provider.name}: ${provider.summary}`) + .join('\n'); try { - await $.ui.open({ + const opened = await $.ui.open({ id: 'multi-usage', title: 'Multi usage', focus: true, closeOnEscape: true, rows: 20, }); - $.ui.invalidate('ui.render'); - return {}; + if (opened.isPlaced) { + return {}; + } + // The pane waits undrawn (a surface that places no panes): say so, and show the numbers. + $.ui.toast(`Multi usage: ${opened.reason}`); + return { text: summary }; } catch { - return { - text: response.providers - .map((provider) => `${provider.name}: ${provider.summary}`) - .join('\n'), - }; + return { text: summary }; } }); on('ui.render', { component: 'Pane' }, async ($, event, next) => { if (event.requestId !== 'multi-usage' || event.surface !== 'terminal') { return next(event); } - const props = panes.get(await $.session.id()); + const props = (await read($, usagePanes))[await $.session.id()]; if (!props) { return next(event); } @@ -95,32 +110,46 @@ export const register = ( return next(event); } const session = await $.session.id(); - const previous = panes.get(session); + const previous = (await read($, usagePanes))[session]; if (!previous) { return next(event); } if (action === 'toggle-quota-advice') { - const enabled = !advisorySessions.has(session); - if (enabled) { - advisorySessions.add(session); - } else { - advisorySessions.delete(session); - } + const enabled = !(await read($, advisorySessions)).includes(session); + await update($, advisorySessions, (held) => + enabled ? [...held, session] : held.filter((id) => id !== session), + ); const props = { ...previous, quotaAdviceEnabled: enabled }; - panes.set(session, props); + await update($, usagePanes, (held) => + withBounded(held, session, props, maximumPanes), + ); return { props }; } - const route = + const response = action === 'refresh' - ? `/multi/mod/usage?view=providers&refresh=true&sessionId=${encodeURIComponent(session)}` - : `/multi/mod/receipts?sessionId=${encodeURIComponent(session)}`; - const response = await request($, route); - const props = updatePane(previous, action, response); - panes.set(session, props); + ? await fetchUsage($, session, true) + : await getJson( + wire($), + '/multi/mod/receipts', + { sessionId: session }, + { timeoutMs: usageTimeoutMs }, + ); + const props = updatePane(previous, action, accepted(response)); + await update($, usagePanes, (held) => + withBounded(held, session, props, maximumPanes), + ); return { props }; }); }; +function fetchUsage($: EngineInterface, session: string, refresh = false) { + const query: Record = { sessionId: session, view: 'providers' }; + if (refresh) { + query.refresh = 'true'; + } + return getJson(wire($), '/multi/mod/usage', query, { timeoutMs: usageTimeoutMs }); +} + function record(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -204,71 +233,3 @@ function receiptEntry(value: unknown): string[] { ` ${String(value.provider)} · ${String(value.model ?? 'model unreported')} · effort ${String(value.effort ?? 'unreported')} · ${String(value.endpoint ?? 'endpoint unreported')} · ${String(value.source)}`, ]; } - -async function request($: EngineInterface, route: string): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const result = await Promise.race([ - $.http.fetch(`${base}${route}`, { - method: 'GET', - headers: { 'x-multi-gateway-token': token }, - }), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Usage timeout')), 8500); - }), - ]); - return result.ok ? JSON.parse(result.text) : undefined; - } catch { - return undefined; - } finally { - 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, -) { - 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 | 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((_, reject) => { - timer = setTimeout(() => reject(new Error('Attribution timeout')), 1500); - }), - ]); - } catch { - // Attribution is best effort; Claude's own checks decide the call. - } finally { - clearTimeout(timer); - } -} diff --git a/plugins/multi-core/hooks/worker-rows.ts b/plugins/multi-core/hooks/worker-rows.ts index 3a966d9..78205b3 100644 --- a/plugins/multi-core/hooks/worker-rows.ts +++ b/plugins/multi-core/hooks/worker-rows.ts @@ -1,4 +1,16 @@ import type { Register } from 'claude-code'; +import { atom, read } from 'claude-code'; + +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); +const spawnModels = atom( + { plugin: 'multi-core', key: 'spawnModels' } as const, + {} as Record, +); /** * The provider worker types. Each runs one provider's models, and its Agent call's @@ -87,18 +99,15 @@ export function labelled(description: string, model: string): string { * completion) and a background agent's notification gain ` · ` * beside their description; the stored input and message stay as they were. */ -export const register = ( - on: Parameters[0], - agentModels: ReadonlyMap, - spawnModels: ReadonlyMap, -) => { - on('ui.render', { component: 'ToolUse' }, async (_$, event, next) => { +export const register = (on: Parameters[0]) => { + on('ui.render', { component: 'ToolUse' }, async ($, event, next) => { const input = record(event.props.input); if (event.props.tool !== 'Agent' || !input || !isProviderWorker(input.subagent_type)) { return next(event); } + // Read from state while drawing: a spawn that resolves its model redraws the row. const label = workerLabel( - spawnModels.get(event.props.tool_use_id) ?? resultModel(event.props.output), + (await read($, spawnModels))[event.props.tool_use_id] ?? resultModel(event.props.output), ); // The call's provider model is not one of the Agent schema's Claude aliases, and the // engine draws an input its schema rejects as a bare `Agent`: the label carries it. @@ -112,14 +121,16 @@ export const register = ( on( 'ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, - async (_$, event, next) => { + async ($, event, next) => { if (event.component !== 'UserMessage' || event.props.isExpanded) { return next(event); } const task = event.props.task; + const agents = await read($, agentModels); + const spawns = await read($, spawnModels); const label = workerLabel( - (task?.id ? agentModels.get(task.id) : undefined) ?? - (task?.toolUseId ? spawnModels.get(task.toolUseId) : undefined), + (task?.id ? agents[task.id] : undefined) ?? + (task?.toolUseId ? spawns[task.toolUseId] : undefined), ); // A spawn's labelled description already names it in the notification's summary. if (!label || event.props.text.includes(label)) { diff --git a/plugins/multi-core/hooks/workers.ts b/plugins/multi-core/hooks/workers.ts index 5e3e7ba..316bc58 100644 --- a/plugins/multi-core/hooks/workers.ts +++ b/plugins/multi-core/hooks/workers.ts @@ -1,14 +1,56 @@ import type { AgentSpawnInput, EngineInterface, Register } from 'claude-code'; +import { atom, read, update } from 'claude-code'; +import type { MultiCoreDisplayTools, MultiCorePolicy } from '../types/multi-core.d.ts'; import { - ensureHarnessPolicy, - type PolicyClient, - type PolicyResponse, - type PolicyState, -} from './policy.ts'; -import { isHarnessModel } from './provider.ts'; + accepted, + type GatewayResponse, + getJson, + isActive, + postJson, + type Wire, +} from './gateway.ts'; +import { ensureHarnessPolicy, policyClient } from './policy.ts'; +import { isHarnessModel, isMultiModel } from './provider.ts'; +import { type RowsClient, syncDisplayTools } from './rows.ts'; +import { defined, rememberBounded, withBounded, withItem } from './state.ts'; import { isProviderWorker, labelled, register as registerWorkerRows } from './worker-rows.ts'; -const maxBody = 32000; +// State values are named where they are read: the engine's scan reads an atom's plugin and key +// from this file's own source, not across an import. +const policy = atom({ plugin: 'multi-core', key: 'policy' } as const, {} as MultiCorePolicy); +const agentModels = atom( + { plugin: 'multi-core', key: 'agentModels' } as const, + {} as Record, +); +const spawnModels = atom( + { plugin: 'multi-core', key: 'spawnModels' } as const, + {} as Record, +); +const claudeTypes = atom({ plugin: 'multi-core', key: 'claudeTypes' } as const, [] as string[]); +const offeredProviders = atom( + { plugin: 'multi-core', key: 'offeredProviders' } as const, + [] as string[], +); +const displayTools = atom( + { plugin: 'multi-core', key: 'displayTools' } as const, + { registered: [] } as MultiCoreDisplayTools, +); + +const rowsClient = ($: EngineInterface): RowsClient => ({ + wire: wire($), + sessionId: () => $.session.id(), + held: () => read($, displayTools), + save: (change) => update($, displayTools, change), + register: (tool) => $.tool.register(tool), +}); + +const wire = ($: EngineInterface): Wire => ({ + url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), + token: () => $.env.get('MULTI_GATEWAY_TOKEN'), + fetch: (url, init) => $.http.fetch(url, init), + sleep: (ms, signal) => $.clock.sleep(ms, { signal }), +}); + const issues = 'https://github.com/greenpolo/cc-multi-cli-plugin/issues/new?template=bug_report.yml'; @@ -23,84 +65,34 @@ function reportable(reason: string) { 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.`; } -type GatewayResponse = PolicyResponse & { - accepted?: boolean; - error?: string; - isOffered?: boolean; - execution?: 'claude' | 'harness'; - known?: boolean; - model?: string; -}; - type SpawnEvent = AgentSpawnInput; /** * The model an Agent call named for a provider worker, by tool_use_id. The Agent tool's * schema only admits Claude aliases, so `tool.call` (which runs before the engine checks - * the input) takes the value out and `agent.spawn` resolves it against the catalog. + * the input) takes the value out and `agent.spawn` resolves it against the catalog. It + * lives from that `tool.call` to its `agent.spawn`, inside one call, so it is not state. */ const requestedModels = new Map(); -/** Bounded like the other per-call records: the oldest entry makes room. */ -export function rememberBounded(entries: Map, key: string, value: T, limit = 256) { - entries.delete(key); - const oldest = entries.keys().next(); - if (entries.size >= limit && !oldest.done) { - entries.delete(oldest.value); - } - entries.set(key, value); -} - -// A refused reply keeps only the reason: a non-2xx status never carries an -// acknowledgement, so every caller still fails closed on the HTTP status alone. -function refusal(text: string, status: number): GatewayResponse { - let reason: string | undefined; - try { - const parsed: unknown = JSON.parse(text); - if (parsed && typeof parsed === 'object') { - const value = (parsed as { error?: unknown }).error; - reason = typeof value === 'string' && value ? value : undefined; - } - } catch { - // A non-JSON body still names the status below. - } - const detail = text.trim().slice(0, 200); - return { - refused: true, - httpStatus: status, - error: reason ?? (detail ? `gateway ${status}: ${detail}` : `gateway ${status}`), - }; -} +const providerGuidance = + "\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."; -export const register = ( - on: Parameters[0], - _options: Parameters[1], - agentModels: Map = new Map(), - policyState: PolicyState = {}, - spawnModels: Map = new Map(), -) => { - /** 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(); +export const register = (on: Parameters[0], _options: Parameters[1]) => { + registerWorkerRows(on); on('tool.describe', { tool: 'Agent' }, async ($, event, next) => { const described = await next(event); - if (!(await active($))) { + // The paragraph is for the provider types the model is offered; none, no paragraph. + if (!(await isActive(wire($))) || !(await read($, offeredProviders)).length) { return described; } - return { - ...described, - description: `${described.description}\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.`, - }; + return { ...described, description: `${described.description}${providerGuidance}` }; }); on('tool.call', { tool: 'Agent' }, async ($, event, next) => { if (event.tool !== 'Agent' || event.model === undefined) { return next(event); } - if (!isProviderWorker(event.subagent_type) || !(await active($))) { + if (!isProviderWorker(event.subagent_type) || !(await isActive(wire($)))) { return next(event); } rememberBounded(requestedModels, event.tool_use_id, String(event.model)); @@ -112,61 +104,68 @@ export const register = ( } }); on('agent.offer', async ($, event, next) => { - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } - const response = await request( - $, - { - sessionId: await $.session.id(), - cwd: await $.session.cwd(), - agent: event.agent, - parentModel: await $.session.model(), - }, - '/multi/mod/offer', - ); - if (response?.execution === 'claude') { - claudeTypes.add(event.agent); - } else { - claudeTypes.delete(event.agent); + const parentModel = await $.session.model(); + // A built-in agent on a Claude session is Claude's own: no gateway call, ever. + if ( + event.source === 'built-in' && + !isMultiModel(parentModel) && + !isProviderWorker(event.agent) + ) { + await classify($, event.agent, true); + return next(event); } + const response = await postJson(wire($), '/multi/mod/offer', { + sessionId: await $.session.id(), + cwd: await $.session.cwd(), + agent: event.agent, + parentModel, + }); + await classify($, event.agent, nativeClaude(response)); // 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 - ? { isOffered: false } - : next(event); + const hidden = response?.execution === 'harness' && response.isOffered === false; + const result = hidden ? { isOffered: false } : await next(event); + await markOffered($, event.agent, result.isOffered); + return result; }); on('classic.SubagentStart', async ($, event, next) => { - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } - // A native Claude subagent in a Claude session needs no gateway record. + // A native Claude subagent in a Claude session needs no gateway record. The + // subagent's own model and its parent's are not on this event, so any loop known to + // run a Multi model (an inheriting child of a provider worker) keeps the record. if ( - claudeTypes.has(event.agent_type) && + (await read($, claudeTypes)).includes(event.agent_type) && !isProviderWorker(event.agent_type) && - !isMultiModel(await $.session.model()) + !isMultiModel(await $.session.model()) && + !Object.values(await read($, agentModels)).some(isMultiModel) ) { return next(event); } - const response = await request($, { + const response = await postJson(wire($), '/multi/mod/worker', { sessionId: event.session_id, agentId: event.agent_id, subagentType: event.agent_type, cwd: event.cwd, }); - if (response?.accepted && response.model) { - agentModels.set(event.agent_id, response.model); + const model = response?.accepted ? response.model : undefined; + if (model) { + await update($, agentModels, (held) => withBounded(held, event.agent_id, model)); } // Registration is observational for Claude-loop workers. An unregistered // harness worker still cannot dispatch: resolveHarness rejects its scope. return next(event); }); on('agent.spawn', async ($, event, next) => { - if (!(await active($))) { + if (!(await isActive(wire($)))) { return next(event); } // A native Claude subagent runs on Claude Code's own path: no gateway call. - if (nativeClaudeSpawn(event, claudeTypes)) { + if (nativeClaudeSpawn(event, await read($, claudeTypes))) { return next(event); } const resolved = await providerSpawn($, event); @@ -174,22 +173,50 @@ export const register = ( return { deny: resolved.deny }; } const spawn = resolved.event; - if (spawn.model && spawn !== event) { - rememberBounded(spawnModels, event.tool_use_id, spawn.model); - $.ui.invalidate('ui.render'); + const resolvedModel = spawn.model; + if (resolvedModel && spawn !== event) { + // Written to state, so the Agent row and the task notification draw the model. + await update($, spawnModels, (held) => withBounded(held, event.tool_use_id, resolvedModel)); } - const denial = await admit($, spawn, resolved.selection, policyState); + const denial = await admit($, spawn, resolved.selection); if (denial) { return { deny: denial }; } const result = await next(spawn); - if (result.agentId && result.model) { - agentModels.set(result.agentId, result.model); + const { agentId, model } = result; + if (agentId && model) { + await update($, agentModels, (held) => withBounded(held, agentId, model)); } return result; }); }; +/** Truly native: the gateway classifies the type as Claude-loop and its model is not a Multi one. */ +function nativeClaude(response: GatewayResponse | undefined): boolean { + return response?.execution === 'claude' && !isMultiModel(response.model); +} + +/** Remembers whether an agent type is a native Claude subagent, changing state only on a change. */ +async function classify($: EngineInterface, agent: string, isNative: boolean) { + if ((await read($, claudeTypes)).includes(agent) === isNative) { + return; + } + await update($, claudeTypes, (held) => + isNative ? withItem(held, agent) : held.filter((type) => type !== agent), + ); +} + +/** Tracks the provider worker types the model is offered, for the Agent tool's description. */ +async function markOffered($: EngineInterface, agent: string, isOffered: boolean) { + if (!isProviderWorker(agent) || (await read($, offeredProviders)).includes(agent) === isOffered) { + return; + } + await update($, offeredProviders, (held) => + isOffered ? withItem(held, agent) : held.filter((type) => type !== agent), + ); + $.ui.invalidate('tool.describe'); +} + async function spawnPayload($: EngineInterface, event: SpawnEvent) { return { sessionId: await $.session.id(), @@ -216,7 +243,7 @@ async function providerSpawn( const provider = isProviderWorker(event.subagentType) && !event.fork; const named = provider ? (requestedModels.get(event.tool_use_id) ?? event.model) : event.model; const payload = { ...(await spawnPayload($, event)), model: named }; - const selection = await request($, payload, '/multi/mod/worker-model'); + const selection = await postJson(wire($), '/multi/mod/worker-model', payload); if (!provider) { return { event, selection }; } @@ -238,38 +265,37 @@ async function admit( $: EngineInterface, event: SpawnEvent, selection: GatewayResponse | undefined, - policyState: PolicyState, ): Promise { const payload = await spawnPayload($, event); if (!harnessSpawn(event, selection)) { // Keep context for a possible later harness child, but never veto the // engine's native worker because Multi could not reconstruct its policy. - await request($, payload); + await postJson(wire($), '/multi/mod/worker', payload); return undefined; } - await prepareHarness($, policyState); - const mode = await request( - $, - {}, - `/multi/mod/mode?sessionId=${encodeURIComponent(payload.sessionId)}`, + await prepareHarness($); + // The worker's rows anchor in its own transcript only once their tools exist. + await syncDisplayTools(rowsClient($)); + const mode = accepted( + await getJson(wire($), '/multi/mod/mode', { sessionId: payload.sessionId }), ); - const response = await request($, { ...payload, generation: mode?.generation }); + const response = await postJson(wire($), '/multi/mod/worker', { + ...payload, + generation: mode?.generation, + }); return response?.accepted ? undefined : 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. + * A Claude subagent: not a `multi-*` worker type, classified as native Claude (a Claude + * model, not a provider model its definition pins), 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): boolean { +function nativeClaudeSpawn(event: SpawnEvent, claudeTypeNames: readonly string[]): boolean { return ( - claudeTypes.has(event.subagentType) && + claudeTypeNames.includes(event.subagentType) && !isProviderWorker(event.subagentType) && !isMultiModel(event.model) && !isMultiModel(event.parentModel) @@ -284,58 +310,21 @@ function harnessSpawn( return selection?.execution === 'harness' || (!selection?.known && isHarnessModel(inferred)); } -async function active($: EngineInterface): Promise { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - return Boolean(base && token); -} - -async function request( - $: EngineInterface, - payload: Record, - route = '/multi/mod/worker', -) { - const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); - const token = await $.env.get('MULTI_GATEWAY_TOKEN'); - if (!base || !token) { - return undefined; - } - const body = JSON.stringify(payload); - if (encodeURIComponent(body).replace(/%[A-F\d]{2}/gi, 'x').length > maxBody) { - return undefined; - } - let timer: ReturnType | undefined; - try { - const response = $.http.fetch(`${base}${route}`, { - method: route.includes('?') ? 'GET' : 'POST', - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, - ...(route.includes('?') ? {} : { body }), - }); - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('gateway request timeout')), 1500); - }); - const result = await Promise.race([response, timeout]); - return result.ok - ? (JSON.parse(result.text) as GatewayResponse) - : refusal(result.text, result.status); - } catch { - return undefined; - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -async function policyClient($: EngineInterface): Promise { - return { - model: await $.session.model(), - request: (route, payload) => request($, payload, route), - }; -} - -async function prepareHarness($: EngineInterface, state: PolicyState) { - if (state.prompt && !state.harnessReady) { - await ensureHarnessPolicy(await policyClient($), state); +/** + * Admits the prompt's harness policy once for every helper it spawns. The snapshot is + * state, so the admission outlives a reload; concurrent helpers share one admission in + * `policy.ts`. + */ +async function prepareHarness($: EngineInterface) { + const held = await read($, policy); + if (!held.prompt || held.harnessReady) { + return; } + const admitted: MultiCorePolicy = { ...held }; + await ensureHarnessPolicy(policyClient(wire($), await $.session.model()), admitted); + await update($, policy, (latest) => + JSON.stringify(latest.prompt) === JSON.stringify(held.prompt) + ? defined({ ...latest, generation: admitted.generation, harnessReady: admitted.harnessReady }) + : latest, + ); } diff --git a/plugins/multi-core/src/gateway/mod-policy.ts b/plugins/multi-core/src/gateway/mod-policy.ts index d900020..c91455f 100644 --- a/plugins/multi-core/src/gateway/mod-policy.ts +++ b/plugins/multi-core/src/gateway/mod-policy.ts @@ -22,7 +22,7 @@ type Job = { const JOB_TTL_MS = 60_000; const MAX_SESSIONS = 128; /** The longest a hook's long poll holds its reply; the hook sends one request, not a loop. */ -export const POLICY_WAIT_MS = 8000; +const POLICY_WAIT_MS = 8000; /** File discovery runs detached from the bounded hook requests. */ export class ModPolicies { diff --git a/plugins/multi-core/tsconfig.json b/plugins/multi-core/tsconfig.json index 45d05be..0cd864b 100644 --- a/plugins/multi-core/tsconfig.json +++ b/plugins/multi-core/tsconfig.json @@ -11,6 +11,5 @@ "noEmit": true, "skipLibCheck": true }, - "include": ["../../.claude/types", "hooks", "types"], - "exclude": ["hooks/tests"] + "include": ["../../.claude-plugin/types/claude-code", "hooks", "types"] } diff --git a/plugins/multi-core/types/multi-core.d.ts b/plugins/multi-core/types/multi-core.d.ts new file mode 100644 index 0000000..53717a0 --- /dev/null +++ b/plugins/multi-core/types/multi-core.d.ts @@ -0,0 +1,53 @@ +// The values multi-core's hooks keep in `$.state`: held by the host for the session, +// so they survive a hot reload of the hooks module, and a drawing that reads one is +// drawn again when it is written. Plain JSON data; a field without a value is left out. +export type MultiCorePrompt = { sessionId: string; cwd: string; permissionMode?: string }; + +/** The prompt-boundary snapshot the gateway holds this session's mode generation for. */ +export type MultiCorePolicy = { + generation?: number; + prompt?: MultiCorePrompt; + harnessReady?: boolean; + /** The snapshot last posted for a prompt that needs no harness policy, so an unchanged one posts nothing. */ + posted?: string; + /** The session the gateway was greeted for (`session.start`, or the first prompt after a `/clear`). */ + handshake?: string; +}; + +export type MultiCoreDisplayTools = { registered: string[]; revision?: number }; + +export type MultiCoreUsagePane = { + updatedAt: string; + providers: Array<{ + id: string; + name: string; + status: string; + summary: string; + details: string[]; + url?: string; + }>; + receiptLines?: string[]; + error?: string; + quotaAdviceEnabled?: boolean; +}; + +declare module 'claude-code' { + interface PluginState { + 'multi-core': { + policy: MultiCorePolicy; + /** The model each loop last stepped on or was registered with, by agent id (`main` for the main loop). */ + agentModels: Record; + /** The model a provider worker resolved to, by its Agent call's tool_use_id. */ + spawnModels: Record; + /** Agent types positively classified as Claude-loop on a Claude model. */ + claudeTypes: string[]; + /** Provider (multi-*) worker types the model is offered. */ + offeredProviders: string[]; + displayTools: MultiCoreDisplayTools; + /** The usage pane's props, by session id. */ + usagePanes: Record; + /** Sessions whose Agent calls carry the quota advice. */ + advisorySessions: string[]; + }; + } +} diff --git a/plugins/multi-core/types/timers.d.ts b/plugins/multi-core/types/timers.d.ts deleted file mode 100644 index 6cf3fb4..0000000 --- a/plugins/multi-core/types/timers.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// The hooks environment provides timers, which the generated engine declarations -// (`.claude/types/claude-code.d.ts`) do not list among its globals. -declare function setTimeout(callback: (...args: never[]) => void, ms?: number): number; -declare function clearTimeout(id: number | undefined): void; diff --git a/test/unit/native-usage-menu.test.ts b/test/unit/native-usage-menu.test.ts index 627d5ee..ec74e2b 100644 --- a/test/unit/native-usage-menu.test.ts +++ b/test/unit/native-usage-menu.test.ts @@ -1,9 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -// Load the actual Mod modules without requiring Claude's host-only type package -// in the offline TypeScript project. All runtime imports in these modules are erased types. -const hooksUrl = new URL('../../plugins/multi-core/hooks/usage.ts', import.meta.url); +// The usage pane's hooks run in the engine and are covered by `npm run test:mod` +// (`plugins/multi-core/hooks/tests/usage.test.ts`); the Client module is plain code. const viewUrl = new URL('../../plugins/multi-core/hooks/usage-view.ts', import.meta.url); const dashboard = { updatedAt: 'today', @@ -18,64 +17,6 @@ const dashboard = { ], }; -test('usage client messages refresh props and receipts without losing providers or accepting foreign panes', async () => { - const requests: string[] = []; - const engine = { - env: { - get: async (name: string) => (name === 'MULTI_GATEWAY_TOKEN' ? 'secret' : 'http://localhost'), - }, - session: { id: async () => 'session/one' }, - http: { - fetch: async (url: string) => { - requests.push(url); - const result = url.includes('/receipts?') - ? { - receipts: [ - { - agentId: 'worker', - outcome: 'completed', - time: 'today', - requests: 1, - usage: { input_tokens: 2, output_tokens: 3 }, - }, - ], - } - : dashboard; - return { ok: true, text: JSON.stringify(result) }; - }, - }, - ui: { open: async () => {}, invalidate: () => {} }, - }; - type Result = { props?: { providers: unknown[]; receiptLines?: string[] }; text?: string }; - type Hook = ( - host: typeof engine, - event: Record, - next: () => Promise, - ) => Promise; - const hooks = new Map(); - const module = await import(hooksUrl.href); - module.register((name: string, _filter: unknown, hook: Hook) => hooks.set(name, hook)); - const command = hooks.get('command.run'); - const message = hooks.get('ui.message'); - assert(command && message); - const next = async () => ({}); - await command(engine, { args: '' }, next); - const event = { - requestId: 'multi-usage', - element: 'usage', - module: 'hooks/usage-view.ts', - data: { action: 'refresh' }, - }; - const refreshed = await message(engine, event, next); - assert.deepEqual(refreshed.props?.providers, dashboard.providers); - assert(requests[1].includes('refresh=true&sessionId=session%2Fone')); - const receipts = await message(engine, { ...event, data: { action: 'receipts' } }, next); - assert.deepEqual(receipts.props?.providers, dashboard.providers); - assert.match(receipts.props?.receiptLines?.[0] ?? '', /worker/); - await message(engine, { ...event, requestId: 'another-pane' }, next); - assert.equal(requests.length, 3); -}); - type Element = { type: string; props: Record }; function flatten(element: Element): Element[] { const children = element.props.children; @@ -124,103 +65,3 @@ test('usage Client supports provider navigation, receipts and refresh without mo keyHandler?.({ key: 'r' }); assert.deepEqual(posts.at(-1), { action: 'refresh' }); }); - -test('quota advice is opt-in, session scoped, advisory and removed on detach', async () => { - const module = await import(hooksUrl.href); - let session = 'advice-one'; - let reads = 0; - let available = true; - const engine = { - env: { - get: async (name: string) => (name === 'MULTI_GATEWAY_TOKEN' ? 'secret' : 'http://localhost'), - }, - session: { id: async () => session, model: async () => 'claude-sonnet-5' }, - http: { - fetch: async () => { - reads++; - return { ok: available, text: JSON.stringify(dashboard) }; - }, - }, - ui: { open: async () => {}, invalidate: () => {} }, - }; - type Hook = ( - host: typeof engine, - event: Record, - next: (event: Record) => Promise>, - ) => Promise>; - const hooks = new Map(); - module.register((name: string, filter: unknown, handler?: Hook) => { - hooks.set(name, typeof filter === 'function' ? (filter as Hook) : (handler as Hook)); - }); - const submit = hooks.get('classic.PreToolUse'); - const command = hooks.get('command.run'); - const message = hooks.get('ui.message'); - assert(submit && command && message); - const next = async (event: Record) => ({ - ...event, - additionalContext: ['existing context'], - ask: 'Existing permission review', - }); - const prompt = { - tool: 'Agent', - tool_use_id: 'call-1', - prompt: 'Delegate this task', - model: 'chosen-model', - subagent_type: 'cursor', - }; - assert.deepEqual(await submit(engine, prompt, next), await next(prompt)); - assert.equal(reads, 0); - await command(engine, { args: '' }, next); - const toggle = { - requestId: 'multi-usage', - element: 'usage', - data: { action: 'toggle-quota-advice' }, - }; - const enabled = await message(engine, toggle, next); - assert.equal((enabled.props as { quotaAdviceEnabled: boolean }).quotaAdviceEnabled, true); - const readsBeforeSpawn = reads; - const informed = await submit(engine, prompt, async (event) => { - assert.equal(reads, readsBeforeSpawn + 1); - assert.deepEqual(event, prompt); - return next(event); - }); - assert.equal(informed.model, prompt.model); - assert.equal(informed.prompt, prompt.prompt); - assert.equal(informed.ask, 'Existing permission review'); - const context = informed.additionalContext as string[]; - assert.equal(context[0], 'existing context'); - assert.match(context[1], /lowest subscription usage/); - assert.match(context[1], /may choose a more-used model/); - assert.match(context[1], /without waiting/); - assert(!context[1].includes('native spend')); - const readsBeforeSkipped = reads; - for (const event of [ - { ...prompt, tool: 'Read' }, - { ...prompt, agentId: 'nested-worker' }, - ]) { - assert.deepEqual(await submit(engine, event, next), await next(event)); - } - assert.equal(reads, readsBeforeSkipped); - const task = { ...prompt, tool: 'Task' }; - assert.equal(((await submit(engine, task, next)).additionalContext as string[]).length, 2); - assert.equal(reads, readsBeforeSkipped + 1); - const beforeOther = reads; - session = 'advice-two'; - assert.deepEqual(await submit(engine, prompt, next), await next(prompt)); - assert.equal(reads, beforeOther); - session = 'advice-one'; - available = false; - assert.match( - ((await submit(engine, prompt, next)).additionalContext as string[])[1], - /could not be retrieved/, - ); - await message(engine, toggle, next); - const beforeDisabled = reads; - assert.deepEqual(await submit(engine, prompt, next), await next(prompt)); - assert.equal(reads, beforeDisabled); - await message(engine, toggle, next); - module.forgetUsageSession(session); - assert.deepEqual(await submit(engine, prompt, next), await next(prompt)); - assert.equal(reads, beforeDisabled); - assert(!hooks.has('prompt.submit')); -}); From c0715d6f5c997aadbe9dce366a9b700323793424 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:01:46 -0400 Subject: [PATCH 17/36] Scope compaction to one loop, evict idle permission contexts, drop dead record() A main-loop compaction boundary is no longer written into the main snapshot, so workers are no longer resolved as toolless compaction turns, and a failed or cancelled compaction clears it. The permission context tables evict their oldest entry at the cap instead of refusing new work. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-core/src/gateway/mode-hook.ts | 99 ++++++++------------ test/live/native-cursor-harness.ts | 7 +- test/unit/mod-policy.test.ts | 43 ++++++++- test/unit/native-antigravity-gateway.test.ts | 14 +-- test/unit/native-cursor-harness-http.test.ts | 34 +------ test/unit/native-grok-gateway.test.ts | 7 +- 6 files changed, 91 insertions(+), 113 deletions(-) diff --git a/plugins/multi-core/src/gateway/mode-hook.ts b/plugins/multi-core/src/gateway/mode-hook.ts index d016dcf..1d732ea 100644 --- a/plugins/multi-core/src/gateway/mode-hook.ts +++ b/plugins/multi-core/src/gateway/mode-hook.ts @@ -44,7 +44,12 @@ function executionForModel(model: unknown): WorkerExecution { /** Prompt-time snapshots: the existing selector takes effect at the next prompt. */ export class PermissionModes { readonly policies: ModPolicies; - private readonly compactions = new Map(); + /** + * Armed compaction boundaries, keyed by exactly one [session, 'main' | worker]. The + * main loop's boundary is applied by `resolve` and never written into its snapshot, + * so no worker inherits it; a worker's `previous` is what it ran under before. + */ + private readonly compactions = new Map(); private readonly parents = new Map(); private readonly hostOnly = new Set(); /** The last admitted settings restrictions per session, retained across host snapshots. */ @@ -82,9 +87,9 @@ export class PermissionModes { beginPolicy(session: string, cwd: string) { this.parents.delete(session); - for (const key of this.compactions.keys()) { + for (const key of [...this.compactions.keys()]) { if (JSON.parse(key)[0] === session) { - this.compactions.delete(key); + this.clearCompaction(key); } } for (const key of this.pendingWorkers.keys()) { @@ -344,14 +349,14 @@ export class PermissionModes { authorizeModCompaction(session: string, agent?: string): void { const current = this.resolveHarness(session, agent); const key = JSON.stringify([session, agent ?? 'main']); - const previous = this.compactions.get(key)?.previous ?? current; - const context = { ...current, tools: [], compaction: randomUUID() }; - remember(this.compactions, key, { id: context.compaction, previous }); - if (agent) { - this.recordModWorker(session, agent, context); - } else { - this.recordModSession(session, context); + const id = randomUUID(); + if (!agent) { + remember(this.compactions, key, { id }); + return; } + const previous = this.compactions.get(key)?.previous ?? current; + remember(this.compactions, key, { id, previous }); + this.recordModWorker(session, agent, { ...current, tools: [], compaction: id }); } /** Arm a tool-free boundary when SessionStart restored no prompt snapshot. */ @@ -364,58 +369,30 @@ export class PermissionModes { this.authorizeModCompaction(session); } + /** The compaction turn ended, successfully or not: the scope runs under its own policy again. */ finishModCompaction(session: string, agent: string | undefined, id: string | undefined): void { const key = JSON.stringify([session, agent ?? 'main']); - const saved = this.compactions.get(key); - if (!id || saved?.id !== id) { + if (!id || this.compactions.get(key)?.id !== id) { return; } - this.compactions.delete(key); - if (this.resolve(session, agent).compaction !== id) { - return; - } - if (agent) { - this.recordModWorker(session, agent, saved.previous); - } else { - this.recordModSession(session, saved.previous); - } + this.clearCompaction(key); + } + + /** Disarm one scope's boundary without a result, for a cancelled or abandoned compaction. */ + cancelModCompaction(session: string, agent?: string): void { + this.clearCompaction(JSON.stringify([session, agent ?? 'main'])); } - async record(input: Record): Promise { - const session = requiredString(input.session_id, 'session_id'); - if (input.hook_event_name === 'UserPromptSubmit') { - // Clear first: a malformed new snapshot must not retain earlier permissions. - this.parents.delete(session); - remember(this.parents, session, { permissionMode: permissionMode(input.permission_mode) }); + private clearCompaction(key: string): void { + const saved = this.compactions.get(key); + if (!saved) { return; } - if (input.hook_event_name !== 'SubagentStart') { - throw new Error('Unsupported mode hook event'); - } - const agent = requiredString(input.agent_id, 'agent_id'); - const key = JSON.stringify([session, agent]); - this.workers.delete(key); - const type = requiredString(input.agent_type, 'agent_type'); - const cwd = requiredString(input.cwd, 'cwd'); - const definitions = await this.definitions(cwd); - const definition = Object.hasOwn(definitions, type) ? definitions[type] : undefined; - if (!definition) { - throw new Error(`Cannot resolve permissions for Claude worker ${type}`); + this.compactions.delete(key); + const [session, agent] = JSON.parse(key) as [string, string]; + if (agent !== 'main' && saved.previous && this.workers.has(key)) { + this.recordModWorker(session, agent, saved.previous); } - remember(this.workers, key, { - cwd, - model: - definition.model === 'inherit' - ? this.parents.get(session)?.model - : (definition.model ?? this.parents.get(session)?.model), - // A definition without a mode inherits the parent's at resolve time. - permissionMode: definition.permissionMode, - tools: definition.tools?.slice(), - disallowedTools: definition.disallowedTools?.slice(), - ...(definition.nativePermissionError - ? { nativePermissionError: definition.nativePermissionError } - : {}), - }); } forgetSession(session: string): void { @@ -443,7 +420,9 @@ export class PermissionModes { throw new Error('Claude permission mode is unavailable; submit a new prompt'); } if (!agent) { - return structuredClone(parent); + const armed = this.compactions.get(JSON.stringify([session, 'main'])); + const context = structuredClone(parent); + return armed ? { ...context, tools: [], compaction: armed.id } : context; } const worker = this.workers.get(JSON.stringify([session, agent])); if (!worker) { @@ -458,7 +437,6 @@ export class PermissionModes { { ...worker, nativePermissionError: worker.nativePermissionError ?? parent.nativePermissionError, - ...(parent.compaction ? { compaction: parent.compaction } : {}), permissionMode: inherited ? parent.permissionMode : permissionMode(worker.permissionMode ?? parent.permissionMode), @@ -539,9 +517,14 @@ function validateWorkerRequest( } function remember(entries: Map, key: string, value: T): void { - // ponytail: cap lifetime identities; add lifecycle cleanup if long sessions reach this limit. - if (!entries.has(key) && entries.size >= 4096) { - throw new Error('Claude permission context limit reached; restart the gateway'); + // A write refreshes the entry; at the cap the least recently written one makes room, so a + // long-lived gateway never refuses new work for the sake of identities long idle. + entries.delete(key); + if (entries.size >= 4096) { + const oldest = entries.keys().next(); + if (!oldest.done) { + entries.delete(oldest.value); + } } entries.set(key, value); } diff --git a/test/live/native-cursor-harness.ts b/test/live/native-cursor-harness.ts index 536dad5..0723f90 100644 --- a/test/live/native-cursor-harness.ts +++ b/test/live/native-cursor-harness.ts @@ -143,12 +143,7 @@ try { recovering = false; const followup = 'Without using tools, repeat the full line you just produced. One line only.'; const hooks = new PermissionModes(async () => ({})); - await hooks.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'live', - permission_mode: 'auto', - prompt: followup, - }); + hooks.recordModSession('live', { permissionMode: 'auto' }); const history = compact ? [] : [...(request.messages ?? []), { role: 'assistant', content: first.content }]; diff --git a/test/unit/mod-policy.test.ts b/test/unit/mod-policy.test.ts index ba26eed..4f76c6f 100644 --- a/test/unit/mod-policy.test.ts +++ b/test/unit/mod-policy.test.ts @@ -216,7 +216,7 @@ test('ambiguous simultaneous worker starts fail closed and catalog filtering pre assert.equal(modes.offered('/workspace', 'unknown'), false); }); -test('worker contexts retain the authenticated compaction deny marker and parent restrictions', async () => { +test('a main-loop compaction boundary is its own scope: workers keep tools and parent restrictions', async () => { const modes = new PermissionModes(async () => policy.workers); await modes.precompute('/workspace'); modes.recordModSession('s', { @@ -230,13 +230,52 @@ test('worker contexts retain the authenticated compaction deny marker and parent permissionMode: 'auto', }); modes.startPreparedModWorker('s', 'worker', 'cursor', '/workspace'); + const before = modes.resolve('s', 'worker').tools; modes.authorizeModCompaction('s'); - assert.equal(modes.resolve('s', 'worker').compaction, modes.resolve('s').compaction); + const armed = modes.resolve('s'); + assert.deepEqual(armed.tools, []); + assert.ok(armed.compaction); + // The boundary is for the main loop alone: a worker resolved now is an ordinary worker. + assert.equal(modes.resolve('s', 'worker').compaction, undefined); + assert.deepEqual(modes.resolve('s', 'worker').tools, before); assert.deepEqual(modes.resolve('s', 'worker').disallowedTools, ['Bash']); + // A failed or cancelled compaction clears it; it does not stay armed. + modes.cancelModCompaction('s'); + assert.equal(modes.resolve('s').compaction, undefined); + assert.equal(modes.resolve('s').tools, undefined); + modes.authorizeModCompaction('s'); + modes.finishModCompaction('s', undefined, modes.resolve('s').compaction); + assert.equal(modes.resolve('s').compaction, undefined); modes.forgetSession('s'); assert.throws(() => modes.resolve('s', 'worker'), /unavailable/); }); +test('a worker compaction boundary is restored when it is cancelled', async () => { + const modes = new PermissionModes(async () => policy.workers); + await modes.precompute('/workspace'); + modes.recordModSession('s', { permissionMode: 'plan', cwd: '/workspace' }); + await modes.prepareModWorker('s', { + subagentType: 'cursor', + cwd: '/workspace', + permissionMode: 'plan', + }); + modes.startPreparedModWorker('s', 'worker', 'cursor', '/workspace'); + modes.authorizeModCompaction('s', 'worker'); + assert.deepEqual(modes.resolve('s', 'worker').tools, []); + modes.cancelModCompaction('s', 'worker'); + assert.deepEqual(modes.resolve('s', 'worker').tools, ['Read']); + assert.equal(modes.resolve('s', 'worker').compaction, undefined); +}); + +test('the permission context table evicts its oldest entry instead of refusing new sessions', () => { + const modes = new PermissionModes(async () => ({})); + for (let index = 0; index < 4200; index++) { + modes.recordModSession(`session-${index}`, { permissionMode: 'default' }); + } + assert.equal(modes.resolve('session-4199').permissionMode, 'default'); + assert.throws(() => modes.resolve('session-0'), /unavailable/); +}); + test('a policy discovery that outlasts the hook is reused on retry until admitted', async () => { let finish: ((value: PreparedPolicy) => void) | undefined; let discoveries = 0; diff --git a/test/unit/native-antigravity-gateway.test.ts b/test/unit/native-antigravity-gateway.test.ts index 9d3b475..9461f34 100644 --- a/test/unit/native-antigravity-gateway.test.ts +++ b/test/unit/native-antigravity-gateway.test.ts @@ -19,12 +19,7 @@ const reply: MessagesResponse = { test('Antigravity routing resolves authenticated mode and never executes observed tools', async (t) => { const modes = new PermissionModes(async () => ({})); - await modes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'test-session', - permission_mode: 'auto', - prompt: 'edit the fixture', - }); + modes.recordModSession('test-session', { permissionMode: 'auto' }); let calls = 0; const server = createNativeGateway({ token: 'test-token', @@ -71,12 +66,7 @@ test('Antigravity routing resolves authenticated mode and never executes observe test('a busy Antigravity agent is answered 400, not a retryable 502', async (t) => { const modes = new PermissionModes(async () => ({})); - await modes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'busy-session', - permission_mode: 'auto', - prompt: 'edit the fixture', - }); + modes.recordModSession('busy-session', { permissionMode: 'auto' }); const server = createNativeGateway({ token: 'test-token', authFile: '/unused', diff --git a/test/unit/native-cursor-harness-http.test.ts b/test/unit/native-cursor-harness-http.test.ts index 3c59e5a..1665101 100644 --- a/test/unit/native-cursor-harness-http.test.ts +++ b/test/unit/native-cursor-harness-http.test.ts @@ -76,19 +76,9 @@ test('Cursor harness serves isolated main and worker SSE progress without replay }); const permissionModes = new PermissionModes(async () => ({ worker: {} })); for (const session_id of ['session-1', 'session-cancelled']) { - await permissionModes.record({ - hook_event_name: 'UserPromptSubmit', - session_id, - permission_mode: 'auto', - }); + permissionModes.recordModSession(session_id, { permissionMode: 'auto' }); } - await permissionModes.record({ - hook_event_name: 'SubagentStart', - session_id: 'session-1', - agent_id: 'worker-1', - agent_type: 'worker', - cwd: directory, - }); + permissionModes.recordModWorker('session-1', 'worker-1', { cwd: directory }); const server = createNativeGateway({ permissionModes, token: 'test-token', @@ -217,19 +207,9 @@ test('native SSE cancellation stops the SDK run without reporting successful com }); const permissionModes = new PermissionModes(async () => ({ worker: {} })); for (const session_id of ['session-1', 'session-cancelled']) { - await permissionModes.record({ - hook_event_name: 'UserPromptSubmit', - session_id, - permission_mode: 'auto', - }); + permissionModes.recordModSession(session_id, { permissionMode: 'auto' }); } - await permissionModes.record({ - hook_event_name: 'SubagentStart', - session_id: 'session-1', - agent_id: 'worker-1', - agent_type: 'worker', - cwd: directory, - }); + permissionModes.recordModWorker('session-1', 'worker-1', { cwd: directory }); const server = createNativeGateway({ permissionModes, token: 'test-token', @@ -307,11 +287,7 @@ test('a prompt that arrives during a native run is answered 400, not a retryable }), }); const permissionModes = new PermissionModes(async () => ({ worker: {} })); - await permissionModes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'session-busy', - permission_mode: 'auto', - }); + permissionModes.recordModSession('session-busy', { permissionMode: 'auto' }); const server = createNativeGateway({ permissionModes, token: 'test-token', diff --git a/test/unit/native-grok-gateway.test.ts b/test/unit/native-grok-gateway.test.ts index 954f804..9942134 100644 --- a/test/unit/native-grok-gateway.test.ts +++ b/test/unit/native-grok-gateway.test.ts @@ -47,12 +47,7 @@ async function gateway(t: test.TestContext, handle: Parameters ({})); - await value.record({ - hook_event_name: 'UserPromptSubmit', - session_id: 'test-session', - permission_mode: 'auto', - prompt: 'run the tests', - }); + value.recordModSession('test-session', { permissionMode: 'auto' }); return value; } From 432c6f83dde9a4e00a3a8b21cee99974588a5d30 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:01:46 -0400 Subject: [PATCH 18/36] Keep the session gateway out of nested Claude runs and harden the launcher Nested claude runs get the start environment back through a PATH shim and the bootstrap; a caller ANTHROPIC_BASE_URL and proxy are honored; discovery runs in parallel; a Claude that exits early ends the launch with its exit code; a failing plugin listing no longer aborts it; agent view is scoped through the session settings alone. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- docs/installation.md | 7 +- plugins/multi-core/src/gateway/nested-env.ts | 43 ++++ plugins/multi-core/src/install/bootstrap.ts | 11 +- .../multi-core/src/install/nested-claude.ts | 20 ++ plugins/multi-core/src/install/process.ts | 57 ++++++ plugins/multi-core/src/launcher.ts | 193 +++++++++++++----- test/unit/native-launcher-exit.test.ts | 52 +++++ test/unit/native-launcher.test.ts | 31 +-- test/unit/native-session-isolation.test.ts | 108 ++++++++++ 9 files changed, 443 insertions(+), 79 deletions(-) create mode 100644 plugins/multi-core/src/gateway/nested-env.ts create mode 100644 plugins/multi-core/src/install/nested-claude.ts create mode 100644 test/unit/native-launcher-exit.test.ts create mode 100644 test/unit/native-session-isolation.test.ts diff --git a/docs/installation.md b/docs/installation.md index 6550b77..a79952a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,7 +5,12 @@ - Node 24.12 or newer from a persistent installation. Setup records its executable path. - Claude Code 2.1.272 or newer with function hooks. `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` would block the local gateway, so the launcher replaces it for its session with - `DISABLE_AUTOUPDATER`, `DISABLE_TELEMETRY`, `DISABLE_ERROR_REPORTING` and `DISABLE_BUG_COMMAND`. + `DISABLE_AUTOUPDATER`, `DISABLE_TELEMETRY`, `DISABLE_ERROR_REPORTING`, `DISABLE_BUG_COMMAND` and + `DISABLE_FEEDBACK_COMMAND`. +- A proxy (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`) and a caller-set `ANTHROPIC_BASE_URL` are + honored for Claude's own traffic: the gateway forwards it through the proxy to that base URL. + Node reads the proxy variables at startup only, so the installed command starts the launcher + with `NODE_USE_ENV_PROXY=1`; a launcher started by hand needs it set. - OpenAI: the official Codex CLI (`codex`) and a ChatGPT login. - Cursor: the official Cursor SDK login. No separate Cursor CLI is required. - OpenCode Zen: a Zen API key in OpenCode's auth store or `OPENCODE_API_KEY`. diff --git a/plugins/multi-core/src/gateway/nested-env.ts b/plugins/multi-core/src/gateway/nested-env.ts new file mode 100644 index 0000000..0a80ab7 --- /dev/null +++ b/plugins/multi-core/src/gateway/nested-env.ts @@ -0,0 +1,43 @@ +// --------------------------------------------------------------------------- +// A Multi session points Claude Code at its own gateway through environment +// variables. Every process Claude starts inherits them, including a `claude -p` +// run by an agent's Bash tool, an SDK run, or a script. Those runs are not part +// of the session; once the session exits the gateway is gone, and until then +// they would route through (and inherit the credentials of) a gateway that was +// not started for them. A nested run gets the environment the session started +// with instead (`withoutGateway`, in install/process.ts: the installed bootstrap +// has to carry it without the rest of the gateway). +// --------------------------------------------------------------------------- + +/** The `claude` launcher a session puts first on PATH for the processes it starts. */ +export function nestedClaudeShim(options: { + platform?: NodeJS.Platform; + node: string; + script: string; + claude: string; +}): { file: string; content: string } { + const { node, script, claude } = options; + if ((options.platform ?? process.platform) === 'win32') { + return { + file: 'claude.cmd', + content: `@echo off\r\n"${node}" "${script}" "${claude}" %*\r\n`, + }; + } + const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + return { + file: 'claude', + content: `#!/bin/sh\nexec ${quote(node)} ${quote(script)} ${quote(claude)} "$@"\n`, + }; +} + +/** `directory` first on PATH, whatever case the platform spells the variable in. */ +export function withPathPrefix( + env: NodeJS.ProcessEnv, + directory: string, + platform: NodeJS.Platform = process.platform, +): NodeJS.ProcessEnv { + const name = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'; + const delimiter = platform === 'win32' ? ';' : ':'; + const existing = env[name]; + return { ...env, [name]: existing ? `${directory}${delimiter}${existing}` : directory }; +} diff --git a/plugins/multi-core/src/install/bootstrap.ts b/plugins/multi-core/src/install/bootstrap.ts index 2e98f90..1216d00 100644 --- a/plugins/multi-core/src/install/bootstrap.ts +++ b/plugins/multi-core/src/install/bootstrap.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { type Installation, readInstallation, uninstall } from './installation.ts'; import { installedPlugins, settingsArguments } from './plugins.ts'; -import { run } from './process.ts'; +import { needsEnvProxy, run, withoutGateway } from './process.ts'; async function dispatch(state: Installation, args: string[], management: boolean) { const { root, providers } = await installedPlugins(state.claude, settingsArguments(args)); @@ -12,13 +12,14 @@ async function dispatch(state: Installation, args: string[], management: boolean return 0; } if (!management && (!root || providers.length === 0)) { - return run(state.claude, args); + return run(state.claude, args, { env: withoutGateway(process.env) }); } if (!management && state.command === 'claude' && process.env.MULTI_GATEWAY_TOKEN) { // A launch command named `claude` also catches Claude's own nested runs (agents // calling `claude -p`, SDK spawns, hooks). Inside a Multi session those must - // reach the real executable rather than start a second gateway. - return run(state.claude, args); + // reach the real executable rather than start a second gateway, and without this + // session's gateway URL and credentials: that gateway is not theirs. + return run(state.claude, args, { env: withoutGateway(process.env) }); } if (!root) { throw new Error('Enable multi-core at user scope before using Multi commands.'); @@ -34,6 +35,8 @@ async function dispatch(state: Installation, args: string[], management: boolean ...(state.models !== undefined && process.env.MULTI_MODELS === undefined ? { MULTI_MODELS: state.models } : {}), + // Node reads this at startup only, so the launcher process has to be started with it. + ...(needsEnvProxy(process.env) ? { NODE_USE_ENV_PROXY: '1' } : {}), MULTI_REAL_CLAUDE: state.claude, MULTI_ENABLED_PROVIDERS: providers.join(','), MULTI_ANTIGRAVITY: providers.includes('antigravity') ? '1' : '0', diff --git a/plugins/multi-core/src/install/nested-claude.ts b/plugins/multi-core/src/install/nested-claude.ts new file mode 100644 index 0000000..ddac466 --- /dev/null +++ b/plugins/multi-core/src/install/nested-claude.ts @@ -0,0 +1,20 @@ +// Runs the real Claude Code without the enclosing Multi session's gateway +// environment. The launcher puts a `claude` shim that calls this first on the +// session's PATH, so a `claude` started by Claude's own tools is an ordinary run. +import { run, withoutGateway } from './process.ts'; + +const [claude, ...args] = process.argv.slice(2); +if (!claude) { + console.error('Multi: nested claude shim needs the real executable path.'); + process.exitCode = 1; +} else { + void run(claude, args, { env: withoutGateway(process.env) }).then( + (code) => { + process.exitCode = code; + }, + (error: unknown) => { + console.error(`Multi: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }, + ); +} diff --git a/plugins/multi-core/src/install/process.ts b/plugins/multi-core/src/install/process.ts index e3f62f6..6908c3d 100644 --- a/plugins/multi-core/src/install/process.ts +++ b/plugins/multi-core/src/install/process.ts @@ -40,3 +40,60 @@ export async function run( process.off('SIGINT', interrupt); } } + +/** What the session started with, kept so a nested run can be given it back. */ +const RESTORABLE = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_CUSTOM_HEADERS'] as const; +const ORIGINAL_PREFIX = 'MULTI_ORIG_'; + +/** Remember the caller's own values before the launcher replaces them. */ +export function rememberOriginalEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const remembered: NodeJS.ProcessEnv = {}; + for (const name of RESTORABLE) { + const value = env[name]; + if (value !== undefined) { + remembered[`${ORIGINAL_PREFIX}${name}`] = value; + } + } + return remembered; +} + +/** + * The environment for a Claude run that is not the Multi session: no gateway URL, no + * gateway token, no gateway credential, and the caller's own base URL and headers + * back. An environment that carries no gateway token is returned unchanged. + */ +export function withoutGateway(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const token = env.MULTI_GATEWAY_TOKEN; + if (token === undefined) { + return env; + } + const result: NodeJS.ProcessEnv = { ...env }; + delete result.MULTI_GATEWAY_TOKEN; + delete result.MULTI_MOD_GATEWAY_URL; + if (result.ANTHROPIC_AUTH_TOKEN === token) { + delete result.ANTHROPIC_AUTH_TOKEN; + } + for (const name of RESTORABLE) { + const original = env[`${ORIGINAL_PREFIX}${name}`]; + delete result[`${ORIGINAL_PREFIX}${name}`]; + if (original === undefined) { + delete result[name]; + } else { + result[name] = original; + } + } + return result; +} + +/** + * Node's fetch ignores HTTP_PROXY and HTTPS_PROXY unless NODE_USE_ENV_PROXY=1 is set + * when the process starts; setting it later has no effect. A user who needs a proxy to + * reach Anthropic or a provider has it for Claude Code itself, so the gateway has to + * honor it too. True when the process is configured for a proxy but not yet using it. + */ +export function needsEnvProxy(env: NodeJS.ProcessEnv): boolean { + return ( + env.NODE_USE_ENV_PROXY === undefined && + ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((name) => Boolean(env[name])) + ); +} diff --git a/plugins/multi-core/src/launcher.ts b/plugins/multi-core/src/launcher.ts index 6e62598..4f8df42 100644 --- a/plugins/multi-core/src/launcher.ts +++ b/plugins/multi-core/src/launcher.ts @@ -2,7 +2,7 @@ import { execFile, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { realpathSync } from 'node:fs'; -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -63,7 +63,6 @@ import { zenPickerOptions, } from '../../multi-zen/src/models.ts'; import { zenUsageReader } from '../../multi-zen/src/usage-adapter.ts'; -import { AgentCatalog } from './gateway/agent-catalog.ts'; import { loadWorkerPermissions, type PluginPermissionInventory, @@ -74,6 +73,7 @@ import { DISPLAY_TOOL_SERVER } from './gateway/display-rows.ts'; import { executableInvocation, resolveExecutable } from './gateway/executable.ts'; import { ModBridge } from './gateway/mod-bridge.ts'; import { PermissionModes } from './gateway/mode-hook.ts'; +import { nestedClaudeShim, withPathPrefix } from './gateway/nested-env.ts'; import type { ProviderUsageReader } from './gateway/provider-usage.ts'; import { ReceiptLedger } from './gateway/receipts.ts'; import type { GatewayEvent } from './gateway/server.ts'; @@ -86,6 +86,7 @@ import { } from './gateway/worker-catalog.ts'; import { providerSelection } from './install/plugins.ts'; +import { needsEnvProxy, rememberOriginalEnvironment } from './install/process.ts'; const enabledProviders = providerSelection(process.env.MULTI_ENABLED_PROVIDERS); const providerEnabled = (provider: string) => @@ -134,8 +135,8 @@ interface LaunchSettings { async function main() { const args = process.argv.slice(2); await handleCommand(args[0]); - const { cursorModels, cursorSignedIn } = await discoverCursor(args[0] === '--cursor-models'); if (args[0] === '--cursor-models') { + const { cursorModels, cursorSignedIn } = await discoverCursor(true); printCursorModels(cursorModels, cursorSignedIn); return; } @@ -143,20 +144,33 @@ async function main() { args.shift(); } validateSessionLaunch(args); - const pluginInventory = await pluginPermissions(process.cwd(), args); + warnIfProxyUnused(); const pluginRoot = await findPluginRoot(fileURLToPath(import.meta.url)); await assertFunctionHooksSupported(); - const anthropic = await anthropicSignedIn(); const fullCatalog = process.env.MULTI_MODELS !== undefined; - const cursorPicker = selectedCursorOptions(cursorModels, fullCatalog); const authFile = path.join( process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'auth.json', ); - const { codexSignedIn, openaiReview } = await discoverOpenAI(authFile); - const zenKey = providerEnabled('zen') ? await readZenKey() : undefined; - const antigravityModels = await discoverAntigravity(); - const grokModels = await discoverGrok(); + // Independent probes run together: Cursor's model listing alone can take 15 s. + const [ + pluginInventory, + anthropic, + { cursorModels }, + { codexSignedIn, openaiReview }, + zenKey, + antigravityModels, + grokModels, + ] = await Promise.all([ + pluginInventoryOrEmpty(process.cwd(), args), + anthropicSignedIn(), + discoverCursor(false), + discoverOpenAI(authFile), + providerEnabled('zen') ? readZenKey() : Promise.resolve(undefined), + discoverAntigravity(), + discoverGrok(), + ]); + const cursorPicker = selectedCursorOptions(cursorModels, fullCatalog); const token = randomBytes(32).toString('hex'); const defaultModels = fullCatalog ? pickerSettings( @@ -176,9 +190,12 @@ async function main() { fullCatalog, ); await mergeSettings(args, settings); - // The supervisor does not transfer --agents or our session-local gateway env, - // and can outlive the child whose exit releases settingsDir and the gateway. - // Keep ordinary background subagent tasks available within this owned session. + // Agent view and background sessions are off for this session only, through the + // session's own settings: nested Claude runs do not read them, unlike an environment + // variable. The background supervisor does not carry --agents or this session's gateway + // environment, and outlives the child whose exit releases settingsDir and the gateway, + // so a session handed to it would lose its workers and its gateway. Ordinary background + // subagent tasks stay available inside this session. settings.disableAgentView = true; filterPicker(settings, process.env.MULTI_MODELS, defaultModels); const callerSettings = structuredClone(settings); @@ -236,13 +253,9 @@ async function main() { usageReaders, permissionModes, approvalBridge, - approvalProviders, + anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL || undefined, blockAnthropic: !anthropic, guardAuto: true, - agentCatalog: new AgentCatalog( - agents, - settings.modelPicker.options.map((option) => option.model), - ), onEvent: traceEvent, }); await new Promise((resolve, reject) => { @@ -261,11 +274,12 @@ async function main() { configureApproval(settings, approvalProviders, selectedModel, { antigravity, grok }, anthropic); await writeFile(settingsFile, JSON.stringify(settings), { mode: 0o600 }); const definitions = JSON.stringify(agents); - const childEnvironment = gatewayEnvironment(address.port, token, anthropic); + const baseEnvironment = gatewayEnvironment(address.port, token, anthropic); const claudePath = resolveExecutable('claude', { configuredPath: claudeExecutable, - env: childEnvironment, + env: baseEnvironment, }); + const childEnvironment = await withNestedClaudeShim(baseEnvironment, settingsDir, claudePath); const childArguments = launcherArguments( args, settingsFile, @@ -289,7 +303,7 @@ async function main() { await rm(settingsDir, { recursive: true, force: true }); throw error; } - const ready = awaitModSessionStart(); + const modStart = awaitModSessionStart(); const child = spawn(childInvocation.command, childInvocation.args, { stdio: 'inherit', env: childEnvironment, @@ -304,20 +318,19 @@ async function main() { await receipts.drain(); await rm(settingsDir, { recursive: true, force: true }); }; - try { - await ready; - } catch (error) { - child.kill(); - await shutdown(); - throw error; - } + // Attached before anything is awaited: a Claude that cannot start, or that exits at + // once (--version, an argument error), must end the launcher with its own exit code + // rather than after the mod's acknowledgement times out. + const exited = new Promise((resolve) => { + child.once('exit', (code) => resolve(code ?? 1)); + }); child.once('error', (error) => { + modStart.cancel(); console.error(error.message); void shutdown().finally(() => process.exit(1)); }); - child.once('exit', (code) => { - void shutdown().finally(() => process.exit(code ?? 1)); - }); + await awaitStartOrExit(modStart, exited, child, shutdown); + void exited.then((code) => shutdown().finally(() => process.exit(code))); if (process.platform === 'win32') { // Windows console control events do not provide POSIX process groups. Claude's // child owns Ctrl+C handling; forward termination explicitly to its process tree. @@ -363,15 +376,11 @@ function validateSessionLaunch(args: string[]) { args.some((arg) => arg === '--bg' || arg === '--background') || ['attach', 'respawn'].includes(args[0] ?? '') ) { + // The background supervisor does not carry the gateway environment or --agents. throw new Error( 'Multi sessions must stay attached to their launcher. Exit and use --resume to continue with a fresh gateway; whole-session background handoff is unsupported.', ); } - if (process.env.ANTHROPIC_BASE_URL) { - throw new Error( - 'Start without ANTHROPIC_BASE_URL; this launcher supplies the central gateway.', - ); - } if (args.some((arg) => arg === '--agents' || arg.startsWith('--agents='))) { throw new Error('This launcher supplies --agents; use agent files for additional agents.'); } @@ -588,22 +597,54 @@ function atLeastVersion(actual: number[], required: number[]): boolean { */ const modSessionStartTimeoutMs = Number(process.env.MULTI_MOD_START_TIMEOUT_MS ?? 30000); -function awaitModSessionStart(): Promise { - return new Promise((resolve, reject) => { +/** + * Wait for the mod's acknowledgement, or for a Claude that exits first: that ends the + * launch with Claude's own exit code. A timeout is the launcher's error. + */ +async function awaitStartOrExit( + modStart: { ready: Promise; cancel: () => void }, + exited: Promise, + child: { kill: () => boolean }, + shutdown: () => Promise, +) { + try { + const earlyExit = await Promise.race([modStart.ready.then(() => undefined), exited]); + if (earlyExit !== undefined) { + modStart.cancel(); + await shutdown(); + process.exit(earlyExit); + } + } catch (error) { + child.kill(); + await shutdown(); + throw error; + } +} + +function awaitModSessionStart(): { ready: Promise; cancel: () => void } { + let cancel = () => {}; + const ready = new Promise((resolve, reject) => { const timer = setTimeout(() => { - process.off('multi-mod-session-start', ready); + process.off('multi-mod-session-start', acknowledged); reject( new Error( 'Claude Code 2.1.272 or newer with loaded function hooks is required; the Multi mod did not acknowledge session.start.', ), ); }, modSessionStartTimeoutMs); - const ready = () => { + const acknowledged = () => { clearTimeout(timer); resolve(); }; - process.once('multi-mod-session-start', ready); + cancel = () => { + clearTimeout(timer); + process.off('multi-mod-session-start', acknowledged); + }; + process.once('multi-mod-session-start', acknowledged); }); + // A cancelled wait never settles; a timed-out one is handled by whoever awaits it. + ready.catch(() => {}); + return { ready, cancel }; } async function anthropicSignedIn(): Promise { @@ -1139,10 +1180,15 @@ function pickerSettings( } /** - * CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC also blocks the plugin worker's - * loopback call to this gateway, which the Mods control plane requires. Keep - * the user's intent (no updater, telemetry or error reports) with the narrower - * flags instead of silently running without the mod. + * CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC also blocks the plugin worker's loopback call + * to this gateway, which the Mods control plane requires (an end-to-end check on + * Claude Code 2.1.283 showed the broad flag blocks the function-hook fetch while the + * narrow flags do not). Keep the user's intent (no updater, telemetry, error reports or + * feedback command) with the narrower flags instead of silently running without the mod. + * + * This is not about GrowthBook. The broad flag and DISABLE_TELEMETRY both turn it off, + * and on Claude Code 2.1.287 the Mods rollout flag `tengu_plugin_hooks_modules` defaults + * to on without it, so the replacement flags leave the mod loading. */ function translateTrafficPolicy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC === undefined) { @@ -1150,7 +1196,7 @@ function translateTrafficPolicy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { } const { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: _flag, ...rest } = env; process.stderr.write( - 'Multi: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC would block the local gateway; using DISABLE_AUTOUPDATER, DISABLE_TELEMETRY, DISABLE_ERROR_REPORTING and DISABLE_BUG_COMMAND instead.\n', + 'Multi: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC would block the local gateway; using DISABLE_AUTOUPDATER, DISABLE_TELEMETRY, DISABLE_ERROR_REPORTING, DISABLE_BUG_COMMAND and DISABLE_FEEDBACK_COMMAND instead.\n', ); return { ...rest, @@ -1158,6 +1204,7 @@ function translateTrafficPolicy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1', DISABLE_BUG_COMMAND: '1', + DISABLE_FEEDBACK_COMMAND: '1', }; } @@ -1166,13 +1213,17 @@ function gatewayEnvironment(port: number, token: string, anthropic: boolean) { delete env.OPENCODE_API_KEY; return { ...env, - CLAUDE_CODE_DISABLE_AGENT_VIEW: '1', + // What nested Claude runs get back in place of the gateway (see nested-env.ts). + ...rememberOriginalEnvironment(process.env), ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, - // A custom base URL disables Claude's on-demand tool loading unless opted in. - // We forward Claude tool references, so match the direct default of always - // deferring; 'auto' loads every tool once a 1M window makes them under 10%. + // A custom base URL makes Claude Code skip on-demand tool loading unless it is told + // otherwise, and a gateway base URL is always custom. Claude's own default against + // Anthropic is to defer tools always, which is what 'true' says; the user's own + // setting is kept. 'auto' would instead load every tool once a 1M window makes them + // under 10%. ENABLE_TOOL_SEARCH: process.env.ENABLE_TOOL_SEARCH ?? 'true', MULTI_GATEWAY_TOKEN: token, + // Claude Code 2.1.287 and newer has Mods on by default; 2.1.272 to 2.1.286 need this. CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: '1', MULTI_MOD_GATEWAY_URL: `http://127.0.0.1:${port}`, ...(!anthropic ? { ANTHROPIC_AUTH_TOKEN: token } : {}), @@ -1185,6 +1236,48 @@ function gatewayEnvironment(port: number, token: string, anthropic: boolean) { }; } +/** + * A `claude` run by Claude's own tools (Bash `claude -p`, scripts) inherits the session's + * gateway environment and would route through a gateway that is not its own. Put a + * `claude` on the session's PATH that runs the real executable without it. + */ +async function withNestedClaudeShim( + env: NodeJS.ProcessEnv, + settingsDir: string, + claude: string, +): Promise { + const directory = path.join(settingsDir, 'bin'); + const shim = nestedClaudeShim({ + node: process.execPath, + script: fileURLToPath(new URL('./install/nested-claude.ts', import.meta.url)), + claude, + }); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, shim.file), shim.content, { mode: 0o755 }); + return withPathPrefix(env, directory); +} + +/** A proxy the user has configured is honored only if Node was started to use it. */ +function warnIfProxyUnused() { + if (needsEnvProxy(process.env)) { + process.stderr.write( + 'Multi: HTTP_PROXY/HTTPS_PROXY is set but Node ignores it for the gateway; start the launcher with NODE_USE_ENV_PROXY=1 (the installed command does).\n', + ); + } +} + +/** A plugin inventory that cannot be read means no plugin workers, not no session. */ +async function pluginInventoryOrEmpty(cwd: string, args: readonly string[]) { + try { + return await pluginPermissions(cwd, args); + } catch (error) { + process.stderr.write( + `Multi: could not list Claude plugins (${error instanceof Error ? error.message.split('\n', 1)[0] : String(error)}); plugin-provided workers are unavailable.\n`, + ); + return { permissions: {}, multiCoreEnabled: false } satisfies PluginPermissionInventory; + } +} + function traceEvent(event: GatewayEvent) { if (process.env.MULTI_NATIVE_TRACE === '1') { process.stderr.write(`[native] ${JSON.stringify(event)}\n`); diff --git a/test/unit/native-launcher-exit.test.ts b/test/unit/native-launcher-exit.test.ts new file mode 100644 index 0000000..bf68b4e --- /dev/null +++ b/test/unit/native-launcher-exit.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { removeTemporary } from '../temporary.ts'; + +test('a Claude that exits before the mod acknowledges ends the launch with its own exit code', { + skip: process.platform === 'win32', +}, async (t) => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'launcher-early-exit-')); + t.after(() => removeTemporary(cwd)); + await mkdir(path.join(cwd, 'bin')); + await writeFile( + path.join(cwd, 'bin', 'claude'), + `#!/usr/bin/env node +const args=process.argv.slice(2); +if(args.includes('plugin')&&args.includes('list')){console.log('not json');process.exit(1)} +if(args[0]==='--version'){console.log('2.1.287');process.exit(0)} +if(args[0]==='auth'){console.log(JSON.stringify({loggedIn:true}));process.exit(0)} +process.exit(7); +`, + { mode: 0o755 }, + ); + const launcher = fileURLToPath( + new URL('../../plugins/multi-core/src/launcher.ts', import.meta.url), + ); + const started = Date.now(); + const result = await promisify(execFile)(process.execPath, [launcher, '--', '--version'], { + cwd, + timeout: 20000, + env: { + PATH: path.join(cwd, 'bin') + path.delimiter + process.env.PATH, + HOME: cwd, + CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'), + CODEX_HOME: cwd, + MULTI_ENABLED_PROVIDERS: '', + ANTHROPIC_BASE_URL: 'https://corp.example/anthropic', + }, + }).then( + () => ({ code: 0, stderr: '' }), + (error: { code: number; stderr: string }) => ({ code: error.code, stderr: error.stderr }), + ); + assert.equal(result.code, 7); + assert(Date.now() - started < 15000, 'did not wait for the acknowledgement timeout'); + assert(!result.stderr.includes('did not acknowledge')); + // A failing plugin listing does not abort the launch; a caller base URL is accepted. + assert.match(result.stderr, /could not list Claude plugins/); +}); diff --git a/test/unit/native-launcher.test.ts b/test/unit/native-launcher.test.ts index 13a986d..2a9a09e 100644 --- a/test/unit/native-launcher.test.ts +++ b/test/unit/native-launcher.test.ts @@ -6,7 +6,6 @@ import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import { AgentCatalog } from '../../plugins/multi-core/src/gateway/agent-catalog.ts'; import { checkLauncherArgumentLimit, workerCatalog, @@ -56,7 +55,7 @@ if(args[0]==='--version'){console.log(process.env.TEST_CLAUDE_VERSION??'2.1.272' const result=(value)=>{const base=process.env.MULTI_MOD_GATEWAY_URL;if(!base){console.log(value);return}const url=new URL(base+'/multi/mod/session');const req=require('node:http').request(url,{method:'POST',headers:{'content-type':'application/json','x-multi-gateway-token':process.env.MULTI_GATEWAY_TOKEN}},()=>console.log(value));req.on('error',()=>console.log(value));req.end(JSON.stringify({sessionId:'fixture',event:'start'}));}; if(args[0]==='auth'){if(process.env.TEST_AUTH==='malformed'){console.log('not-json');process.exit(0)}if(process.env.TEST_AUTH==='error'){process.exit(2)}if(process.env.TEST_AUTH==='missing'){console.log('{}');process.exit(0)}process.stdout.write(JSON.stringify({loggedIn:process.env.TEST_AUTH==='yes'}));process.exitCode=process.env.TEST_AUTH==='yes'?0:1}else{ const settings=JSON.parse(fs.readFileSync(args[args.indexOf('--settings')+1],'utf8')); - result(JSON.stringify({agentView:process.env.CLAUDE_CODE_DISABLE_AGENT_VIEW,backgroundTasks:process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS,functionHooks:process.env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS,settings,models:args.filter(x=>x.startsWith('multi/')),args,settingsCount:args.filter(x=>x==='--settings').length,hasLocalToken:!!process.env.MULTI_GATEWAY_TOKEN,apiTimeout:process.env.API_TIMEOUT_MS,toolSearch:process.env.ENABLE_TOOL_SEARCH,auth:process.env.ANTHROPIC_API_KEY?'api':process.env.ANTHROPIC_AUTH_TOKEN?'local':'native'}));} + result(JSON.stringify({firstPath:(process.env.PATH||'').split(require('node:path').delimiter)[0],agentView:process.env.CLAUDE_CODE_DISABLE_AGENT_VIEW,backgroundTasks:process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS,functionHooks:process.env.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS,settings,models:args.filter(x=>x.startsWith('multi/')),args,settingsCount:args.filter(x=>x==='--settings').length,hasLocalToken:!!process.env.MULTI_GATEWAY_TOKEN,apiTimeout:process.env.API_TIMEOUT_MS,toolSearch:process.env.ENABLE_TOOL_SEARCH,auth:process.env.ANTHROPIC_API_KEY?'api':process.env.ANTHROPIC_AUTH_TOKEN?'local':'native'}));} `, ); const launcher = fileURLToPath( @@ -99,7 +98,12 @@ const settings=JSON.parse(fs.readFileSync(args[args.indexOf('--settings')+1],'ut assert.equal(result.args.at(-1), path.resolve(path.dirname(launcher), '../../..')); assert.equal(result.settingsCount, 1); assert.equal(result.settings.disableAgentView, true); - assert.equal(result.agentView, '1'); + assert.equal( + result.agentView, + '0', + 'the setting scopes agent view; the environment is left alone', + ); + assert.match(result.firstPath, /multi-native-settings-[^/\\]+[/\\]bin$/); assert.equal(result.backgroundTasks, undefined); assert.equal(result.functionHooks, '1'); assert.equal( @@ -497,27 +501,6 @@ result(JSON.stringify({settings,agents,args,models:args.filter(x=>x.startsWith(' assert.equal(agents['multi-antigravity'].model, rows[0].model); assert.equal(agents['multi-antigravity'].effort, undefined); assert.match(agents['multi-antigravity'].description, /\(gemini, sonnet-thinking\)/); - const catalog = new AgentCatalog( - agents, - rows.map(({ model }) => model), - ); - const listing = Object.entries(agents) - .map(([name, value]) => { - const worker = value as { description: string; tools: string[] }; - return `- ${name}: ${worker.description} (Tools: ${worker.tools.join(', ')})`; - }) - .join('\n'); - const compacted = JSON.stringify( - catalog.compact({ - messages: [ - { - role: 'user', - content: `\nAvailable agent types for the Agent tool:\n${listing}\n`, - }, - ], - }), - ); - assert.match(compacted, /- multi-antigravity:/); // The opt-out has to stay reversible. A selection saved while rows were tagged is the // spelling the user copied out of the picker, so it must still name a row once the tag diff --git a/test/unit/native-session-isolation.test.ts b/test/unit/native-session-isolation.test.ts new file mode 100644 index 0000000..a682047 --- /dev/null +++ b/test/unit/native-session-isolation.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import type { TestContext } from 'node:test'; +import test from 'node:test'; +import { + nestedClaudeShim, + withPathPrefix, +} from '../../plugins/multi-core/src/gateway/nested-env.ts'; +import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; +import { + needsEnvProxy, + rememberOriginalEnvironment, + withoutGateway, +} from '../../plugins/multi-core/src/install/process.ts'; + +test('a nested Claude run gets the session start environment back, not the gateway', () => { + const original = { + ANTHROPIC_BASE_URL: 'https://corp.example', + ANTHROPIC_CUSTOM_HEADERS: 'x-team: a', + }; + const session: NodeJS.ProcessEnv = { + ...rememberOriginalEnvironment(original), + ANTHROPIC_BASE_URL: 'http://127.0.0.1:1', + ANTHROPIC_CUSTOM_HEADERS: 'x-team: a\nx-multi-gateway-token: tok', + ANTHROPIC_AUTH_TOKEN: 'tok', + MULTI_GATEWAY_TOKEN: 'tok', + MULTI_MOD_GATEWAY_URL: 'http://127.0.0.1:1', + KEEP: 'yes', + }; + assert.deepEqual(withoutGateway(session), { + ANTHROPIC_BASE_URL: 'https://corp.example', + ANTHROPIC_CUSTOM_HEADERS: 'x-team: a', + KEEP: 'yes', + }); + // A caller with no base URL of its own ends with none. + const bare = withoutGateway({ + ...rememberOriginalEnvironment({}), + ANTHROPIC_BASE_URL: 'http://127.0.0.1:1', + MULTI_GATEWAY_TOKEN: 'tok', + }); + assert.deepEqual(bare, {}); + const plain = { ANTHROPIC_BASE_URL: 'https://x' }; + assert.equal(withoutGateway(plain), plain); +}); + +test('the nested claude shim quotes its paths and goes first on PATH', () => { + const posix = nestedClaudeShim({ + platform: 'linux', + node: "/opt/no'de", + script: '/p/nested-claude.ts', + claude: '/c/claude', + }); + assert.equal(posix.file, 'claude'); + assert.equal( + posix.content, + `#!/bin/sh\nexec '/opt/no'\\''de' '/p/nested-claude.ts' '/c/claude' "$@"\n`, + ); + assert.equal( + nestedClaudeShim({ platform: 'win32', node: 'n', script: 's', claude: 'c' }).file, + 'claude.cmd', + ); + assert.equal(withPathPrefix({ PATH: '/a' }, '/s', 'linux').PATH, '/s:/a'); + assert.equal(withPathPrefix({ Path: 'C:\\a' }, 'C:\\s', 'win32').Path, 'C:\\s;C:\\a'); +}); + +test('a proxy in the environment needs Node started with NODE_USE_ENV_PROXY', () => { + assert.equal(needsEnvProxy({ HTTPS_PROXY: 'http://p' }), true); + assert.equal(needsEnvProxy({ https_proxy: 'http://p', NODE_USE_ENV_PROXY: '1' }), false); + assert.equal(needsEnvProxy({}), false); +}); + +async function listen(t: TestContext) { + const server = createNativeGateway({ + token: 'tok', + authFile: '/unused', + guardAuto: true, + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert(address && typeof address !== 'string'); + return (session: string, key?: string) => + fetch(`http://127.0.0.1:${address.port}/multi/mod/session`, { + method: 'POST', + headers: { 'x-multi-gateway-token': 'tok', ...(key ? { 'x-multi-mod-key': key } : {}) }, + body: JSON.stringify({ sessionId: session, permissionMode: 'default' }), + }); +} + +test('once the mod presents its session key, the token alone cannot change that session', async (t) => { + const post = await listen(t); + const first = await post('s1'); + const key = first.headers.get('x-multi-mod-key'); + assert(key, 'the first request for a session is handed a key'); + // Until the mod echoes it, requests stay admitted (a mod without the protocol works). + assert.notEqual((await post('s1')).status, 403); + assert.notEqual((await post('s1', key)).status, 403); + // Confirmed: a caller holding only the token, as a Bash `curl` does, is refused. + const bare = await post('s1'); + assert.equal(bare.status, 403); + assert.equal(bare.headers.get('x-multi-mod-key'), null); + assert.equal((await post('s1', 'wrong')).status, 403); + assert.notEqual((await post('s1', key)).status, 403); + // Another session has its own key. + assert.notEqual((await post('s2')).status, 403); +}); From e13a224c3bc3fd5c43b2373c45c81bc1f0d5be2b Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:01:46 -0400 Subject: [PATCH 19/36] Make the gateway transparent to native Claude requests Claude's own classifier goes to Anthropic unless a provider action matches, non-streamed provider replies keep their connection alive, raw routes cover every non-Multi path, bodyless requests stay bodyless, encodings are kept, observation is bounded per line, failures leave a diagnostic, the mod control plane requires a per-session key once the mod holds one, and the dead agent catalog rewriting is removed. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .../multi-core/src/gateway/agent-catalog.ts | 89 ----- plugins/multi-core/src/gateway/mod-keys.ts | 61 ++++ plugins/multi-core/src/gateway/server.ts | 328 +++++++++++++----- .../multi-core/src/gateway/tool-observer.ts | 114 ++++-- test/live/native-openai-cache.ts | 5 - test/unit/native-agent-catalog.test.ts | 110 ------ test/unit/native-approval.test.ts | 53 ++- test/unit/native-gateway-transport.test.ts | 211 +++++++++++ test/unit/native-gateway.test.ts | 52 +-- test/unit/native-zen-gateway.test.ts | 1 - 10 files changed, 633 insertions(+), 391 deletions(-) delete mode 100644 plugins/multi-core/src/gateway/agent-catalog.ts create mode 100644 plugins/multi-core/src/gateway/mod-keys.ts delete mode 100644 test/unit/native-agent-catalog.test.ts create mode 100644 test/unit/native-gateway-transport.test.ts diff --git a/plugins/multi-core/src/gateway/agent-catalog.ts b/plugins/multi-core/src/gateway/agent-catalog.ts deleted file mode 100644 index 6ad8893..0000000 --- a/plugins/multi-core/src/gateway/agent-catalog.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { MessagesRequest } from './messages.ts'; - -interface Worker { - model: string; - description: string; - tools: string[]; -} - -const record = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -/** Registration is unchanged: only exact Multi-owned announcement rows are hidden. */ -export class AgentCatalog { - private hidden = new Set(); - - constructor(workers: Record, models: readonly string[]) { - const remaining = new Set(models); - for (const [name, worker] of Object.entries(workers)) { - if (remaining.delete(worker.model)) { - continue; - } - this.hidden.add(`- ${name}: ${worker.description} (Tools: ${worker.tools.join(', ')})`); - } - } - - compact(body: MessagesRequest): MessagesRequest { - if (!Array.isArray(body.messages)) { - return body; - } - let changed = false; - const messages = body.messages.map((message) => { - if (!record(message) || !['user', 'system'].includes(message.role)) { - return message; - } - const content = this.content(message.content, message.role === 'system'); - if (content === message.content) { - return message; - } - changed = true; - return { ...message, content }; - }); - return changed ? { ...body, messages } : body; - } - - private content(content: string | NonNullable, system: boolean) { - if (typeof content === 'string') { - return this.text(content, system); - } - if (!Array.isArray(content)) { - return content; - } - let changed = false; - const blocks = content.map((block) => { - if (!record(block) || block.type !== 'text' || typeof block.text !== 'string') { - return block; - } - const text = this.text(block.text, system); - if (text === block.text) { - return block; - } - changed = true; - return { ...block, text }; - }); - return changed ? blocks : content; - } - - private text(text: string, system: boolean): string { - // Native CLI announcements may be bare system messages or wrapped user reminders. - if (system) { - return this.section(text); - } - return text.replace(/[\s\S]*?<\/system-reminder>/g, (reminder) => - this.section(reminder), - ); - } - - private section(text: string): string { - return text.replace( - /^(Available agent types for the Agent tool:|New agent types are now available for the Agent tool:)\n((?:- [^\n]*(?:\n|$))+)/gm, - (section: string, heading: string, rows: string) => { - const kept = rows - .split('\n') - .filter((line) => !this.hidden.has(line)) - .join('\n'); - return kept === rows ? section : `${heading}\n${kept}`; - }, - ); - } -} diff --git a/plugins/multi-core/src/gateway/mod-keys.ts b/plugins/multi-core/src/gateway/mod-keys.ts new file mode 100644 index 0000000..beb0507 --- /dev/null +++ b/plugins/multi-core/src/gateway/mod-keys.ts @@ -0,0 +1,61 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; + +// --------------------------------------------------------------------------- +// The gateway token is in the environment of every command Claude runs, so an +// approved `curl` can call the mod control plane with it, including routes that +// set the permission snapshot native harnesses obey. The mod therefore also +// holds a per-session key the gateway mints on the first mod request it sees +// for that session and returns in a response header. The mod keeps it in +// memory, never in the environment, and presents it on every later request. +// +// A session starts enforcing once the mod has presented its key, which any +// mod build that follows the header protocol does with its next request; a +// build that does not never presents one and is never refused. Before that +// first echo a session's requests are admitted, which is the milliseconds +// between the mod's own first request and its second: no tool has run yet. +// --------------------------------------------------------------------------- + +export const MOD_KEY_HEADER = 'x-multi-mod-key'; +const MAX_SESSIONS = 1024; + +interface Entry { + key: string; + confirmed: boolean; +} + +export type ModKeyVerdict = { refused: true } | { refused: false; issue?: string }; + +export class ModSessionKeys { + private readonly entries = new Map(); + + /** Admit or refuse one mod request for `session`; `issue` is a key to return to the mod. */ + check(session: string, presented: string | undefined): ModKeyVerdict { + const entry = this.entries.get(session) ?? this.mint(session); + if (presented === undefined) { + return entry.confirmed ? { refused: true } : { refused: false, issue: entry.key }; + } + if (!matches(presented, entry.key)) { + return { refused: true }; + } + entry.confirmed = true; + return { refused: false }; + } + + private mint(session: string): Entry { + if (this.entries.size >= MAX_SESSIONS) { + const oldest = this.entries.keys().next(); + if (!oldest.done) { + this.entries.delete(oldest.value); + } + } + const entry = { key: randomBytes(24).toString('hex'), confirmed: false }; + this.entries.set(session, entry); + return entry; + } +} + +function matches(actual: string, expected: string): boolean { + const a = Buffer.from(actual); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} diff --git a/plugins/multi-core/src/gateway/server.ts b/plugins/multi-core/src/gateway/server.ts index bd7cb91..646cbe2 100644 --- a/plugins/multi-core/src/gateway/server.ts +++ b/plugins/multi-core/src/gateway/server.ts @@ -12,7 +12,6 @@ import { forAnthropic, fromResponses, toResponses } from '../../../multi-openai/ import { validateZenKey } from '../../../multi-zen/src/auth.ts'; import { fromChat } from '../../../multi-zen/src/chat.ts'; import { zenRequest } from '../../../multi-zen/src/request.ts'; -import type { AgentCatalog } from './agent-catalog.ts'; import type { ApprovalContext, NativeApprovalBridge } from './approval.ts'; import { approvalCwdForComparison, isApprovalRequest, parseApprovalRequest } from './approval.ts'; import { @@ -31,6 +30,7 @@ import type { NativeObservation } from './harness-progress.ts'; import type { Emit, MessagesRequest, MessagesResponse, StopReason } from './messages.ts'; import { ModBridge } from './mod-bridge.ts'; import { ModCompactions } from './mod-compaction.ts'; +import { MOD_KEY_HEADER, ModSessionKeys } from './mod-keys.ts'; import { handleModRoute } from './mod-routes.ts'; import type { PermissionContext, PermissionModes } from './mode-hook.ts'; import { type NativeHarness, nativeHarnessErrorStatus } from './native-harness.ts'; @@ -43,7 +43,7 @@ import { forwardObservedTools, ToolObserver } from './tool-observer.ts'; import { originalToolNames } from './tools.ts'; const OPENAI_URL = 'https://chatgpt.com/backend-api/codex/responses'; -const ANTHROPIC_URL = 'https://api.anthropic.com'; +const DEFAULT_ANTHROPIC_URL = 'https://api.anthropic.com'; // Anthropic's own Messages API limit is 32 MB; the gateway buffers up to it and leaves // the verdict to the provider, so a request Claude Code could send natively still goes. const MAX_BODY = 32 * 1024 * 1024; @@ -98,6 +98,8 @@ export interface GatewayEvent { endpoint?: string; /** Claude session that owns the request, when the client identified one. */ session?: string; + /** Why a request failed inside the gateway; never a credential or a body. */ + diagnostic?: string; } export interface GatewayOptions { @@ -108,6 +110,10 @@ export interface GatewayOptions { Record<'openai' | 'cursor' | 'zen' | 'antigravity' | 'grok', ProviderUsageReader> >; token: string; + /** How long a non-streamed provider reply may stay silent before its headers go out. */ + jsonKeepAliveMs?: number; + /** Where Claude's own traffic goes: Anthropic by default, or the caller's own base URL. */ + anthropicBaseUrl?: string; enabledProviders?: readonly string[]; authFile: string; fetchImpl?: GatewayFetch; @@ -119,12 +125,10 @@ export interface GatewayOptions { zen?: { apiKey: string }; /** OpenAI review for GPT-originated actions, independent of Claude authentication. */ approvalBridge?: Pick; - approvalProviders?: readonly ('openai' | 'cursor')[]; /** No Anthropic credentials: also block passthrough if no reviewer is available. */ blockAnthropic?: boolean; guardAuto?: boolean; permissionModes?: PermissionModes; - agentCatalog?: AgentCatalog; modBridge?: ModBridge; /** Display rows for native harness actions; the native tool names each harness is known to have. */ displayRows?: DisplayRows; @@ -184,10 +188,14 @@ interface ProviderRequest { emit: Emit; remember: (tool: { id: string; name: string; input: unknown }) => void; startStream: () => void; + /** Arms the non-streamed keepalive before a slow upstream wait; a stream needs none. */ + keepAlive: () => void; } export function createNativeGateway({ token, + anthropicBaseUrl = DEFAULT_ANTHROPIC_URL, + jsonKeepAliveMs = 30000, enabledProviders, authFile, fetchImpl = fetch, @@ -198,11 +206,9 @@ export function createNativeGateway({ grok, zen, approvalBridge, - approvalProviders: _approvalProviders = approvalBridge ? ['openai'] : [], blockAnthropic, guardAuto, permissionModes, - agentCatalog, modBridge = new ModBridge(), displayRows = new DisplayRows(), displayTools = {}, @@ -243,6 +249,9 @@ export function createNativeGateway({ if (zen) { validateZenKey(zen.apiKey); } + const anthropicOrigin = parseAnthropicBase(anthropicBaseUrl); + const anthropicUrl = (url: URL) => anthropicOrigin + url.pathname + url.search; + const modKeys = new ModSessionKeys(); const fallbackSession = randomUUID(); // Offer the mod rows only for the harnesses this gateway runs. for (const [provider, names] of Object.entries(displayTools)) { @@ -256,23 +265,6 @@ export function createNativeGateway({ string, { tool: PendingApprovalTool; context: ApprovalContext } >(); - function nativeReviewSession(session: unknown) { - if (blockAnthropic || typeof session !== 'string' || !session) { - return false; - } - const contexts = [...approvalContexts.values()].filter((context) => { - try { - return JSON.parse(context.scope)[0] === session; - } catch { - return false; - } - }); - // A native classifier may pass through only when this session has no - // provider-owned review context. Per-action routing below handles mixed - // sessions; this fallback covers native Claude's classifier retries that - // carry no correlatable tool action. - return contexts.length > 0 && contexts.every((context) => !providerOwnedReview(context.model)); - } const matchesBashAction = ( candidate: { tool: PendingApprovalTool; context: ApprovalContext }, action: unknown, @@ -369,14 +361,13 @@ export function createNativeGateway({ const model = request.context.model; const provider = model?.split('/')[1]; const harness = provider === 'cursor' ? cursor : grokOrAntigravity(provider); - const native = harness; - if (!native || !model) { + if (!harness || !model) { throw new Error('Precomputed summaries require a native harness model'); } assertProviderEnabled(model, enabledProviders); // Use a separate native record. A speculative summary never advances or rewinds // the originating run and never receives native tool capabilities. - const result = await native.handle( + const result = await harness.handle( { model, max_tokens: 3000, @@ -434,7 +425,6 @@ export function createNativeGateway({ throw refuseHarness(exchange, provider, new BadRequest(unavailable[provider])); } const scope = harnessScope(exchange); - const nativeBody = exchange.body; let inputTokens: number; try { inputTokens = validateHarness(exchange, provider, bridge); @@ -464,7 +454,7 @@ export function createNativeGateway({ }; const result = await bridge .handle( - nativeBody, + body, scope, signal, body.stream ? emit : undefined, @@ -473,6 +463,12 @@ export function createNativeGateway({ ) .catch((error: unknown) => { modBridge.complete(scope, signal.aborted ? 'cancelled' : 'failed', run, reason(error)); + // A failed compaction turn must not leave its scope tool-free. + permissionModes?.finishModCompaction( + identity.session, + agentId, + exchange.permissionContext?.compaction, + ); throw error; }); permissionModes?.finishModCompaction( @@ -615,6 +611,7 @@ export function createNativeGateway({ agentId ?? header(req.headers['x-claude-code-session-id']) ?? fallbackSession, ), }; + exchange.keepAlive(); const upstream = await codexRequest(authFile, signal, (auth) => fetchImpl(OPENAI_URL, { method: 'POST', @@ -667,6 +664,7 @@ export function createNativeGateway({ return res.end(JSON.stringify({ input_tokens: prepared.inputTokens })); } onEvent({ route: 'zen-request', agentId, model: body.model }); + exchange.keepAlive(); const upstream = await fetchImpl(`https://opencode.ai/zen/v1/${prepared.endpoint}`, { method: 'POST', headers: { @@ -721,7 +719,7 @@ export function createNativeGateway({ forwarded = cleaned === body ? raw : Buffer.from(JSON.stringify(cleaned)); } try { - const upstream = await fetchImpl(ANTHROPIC_URL + url.pathname + url.search, { + const upstream = await fetchImpl(anthropicUrl(url), { method: req.method ?? 'GET', headers, body: forwarded, @@ -734,9 +732,12 @@ export function createNativeGateway({ res, signal, body.tools?.length ? exchange.remember : undefined, + body.model, ); - } catch { - // Anthropic failures are not rebranded: Claude Code sees a dropped connection. + } catch (error) { + // Anthropic failures are not rebranded: Claude Code sees a dropped connection. The + // reason still reaches the event stream, so a gateway bug is not silent. + onEvent({ route: 'anthropic', model: body.model, diagnostic: reason(error) }); res.destroy(); } } @@ -745,6 +746,7 @@ export function createNativeGateway({ res: http.ServerResponse, signal: AbortSignal, remember?: (tool: { id: string; name: string; input: unknown }) => void, + model?: unknown, ) { const responseHeaders = Object.fromEntries(upstream.headers); for (const name of STRIPPED_RESPONSE_HEADERS) { @@ -752,7 +754,9 @@ export function createNativeGateway({ } res.writeHead(upstream.status, responseHeaders); if (guardAuto && upstream.ok && remember) { - await forwardObservedTools(upstream, res, remember, signal); + await forwardObservedTools(upstream, res, remember, signal, (diagnostic) => + onEvent({ route: 'anthropic', model: String(model ?? ''), diagnostic }), + ); } else if (upstream.body) { await pipeline(Readable.fromWeb(upstream.body), res); } else { @@ -775,13 +779,13 @@ export function createNativeGateway({ throw new BadRequest('Anthropic is not signed in. Select an external model.'); } const method = req.method ?? 'GET'; - const hasBody = method !== 'GET' && method !== 'HEAD'; + const hasBody = requestHasBody(req); const headers = anthropicHeaders(req); - if (req.headers['content-length'] !== undefined) { + if (hasBody && req.headers['content-length'] !== undefined) { headers['content-length'] = String(req.headers['content-length']); } try { - const upstream = await fetchImpl(ANTHROPIC_URL + url.pathname + url.search, { + const upstream = await fetchImpl(anthropicUrl(url), { method, headers, body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined, @@ -791,7 +795,8 @@ export function createNativeGateway({ }); onEvent({ route: 'anthropic', status: upstream.status, model: undefined }); await relayAnthropic(upstream, res, abort.signal); - } catch { + } catch (error) { + onEvent({ route: 'anthropic', path: url.pathname, diagnostic: reason(error) }); res.destroy(); } } @@ -854,34 +859,50 @@ export function createNativeGateway({ res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(decision)); } + /** + * Claude's own classifier (auto mode, for Claude's own actions) goes to Anthropic. It + * leaves for provider review only when a provider-owned action observed in this session + * positively matches: observing Claude's stream is best effort, so a missing match + * proves nothing about who proposed the action. + */ + function classifyUnobserved( + exchange: ProviderRequest, + metadata: ReturnType, + ) { + // Without action observation there are no candidates to match: only a session whose + // retained context is Claude's own may pass to Anthropic. + const context = metadata.scope ? approvalContexts.get(metadata.scope) : undefined; + if (context && !providerOwnedReview(context.model) && !blockAnthropic) { + return handleAnthropic(exchange); + } + return dispatchReview(exchange, metadata, null); + } async function dispatchNativeClassification( exchange: ProviderRequest, metadata: ReturnType, ) { + if (!guardAuto) { + return classifyUnobserved(exchange, metadata); + } let candidates: ReturnType; try { candidates = reviewCandidatesFor(exchange.parsed, metadata.session); } catch (error) { - if (nativeReviewSession(metadata.session)) { - return handleAnthropic(exchange); + if (blockAnthropic) { + throw error; } - throw error; + return handleAnthropic(exchange); } - if (candidates.length === 1) { - const context = candidates[0].context; - if (providerOwnedReview(context.model)) { + if (!candidates.some(({ context }) => providerOwnedReview(context.model))) { + if (blockAnthropic) { return dispatchReview(exchange, metadata, null); } return handleAnthropic(exchange); } - if ( - candidates.length > 1 && - candidates.some(({ context }) => context.model.startsWith('multi/openai/')) - ) { - return dispatchReview(exchange, metadata, null); - } - if (nativeReviewSession(metadata.session)) { - return handleAnthropic(exchange); + // metadata.session picks the review candidates, so a header that disagrees with it + // could steer a provider action's review to another session. + if (metadata.mismatch) { + throw new BadRequest('Session header and metadata disagree'); } return dispatchReview(exchange, metadata, null); } @@ -1000,6 +1021,29 @@ export function createNativeGateway({ } return handleOpenAI(exchange, external); } + function handleMod( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + parsed: Record, + ) { + if (!admitModRequest(req, res, parsed, modKeys)) { + return undefined; + } + return handleModRoute( + req, + res, + url, + parsed, + modBridge, + permissionModes, + compactions, + receipts, + billedUsage, + dashboard, + displayRows, + ); + } return http.createServer(async (req, res) => { const abort = new AbortController(); res.on('close', () => { @@ -1007,7 +1051,6 @@ export function createNativeGateway({ abort.abort(); } }); - let heartbeat: NodeJS.Timeout | undefined; let sourceModel = ''; let sourceSession = ''; let sourceScope: string | undefined; @@ -1041,6 +1084,7 @@ export function createNativeGateway({ } } }; + const replies = new ReplyKeepAlive(res, (type) => emit(type, {}), jsonKeepAliveMs); const emit: Emit = (type, value) => { if (guardAuto) { observer.event({ type, ...value }); @@ -1055,22 +1099,10 @@ export function createNativeGateway({ if (!authorizeRequest(req, res, token, guardAuto)) { return; } - const request = await readRequest(req, agentCatalog); + const request = await readRequest(req); const { parsed } = request; if (url.pathname.startsWith('/multi/mod/')) { - return handleModRoute( - req, - res, - url, - parsed, - modBridge, - permissionModes, - compactions, - receipts, - billedUsage, - dashboard, - displayRows, - ); + return handleMod(req, res, url, parsed); } const { body, raw, followUp } = displayRowsRemoved(url, request); const external = externalModel(body.model); @@ -1099,14 +1131,8 @@ export function createNativeGateway({ agentId, emit, remember, - startStream: () => { - if (!body.stream) { - return; - } - res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); - res.flushHeaders(); - heartbeat = setInterval(() => emit('ping', {}), 15000); - }, + startStream: () => replies.startStream(body.stream), + keepAlive: () => replies.keepAlive(body.stream), }; if (url.pathname === '/multi/permission') { return sendPermissionDecision(exchange); @@ -1117,22 +1143,123 @@ export function createNativeGateway({ await dispatch(exchange, metadata, external); } catch (error) { abort.abort(); - failResponse(res, emit, error); + failResponse(res, emit, error, replies.jsonStarted); } finally { - clearInterval(heartbeat); + replies.stop(); } }); } class RequestTooLarge extends Error {} -/** Everything under /v1 except the Messages API the gateway parses and may route. */ +/** + * What keeps a provider reply's connection alive while it works. A stream sends its + * headers at once and a `ping` event every 15 s. Claude Code's request timeout only + * bounds the wait for response headers, and a timed-out request is retried: a run that + * is still working would then be started again. A non-streamed reply that is still + * pending after a while therefore sends its headers and keeps the connection alive with + * leading JSON whitespace, which any JSON parser skips before the message. Replies and + * failures that come sooner keep their own status; after that point a failure can only + * be an error body. + */ +class ReplyKeepAlive { + jsonStarted = false; + private timer: NodeJS.Timeout | undefined; + private readonly res: http.ServerResponse; + private readonly ping: (type: 'ping') => void; + private readonly delayMs: number; + + constructor(res: http.ServerResponse, ping: (type: 'ping') => void, delayMs: number) { + this.res = res; + this.ping = ping; + this.delayMs = delayMs; + } + + startStream(streamed: boolean | undefined) { + if (this.timer) { + return; + } + if (!streamed) { + this.keepAlive(streamed); + return; + } + this.res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + this.res.flushHeaders(); + this.timer = setInterval(() => this.ping('ping'), 15000); + } + + keepAlive(streamed: boolean | undefined) { + if (this.timer || streamed) { + return; + } + this.timer = setTimeout(() => { + this.jsonStarted = true; + this.res.writeHead(200, { 'content-type': 'application/json' }); + this.res.flushHeaders(); + this.timer = setInterval(() => this.res.write(' '), Math.min(15000, this.delayMs)); + }, this.delayMs); + } + + stop() { + clearInterval(this.timer); + } +} + +/** + * The gateway token alone does not authorize a mod request that changes a session: the + * token is in the environment of Claude's own tools. See mod-keys.ts. + */ +function admitModRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + parsed: Record, + keys: ModSessionKeys, +) { + if (req.method !== 'POST' || typeof parsed.sessionId !== 'string' || !parsed.sessionId) { + return true; + } + const verdict = keys.check(parsed.sessionId, header(req.headers[MOD_KEY_HEADER])); + if (verdict.refused) { + res.writeHead(403, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'Mod session key required' })); + return false; + } + if (verdict.issue) { + res.setHeader(MOD_KEY_HEADER, verdict.issue); + } + return true; +} + +/** + * Everything except the Messages API the gateway parses and may route, and its own + * /multi/* control routes, goes to Anthropic as raw bytes with the caller's headers. + */ function isRawAnthropicPath(pathName: string) { return ( - pathName.startsWith('/v1/') && !['/v1/messages', '/v1/messages/count_tokens'].includes(pathName) + !pathName.startsWith('/multi/') && + !['/v1/messages', '/v1/messages/count_tokens'].includes(pathName) + ); +} + +/** A request body is present when the client declared one, whatever the method. */ +function requestHasBody(req: http.IncomingMessage) { + if (req.method === 'GET' || req.method === 'HEAD') { + return false; + } + return ( + req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length'] ?? 0) > 0 ); } +/** The origin and path prefix Claude's traffic is forwarded to, without a trailing slash. */ +function parseAnthropicBase(value: string) { + const parsed = new URL(value); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('The Anthropic base URL must be http or https'); + } + return parsed.origin + parsed.pathname.replace(/\/+$/, ''); +} + /** * Display rows are Claude Code UI: no provider sees them as tools or history. A * request that only answers a reply's rows is marked as that reply's follow-up. @@ -1167,7 +1294,7 @@ function providerSignal(disconnected: AbortSignal, model: string | null, timeout return AbortSignal.any([disconnected, AbortSignal.timeout(timeoutMs)]); } -async function readRequest(req: http.IncomingMessage, catalog?: AgentCatalog) { +async function readRequest(req: http.IncomingMessage) { const chunks: Buffer[] = []; let size = 0; for await (const chunk of req) { @@ -1187,10 +1314,7 @@ async function readRequest(req: http.IncomingMessage, catalog?: AgentCatalog) { if (!isRecord(parsed) || (parsed.model !== undefined && typeof parsed.model !== 'string')) { throw new BadRequest('Expected an object with a string model'); } - const body: MessagesRequest = isApprovalRequest(parsed) - ? parsed - : (catalog?.compact(parsed) ?? parsed); - return { raw: body === parsed ? raw : Buffer.from(JSON.stringify(body)), parsed, body }; + return { raw, parsed, body: parsed as MessagesRequest }; } function providerRoute( @@ -1248,7 +1372,7 @@ function rememberResult(exchange: ProviderRequest, result: MessagesResponse) { } } function sendResult(exchange: ProviderRequest, result: MessagesResponse) { - if (!exchange.body.stream) { + if (!exchange.body.stream && !exchange.res.headersSent) { exchange.res.writeHead(200, { 'content-type': 'application/json' }); } exchange.res.end(exchange.body.stream ? undefined : JSON.stringify(result)); @@ -1289,9 +1413,11 @@ function requestIdentity( /* Unknown identity cannot grant auto capability. */ } } - // Session identity matters to provider routes. A Claude passthrough request is - // never refused for it: Anthropic owns that request. - if (provider && session && sessionHeader && session !== sessionHeader) { + // Session identity matters to provider routes and to classifier requests that go to + // provider review (checked at dispatch). A Claude passthrough request is never refused + // for it: Anthropic owns that request. + const mismatch = Boolean(session && sessionHeader && session !== sessionHeader); + if (provider && mismatch) { throw new BadRequest('Session header and metadata disagree'); } session = session || sessionHeader || ''; @@ -1299,6 +1425,7 @@ function requestIdentity( return { identity, session, + mismatch, scope: identity ? JSON.stringify([identity, agentId ?? 'main']) : undefined, }; } @@ -1358,10 +1485,24 @@ function anthropicHeaders(req: http.IncomingMessage) { headers[name] = single; } } - headers['accept-encoding'] = 'identity'; + headers['accept-encoding'] = decodableEncodings(req.headers['accept-encoding']); return headers; } +/** + * Node's fetch decodes gzip, deflate and br before the gateway sees a byte, so the + * observer and the relay always handle decoded bytes (the relay drops content-encoding + * and content-length). Keep the client's own preference for those, so Anthropic + * compresses as it would for Claude Code directly; anything else asks for identity. + */ +function decodableEncodings(value: string | string[] | undefined) { + const wanted = (header(value) ?? '') + .split(',') + .map((entry) => entry.trim().split(';')[0].toLowerCase()) + .filter((entry) => ['gzip', 'deflate', 'br'].includes(entry)); + return wanted.length ? wanted.join(', ') : 'identity'; +} + function authorizeRequest( req: http.IncomingMessage, res: http.ServerResponse, @@ -1379,7 +1520,6 @@ function authorizeRequest( ![ '/v1/messages', '/v1/messages/count_tokens', - '/api/hello', '/multi/mod/session', '/multi/mod/worker', '/multi/mod/worker-model', @@ -1409,7 +1549,12 @@ function authorizeRequest( return true; } -function failResponse(res: http.ServerResponse, emit: Emit, error: unknown) { +function failResponse( + res: http.ServerResponse, + emit: Emit, + error: unknown, + jsonKeepAlive: boolean, +) { if (res.destroyed) { return; } @@ -1446,7 +1591,10 @@ function failResponse(res: http.ServerResponse, emit: Emit, error: unknown) { if (error instanceof UpstreamFailure && error.retryAfter && !res.headersSent) { res.setHeader('retry-after', error.retryAfter); } - if (res.headersSent) { + if (res.headersSent && jsonKeepAlive) { + // The 200 and its headers are already out; a status can no longer carry the failure. + res.end(JSON.stringify(failure)); + } else if (res.headersSent) { emit('error', { error: failure.error }); res.end(); } else { diff --git a/plugins/multi-core/src/gateway/tool-observer.ts b/plugins/multi-core/src/gateway/tool-observer.ts index ee84865..88d10a2 100644 --- a/plugins/multi-core/src/gateway/tool-observer.ts +++ b/plugins/multi-core/src/gateway/tool-observer.ts @@ -82,6 +82,7 @@ class SseObserver { private decoder = new TextDecoder(); private pending = ''; private data: string[] = []; + private dataBytes = 0; private observer: ToolObserver; constructor(observer: ToolObserver) { @@ -90,73 +91,122 @@ class SseObserver { push(chunk: Uint8Array) { this.pending += this.decoder.decode(chunk, { stream: true }); - if (this.pending.length > MAX_BYTES) { - throw new Error('Observed SSE buffer exceeds 8 MiB'); - } + // Split first: a chunk may hold many complete events, and only an incomplete line or + // event that outgrows the bound is a reason to stop observing. for (let index = this.pending.indexOf('\n'); index !== -1; index = this.pending.indexOf('\n')) { const line = this.pending.slice(0, index).replace(/\r$/, ''); this.pending = this.pending.slice(index + 1); this.line(line); } + if (this.pending.length > MAX_BYTES) { + throw new Error('Observed SSE line exceeds 8 MiB'); + } } private line(line: string) { if (line.startsWith('data:')) { - this.data.push(line.slice(5).replace(/^ /, '')); + const value = line.slice(5).replace(/^ /, ''); + this.dataBytes += value.length; + if (this.dataBytes > MAX_BYTES) { + throw new Error('Observed SSE event exceeds 8 MiB'); + } + this.data.push(value); } else if (!line && this.data.length) { const value = this.data.join('\n'); this.data = []; + this.dataBytes = 0; this.observer.event(JSON.parse(value)); } } } +/** Observation of one response body; once it cannot keep up it stops and says why. */ +class BestEffortObservation { + private readonly observer: ToolObserver; + private readonly streamed: boolean; + private readonly stopped: (reason: string) => void; + private readonly sse: SseObserver; + private chunks: Uint8Array[] = []; + private bytes = 0; + private observing = true; + + constructor( + remember: (tool: Tool) => void, + streamed: boolean, + stopped: (reason: string) => void, + ) { + this.observer = new ToolObserver(remember); + this.sse = new SseObserver(this.observer); + this.streamed = streamed; + this.stopped = stopped; + } + + push(chunk: Uint8Array) { + if (!this.observing) { + return; + } + try { + this.accept(chunk); + } catch (error) { + this.stop(error); + } + } + + finish() { + if (!this.observing || this.streamed) { + return; + } + try { + this.observer.response(JSON.parse(Buffer.concat(this.chunks).toString('utf8'))); + } catch (error) { + this.stop(error); + } + } + + private accept(chunk: Uint8Array) { + if (this.streamed) { + this.sse.push(chunk); + return; + } + this.bytes += chunk.length; + if (this.bytes > MAX_BYTES) { + throw new Error('Observed response exceeds 8 MiB'); + } + this.chunks.push(chunk); + } + + private stop(error: unknown) { + this.observing = false; + this.chunks = []; + this.stopped(error instanceof Error ? error.message : String(error)); + } +} + /** * Pass the upstream bytes through unchanged and observe them on the side. An * observation failure (partial tool JSON, oversized input) only stops observing; - * it never reaches Claude Code. The passthrough itself has no size cap. + * it never reaches Claude Code, and `stopped` records why. The passthrough itself + * has no size cap. */ export async function forwardObservedTools( upstream: Response, res: ServerResponse, remember: (tool: Tool) => void, signal: AbortSignal, + stopped: (reason: string) => void = () => {}, ) { if (!upstream.body) { res.end(); return; } - const observer = new ToolObserver(remember); - const streamed = upstream.headers.get('content-type')?.includes('text/event-stream'); - const sse = new SseObserver(observer); - const chunks: Uint8Array[] = []; - let bytes = 0; - let observing = true; + const streamed = Boolean(upstream.headers.get('content-type')?.includes('text/event-stream')); + const observation = new BestEffortObservation(remember, streamed, stopped); for await (const chunk of upstream.body) { if (!res.write(chunk)) { await once(res, 'drain', { signal }); } - if (!observing) { - continue; - } - try { - if (streamed) { - sse.push(chunk); - } else { - bytes += chunk.length; - observing = bytes <= MAX_BYTES; - chunks.push(chunk); - } - } catch { - observing = false; - } - } - if (observing && !streamed) { - try { - observer.response(JSON.parse(Buffer.concat(chunks).toString('utf8'))); - } catch { - // Observation is best effort. - } + observation.push(chunk); } + observation.finish(); res.end(); } diff --git a/test/live/native-openai-cache.ts b/test/live/native-openai-cache.ts index 63ef6a6..d2c2385 100644 --- a/test/live/native-openai-cache.ts +++ b/test/live/native-openai-cache.ts @@ -5,7 +5,6 @@ import { createHash, randomUUID } from 'node:crypto'; import { mkdtemp, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { AgentCatalog } from '../../plugins/multi-core/src/gateway/agent-catalog.ts'; import type { MessagesResponse } from '../../plugins/multi-core/src/gateway/messages.ts'; import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; import type { ResponsesRequest } from '../../plugins/multi-openai/src/responses.ts'; @@ -60,7 +59,6 @@ const workers = { tools: ['Read'], }, }; -const catalog = new AgentCatalog(workers, ['multi/openai/gpt-6-astra']); const session = randomUUID(); const token = randomUUID(); const samples: Sample[] = []; @@ -237,7 +235,6 @@ for (let turn = 0; turn < 3; turn++) { token, authFile, blockAnthropic: true, - agentCatalog: catalog, fetchImpl: async (url, init) => { assert.equal(url, 'https://chatgpt.com/backend-api/codex/responses'); assert(samples.length < 6, 'Six-request inference budget exhausted'); @@ -252,8 +249,6 @@ for (let turn = 0; turn < 3; turn++) { JSON.stringify(body.input, null, 2), ); assert(input.includes('CACHE_VISIBLE_WORKER'), 'Native CLI catalog was not observed'); - assert(!input.includes('CACHE_HIDDEN_EFFORT'), 'Reasoning variant leaked into context'); - assert(!input.includes('CACHE_HIDDEN_MODEL'), 'Non-picker model leaked into context'); const sample: Sample = { turn, model: body.model, diff --git a/test/unit/native-agent-catalog.test.ts b/test/unit/native-agent-catalog.test.ts deleted file mode 100644 index e4b2b69..0000000 --- a/test/unit/native-agent-catalog.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { AgentCatalog } from '../../plugins/multi-core/src/gateway/agent-catalog.ts'; -import type { MessagesRequest } from '../../plugins/multi-core/src/gateway/messages.ts'; - -const workers = { - 'openai-native': { - model: 'multi/openai/gpt-6-astra', - description: 'Astra', - tools: ['Read', 'Bash'], - }, - 'openai-native-high': { - model: 'multi/openai/gpt-6-astra', - description: 'Astra high', - tools: ['Read', 'Bash'], - }, - 'zen-other': { model: 'multi/zen/other', description: 'Other', tools: ['Read'] }, -}; -const rows = Object.entries(workers).map( - ([name, worker]) => `- ${name}: ${worker.description} (Tools: ${worker.tools.join(', ')})`, -); -const custom = '- custom: User-owned worker (Tools: Read)'; -const listing = (heading = 'Available agent types for the Agent tool:') => - `\nBefore\n${heading}\n${[...rows, custom].join('\n')}\n\nAfter\n`; -const request = (content: string): MessagesRequest => ({ messages: [{ role: 'user', content }] }); - -test('catalog advertises one worker per picker model without mutating registration or history', () => { - const original = structuredClone(workers); - const catalog = new AgentCatalog(workers, ['multi/openai/gpt-6-astra']); - for (const heading of [ - 'Available agent types for the Agent tool:', - 'New agent types are now available for the Agent tool:', - ]) { - const body = request(listing(heading)); - const before = structuredClone(body); - const compacted = catalog.compact(body); - assert.deepEqual(body, before); - const expected = listing(heading).replace(`${rows[1]}\n`, '').replace(`${rows[2]}\n`, ''); - assert.deepEqual(compacted, request(expected)); - assert.strictEqual(catalog.compact(compacted), compacted); - } - assert.deepEqual(workers, original); -}); - -test('catalog preserves ordinary text, custom overrides, tool results and unknown formats', () => { - const catalog = new AgentCatalog(workers, []); - for (const content of [ - rows.join('\n'), - listing().replaceAll('Agent tool:', 'Future tool:'), - listing().replaceAll('Astra', 'Custom Astra').replace(rows[2], custom), - ]) { - const body = request(content); - assert.strictEqual(catalog.compact(body), body); - } - const body: MessagesRequest = { - system: listing(), - messages: [ - { role: 'assistant', content: listing() }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: listing() }] }, - ], - }; - assert.strictEqual(catalog.compact(body), body); -}); - -test('catalog handles bare native system announcements without changing ordinary user text', () => { - const catalog = new AgentCatalog(workers, ['multi/openai/gpt-6-astra']); - const text = `Available agent types for the Agent tool:\n${[...rows, custom].join('\n')}\n\nKeep concurrency instructions.`; - const body: MessagesRequest = { - messages: [ - { role: 'system', content: text }, - { role: 'user', content: text }, - ], - }; - assert.deepEqual(catalog.compact(body), { - messages: [ - { role: 'system', content: text.replace(`${rows[1]}\n`, '').replace(`${rows[2]}\n`, '') }, - { role: 'user', content: text }, - ], - }); -}); - -test('catalog compacts text blocks only and follows a changed picker selection', () => { - const catalog = new AgentCatalog(workers, ['multi/zen/other']); - const body: MessagesRequest = { - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: listing(), cache_control: { type: 'ephemeral' } }, - { type: 'text', text: 'Keep user instructions' }, - ], - }, - ], - }; - assert.deepEqual(catalog.compact(body), { - messages: [ - { - role: 'user', - content: [ - { - type: 'text', - text: listing().replace(`${rows[0]}\n`, '').replace(`${rows[1]}\n`, ''), - cache_control: { type: 'ephemeral' }, - }, - { type: 'text', text: 'Keep user instructions' }, - ], - }, - ], - }); -}); diff --git a/test/unit/native-approval.test.ts b/test/unit/native-approval.test.ts index c68911a..d70c9fb 100644 --- a/test/unit/native-approval.test.ts +++ b/test/unit/native-approval.test.ts @@ -430,7 +430,10 @@ test('headerless classifier uses pending worker context and rejects ambiguous ac // The mode Claude reports with the pending action binds review mid-turn. assert.equal(contexts[0].planMode, true); await prepare('worker-b', 'node b.js', 'plan', 'cd /other-workspace && node b.js'); - assert.equal((await send(request(1, session, 'node b.js'))).status, 400); + // No observed provider action matches, so this is not a provider action to review: it + // goes to Anthropic (the stub answers 200) and the GPT reviewer never sees it. + assert.equal((await send(request(1, session, 'node b.js'))).status, 200); + assert.equal(contexts.length, 1); await prepare( 'worker-b', 'node b.js', @@ -438,7 +441,8 @@ test('headerless classifier uses pending worker context and rejects ambiguous ac `${process.execPath} -e ${JSON.stringify('process.stdout.write("unsafe")')}`, path.join(os.tmpdir(), 'unsafe').replaceAll('\\', '/'), ); - assert.equal((await send(request(1, session, 'node b.js'))).status, 400); + assert.equal((await send(request(1, session, 'node b.js'))).status, 200); + assert.equal(contexts.length, 1); await prepare('worker-b', 'node b.js', 'plan', `cd ${classifierCwd} && node b.js`); assert.equal((await send(request(1, session, 'node b.js'))).status, 200); await prepare('worker-a', 'node b.js'); @@ -553,12 +557,18 @@ async function mixedReviewGateway(t: TestContext, reviewer = true, blockAnthropi }); const address = server.address(); assert(address && typeof address !== 'string'); - const send = (body: unknown, endpoint = '/v1/messages', worker?: string) => + const send = ( + body: unknown, + endpoint = '/v1/messages', + worker?: string, + extra: Record = {}, + ) => fetch(`http://127.0.0.1:${address.port}${endpoint}`, { method: 'POST', headers: { 'x-multi-gateway-token': 'token', ...(worker ? { 'x-claude-code-agent-id': worker } : {}), + ...extra, }, body: JSON.stringify(body), }); @@ -669,11 +679,11 @@ test('authenticated mixed-provider review follows main and headerless worker ori assert.equal((await gateway.classify('node zen.js')).status, 200); assert.equal(gateway.reviews.length, 2, 'Claude and Zen never borrow GPT review'); assert.equal(gateway.nativeReviews.length, 3); - assert.equal( - (await gateway.classify('node main.js')).status, - 400, - 'A provider switch invalidates stale main actions', - ); + // A provider switch invalidates the stale GPT action: nothing provider-owned matches + // any more, so the request is Claude's own classifier and goes to Anthropic. + assert.equal((await gateway.classify('node main.js')).status, 200); + assert.equal(gateway.reviews.length, 2, 'A stale GPT action is never reviewed again'); + assert.equal(gateway.nativeReviews.length, 4); }); test('Claude-only Auto passes native classifier formats and fallback models through unchanged', async (t) => { @@ -729,17 +739,32 @@ test('mixed-provider review rejects missing, cross-session, ambiguous, and unava const guard = await gateway.prepare(gpt, 'node unavailable.js'); assert.deepEqual(await guard.json(), {}); assert.equal((await gateway.classify('node unavailable.js')).status, 400); - assert.equal((await gateway.classify('node missing.js')).status, 400); + // No observed provider action matches: Claude's own classifier, answered by Anthropic. + assert.equal((await gateway.classify('node missing.js')).status, 200); + assert.deepEqual(gateway.nativeReviews.length, 1); + // The session header disagrees with the metadata that selects the provider action. + const crossed = await gateway.send( + { + ...request(1, JSON.stringify({ session_id: 'mixed' }), 'node unavailable.js'), + model: 'claude-sonnet-5', + }, + '/v1/messages', + undefined, + { 'x-claude-code-session-id': 'other' }, + ); + assert.equal(crossed.status, 400); + assert.match(await crossed.text(), /disagree/); + // Another session owns no provider action here, so its classifier is Claude's own. assert.equal( (await gateway.classify('node unavailable.js', 1, 'claude-sonnet-5', 'other')).status, - 400, + 200, ); await gateway.prepare('claude-sonnet-5', 'node unavailable.js', 'claude-worker'); assert.equal((await gateway.classify('node unavailable.js')).status, 400); - assert.deepEqual( - gateway.nativeReviews, - [], - 'Never fall back to Claude for an unavailable or ambiguous GPT reviewer', + assert.equal( + gateway.nativeReviews.length, + 2, + 'An ambiguous GPT origin never falls back to Claude', ); }); diff --git a/test/unit/native-gateway-transport.test.ts b/test/unit/native-gateway-transport.test.ts new file mode 100644 index 0000000..c65e65a --- /dev/null +++ b/test/unit/native-gateway-transport.test.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import type { TestContext } from 'node:test'; +import test from 'node:test'; +import type { GatewayFetch } from '../../plugins/multi-core/src/gateway/fetch.ts'; +import type { GatewayEvent } from '../../plugins/multi-core/src/gateway/server.ts'; +import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; + +const TOKEN = 'local-test-secret'; + +async function listen(t: TestContext, options: Partial[0]>) { + const server = createNativeGateway({ + token: TOKEN, + authFile: '/nonexistent/auth.json', + guardAuto: true, + ...options, + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + const address = server.address(); + assert(address !== null && typeof address === 'object', 'Gateway port'); + return address.port; +} + +test('a bodyless DELETE goes upstream without a streamed body', async (t) => { + const seen: { url: string; method: string; body: unknown; duplex: unknown }[] = []; + const fetchImpl: GatewayFetch = async (url, init) => { + seen.push({ url, method: init.method, body: init.body, duplex: init.duplex }); + return new Response(null, { status: 204 }); + }; + const port = await listen(t, { fetchImpl }); + const response = await fetch(`http://127.0.0.1:${port}/v1/files/file_1`, { method: 'DELETE' }); + assert.equal(response.status, 204); + assert.deepEqual(seen, [ + { + url: 'https://api.anthropic.com/v1/files/file_1', + method: 'DELETE', + body: undefined, + duplex: undefined, + }, + ]); +}); + +test('paths outside /v1 go to Anthropic raw, while /multi routes keep their own gate', async (t) => { + const urls: string[] = []; + const fetchImpl: GatewayFetch = async (url) => { + urls.push(url); + return new Response('ok'); + }; + const port = await listen(t, { fetchImpl }); + for (const route of ['/api/hello', '/api/claude_cli/bootstrap?x=1', '/']) { + assert.equal((await fetch(`http://127.0.0.1:${port}${route}`)).status, 200); + } + assert.deepEqual(urls, [ + 'https://api.anthropic.com/api/hello', + 'https://api.anthropic.com/api/claude_cli/bootstrap?x=1', + 'https://api.anthropic.com/', + ]); + assert.equal((await fetch(`http://127.0.0.1:${port}/multi/other`)).status, 403); + assert.equal(urls.length, 3); +}); + +test('the caller own base URL receives native traffic', async (t) => { + const urls: string[] = []; + const fetchImpl: GatewayFetch = async (url) => { + urls.push(url); + return new Response('ok'); + }; + const port = await listen(t, { fetchImpl, anthropicBaseUrl: 'https://corp.example/anthropic/' }); + await fetch(`http://127.0.0.1:${port}/v1/files?a=1`); + await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + body: JSON.stringify({ model: 'claude-opus-4-6', messages: [] }), + }); + assert.deepEqual(urls, [ + 'https://corp.example/anthropic/v1/files?a=1', + 'https://corp.example/anthropic/v1/messages', + ]); +}); + +test('the client encoding preference reaches Anthropic only for encodings fetch decodes', async (t) => { + const encodings: (string | undefined)[] = []; + const fetchImpl: GatewayFetch = async (_url, init) => { + encodings.push(init.headers['accept-encoding']); + return new Response('ok'); + }; + const port = await listen(t, { fetchImpl }); + for (const value of ['gzip, br;q=0.5, zstd', 'zstd', 'identity']) { + await fetch(`http://127.0.0.1:${port}/v1/files`, { headers: { 'accept-encoding': value } }); + } + assert.deepEqual(encodings, ['gzip, br', 'identity', 'identity']); +}); + +test('a gateway failure on a native request is reported as a diagnostic, not hidden', async (t) => { + const events: GatewayEvent[] = []; + const fetchImpl: GatewayFetch = async () => { + throw new TypeError('fetch failed'); + }; + const port = await listen(t, { fetchImpl, onEvent: (event) => events.push(event) }); + await assert.rejects( + fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + body: JSON.stringify({ model: 'claude-opus-4-6', messages: [] }), + }), + ); + assert( + events.some((event) => event.route === 'anthropic' && event.diagnostic === 'fetch failed'), + ); +}); + +test('a long observed tool_use line stops observation with a diagnostic and passes through', async (t) => { + const events: GatewayEvent[] = []; + const big = 'x'.repeat(9 * 1024 * 1024); + const body = `event: content_block_delta\ndata: ${big}\n`; + const fetchImpl: GatewayFetch = async () => + new Response(`${body}\nevent: ping\ndata: {}\n\n`, { + headers: { 'content-type': 'text/event-stream' }, + }); + const port = await listen(t, { fetchImpl, onEvent: (event) => events.push(event) }); + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + body: JSON.stringify({ + model: 'claude-opus-4-6', + stream: true, + tools: [{ name: 'Write', input_schema: { type: 'object' } }], + messages: [], + }), + }); + assert.equal((await response.text()).length, body.length + '\nevent: ping\ndata: {}\n\n'.length); + assert(events.some((event) => event.diagnostic?.includes('exceeds 8 MiB'))); +}); + +test('a non-streamed provider reply that outlasts the delay sends headers and keeps alive', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'keepalive-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const authFile = path.join(dir, 'auth.json'); + await writeFile( + authFile, + JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'fake', account_id: 'fake' } }), + ); + const events = [ + { type: 'response.created', response: { id: 'r', usage: null } }, + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'message', content: [] }, + }, + { type: 'response.output_text.delta', output_index: 0, delta: 'Done' }, + { + type: 'response.output_item.done', + output_index: 0, + item: { type: 'message', content: [{ type: 'output_text', text: 'Done' }] }, + }, + { + type: 'response.completed', + response: { id: 'r', usage: { input_tokens: 1, output_tokens: 1 } }, + }, + ] + .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + .join(''); + const fetchImpl: GatewayFetch = async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + return new Response(events, { headers: { 'content-type': 'text/event-stream' } }); + }; + const port = await listen(t, { fetchImpl, authFile, jsonKeepAliveMs: 50 }); + const request = JSON.stringify({ + model: 'multi/openai/gpt-6-luna', + max_tokens: 10, + messages: [{ role: 'user', content: 'hi' }], + }); + const started = Date.now(); + const timing = await new Promise<{ headersAt: number; text: string; status?: number }>( + (resolve, reject) => { + const req = http.request( + { + port, + host: '127.0.0.1', + path: '/v1/messages', + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + }, + (res) => { + const headersAt = Date.now() - started; + let text = ''; + res.on('data', (chunk) => { + text += chunk; + }); + res.on('end', () => resolve({ headersAt, text, status: res.statusCode })); + }, + ); + req.on('error', reject); + req.end(request); + }, + ); + assert.equal(timing.status, 200); + assert(timing.headersAt < 250, `headers came after ${timing.headersAt} ms`); + assert(timing.text.startsWith(' '), 'leading JSON whitespace'); + assert.equal(JSON.parse(timing.text).content[0].text, 'Done'); +}); diff --git a/test/unit/native-gateway.test.ts b/test/unit/native-gateway.test.ts index 359b29b..2c95b26 100644 --- a/test/unit/native-gateway.test.ts +++ b/test/unit/native-gateway.test.ts @@ -4,7 +4,6 @@ import os from 'node:os'; import path from 'node:path'; import type { TestContext } from 'node:test'; import test from 'node:test'; -import { AgentCatalog } from '../../plugins/multi-core/src/gateway/agent-catalog.ts'; import { type ApprovalContext, NativeApprovalBridge, @@ -673,43 +672,6 @@ test('Claude prompts remain unchanged after OpenAI main and worker requests, inc assert.equal(claudeCalls, 8); }); -test('catalog filtering reaches Claude and OpenAI without changing user text or native registration', async (t) => { - const row = '- hidden: Hidden worker (Tools: Read)'; - const text = `\nAvailable agent types for the Agent tool:\n${row}\n- custom: Keep this (Tools: Read)\n`; - const seen: string[] = []; - const call = await gateway( - t, - async (url, options) => { - seen.push(String(options.body)); - return url.includes('anthropic') - ? Response.json({ content: [] }) - : new Response(sse(textEvents)); - }, - { - agentCatalog: new AgentCatalog( - { hidden: { model, description: 'Hidden worker', tools: ['Read'] } }, - [], - ), - }, - ); - for (const choice of [model, 'claude-sonnet-5']) { - await ( - await call({ - model: choice, - messages: [ - { role: 'user', content: text }, - { role: 'user', content: row }, - ], - }) - ).text(); - } - assert.equal(seen.length, 2); - for (const sent of seen) { - assert.equal(sent.split('Hidden worker').length - 1, 1); - assert(sent.includes('Keep this')); - } -}); - test('external route isolates provider credentials and handles simultaneous worker identities', async (t) => { const ids: string[] = []; const models: string[] = []; @@ -1323,12 +1285,7 @@ const harnessReply = (model: string, text = 'native'): MessagesResponse => ({ async function harnessPermissionModes(session = 'routing-session') { const modes = new PermissionModes(async () => ({})); - await modes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: session, - permission_mode: 'auto', - prompt: 'route', - }); + modes.recordModSession(session, { permissionMode: 'auto' }); return modes; } @@ -1558,12 +1515,7 @@ test('plan mode binds OpenAI review and denies planned edits without review', as const session = 'plan-review-session'; const modes = new PermissionModes(async () => ({})); const prompt = (mode: string) => - modes.record({ - hook_event_name: 'UserPromptSubmit', - session_id: session, - permission_mode: mode, - prompt: 'review', - }); + modes.recordModSession(session, { permissionMode: mode as 'default' }); const contexts: (ApprovalContext | undefined)[] = []; const bridge = new NativeApprovalBridge(async (_input, _signal, context) => { contexts.push(context); diff --git a/test/unit/native-zen-gateway.test.ts b/test/unit/native-zen-gateway.test.ts index d12da3a..dc98289 100644 --- a/test/unit/native-zen-gateway.test.ts +++ b/test/unit/native-zen-gateway.test.ts @@ -175,7 +175,6 @@ test('Zen native tools cannot acquire OpenAI review; explicit bypass stays expli let reviewed = 0; const base = await gateway(t, async () => completion(true), { guardAuto: true, - approvalProviders: ['openai'], approvalBridge: { respond: async () => { reviewed++; From 25521f9e79c11c095185b67fcf88510a5f1d4e1c Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:02:21 -0400 Subject: [PATCH 20/36] Untrack the node_modules symlink and ignore symlinked installs A worktree's node_modules symlink was committed because node_modules/ only matches directories. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- .gitignore | 2 +- node_modules | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 120000 node_modules diff --git a/.gitignore b/.gitignore index d551fc3..02935d7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ tmp_rp.txt /docs/ACP_TRANSPORT_BLUEPRINT.md # --- Tooling --- -node_modules/ +node_modules # Agent scratch area: plans, specs, reverse-engineering notes. Local-only. /.agent/ diff --git a/node_modules b/node_modules deleted file mode 120000 index 10b9d81..0000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/walshlab/Software/cc-multi-cli-plugin/node_modules \ No newline at end of file From 9751569bd34f916b4516d62dfd70924e076a798a Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:02:31 -0400 Subject: [PATCH 21/36] Document the Mods hooks as generally available Drop the early-access framing (opt-in variable, /plugin-types as the type source, $store), record the engine's static rules for hooks files, explain why test:mod forces the rollout flag locally, and update the API map for the changed hooks. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- docs/claude-mods.md | 109 ++++++++++++++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/docs/claude-mods.md b/docs/claude-mods.md index 692c99f..18a2866 100644 --- a/docs/claude-mods.md +++ b/docs/claude-mods.md @@ -11,8 +11,10 @@ The authoritative starting point is Anthropic's [Mods proposal and September 9 update](https://github.com/anthropics/claude-code/issues/91870). Anthropic calls the product feature **Claude Mods** and the implementation primitive **function hooks**: a Mod is a plugin whose behavior is implemented with function hooks. -The update says the interface was still early access and rapidly iterating, but -that core semantics had settled enough to publish built-in source. +Mods are generally available and on by default as of Claude Code 2.1.287; no +environment variable turns them on for a person. The engine's own declarations +(`.claude-plugin/types/claude-code/index.d.ts`, written beside a mod it loads from a +folder) are the API authority for this repository. The issue's [Function Hooks: Core Architecture PDF](https://github.com/user-attachments/files/31802150/EXTERNAL.Function.Hooks.Core.Architecture.pdf) @@ -39,8 +41,8 @@ PDF](https://github.com/user-attachments/files/31802150/EXTERNAL.Function.Hooks. Anthropic's [built-in Mods source listings](https://github.com/anthropics/claude-code/tree/main/mods) are the best executable reference. The published README describes a hooks module as one -`register(on, options)` entry, typing against declarations written by -`/plugin-types`, and tests run with `claude plugin test`. As of the source tree +`register(on, options)` entry, typed against the declarations the engine writes, with +tests run by `claude plugin test`. As of the source tree inspected September 22, 2026 (latest `mods/` commit `7974a707` dated September 20), it contains `diff`, `sec-default`, `telemetry`, and `agents-md`: @@ -56,14 +58,34 @@ inspected September 22, 2026 (latest `mods/` commit `7974a707` dated September demonstrates instruction loading through engine hooks rather than prompt or filesystem side channels. -The issue's September 9 cheat-sheet image covers v267/v268-era affordances and -documents the opt-in command -`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude`. Treat it as dated version evidence, -not a permanent API promise. Multi's registration code records that Claude Code -2.1.272 loads exactly one `hooks.json` module, so all Multi hook registrars are -composed by `hooks/register.ts`. Regenerate current local declarations with -`/plugin-types` and test the repository's supported Claude Code build when an -event or result shape changes. +The issue's September 9 cheat-sheet image covers v267/v268-era affordances; treat +it as dated version evidence, not a permanent API promise. Multi's registration code +records that Claude Code 2.1.272 loads exactly one `hooks.json` module, so all Multi +hook registrars are composed by `hooks/register.ts`. The engine rewrites +`.claude-plugin/types/` each time it loads the mod from this checkout (and +`/plugin-types` writes the same declarations to a directory you name); test the +repository's supported Claude Code build when an event or result shape changes. + +### Static rules the engine enforces + +`claude plugin validate .claude-plugin/plugin.json` reads the hooks source the way the +engine will and refuses a module that breaks these rules, which shape the code: + +- `$` is passed only into functions declared in the same file, never across an import. + Each hooks file therefore builds its own small `wire($)` (four closures: the two + `$.env.get` names, `$.http.fetch`, `$.clock.sleep`) and hands that to the shared + gateway client in `gateway.ts`; cross-file helpers (`syncDisplayTools`, the policy + client) take such closures, never `$`. +- `$.env.get` takes a literal name, and `$.noun.event(...)` is spelled at every call + site; `$.env` and the like are never passed as values. +- A module hooks each event once without a matcher, so one `tool.call` hook (in + `register.ts`) answers display rows and attributes every other call. +- State values are named where they are read: each file declares its own + `atom({ plugin: 'multi-core', key: ... } as const, initial)` for the keys it uses; the + value types come from `types/multi-core.d.ts`. +- A hooks module has no timers. Every wait is `$.clock` (`sleep`, `after`, `every`), + which is also the only wait that counts against a hook's 10 s own-time budget; time + inside any other `$` call (a gateway fetch) does not count. ## Community field report @@ -77,7 +99,8 @@ function-hook UI and reports these primitives and constraints: `Client` surface module. - `turn.complete` and `tool.call` let a Mod react to Claude lifecycle and tool activity without spending model tokens. -- `$.store` persists Mod-owned state. +- `$.store` persists Mod-owned state between sessions; `$.state` holds session state + that survives a hot reload and redraws any drawing that read it (Multi uses `$.state`). - A `Client` module path must be a string literal so the engine can discover it. - A JSX surface module must not bind a local variable named `h`, because JSX is compiled into calls to that identifier. @@ -87,22 +110,24 @@ function-hook UI and reports these primitives and constraints: The linked project's [README, especially **How it works**, **Limits**, and **Develop**](https://github.com/sezaakgun/cc-arcade#develop), is the practical -upstream reference behind those findings. Function hooks are early access, so -validate behavior against the Claude Code version supported by this repository +upstream reference behind those findings. It was written before general +availability, so validate behavior against the Claude Code version supported by this repository instead of treating the example as a stable public specification. ## API map used by Multi | Concern | Function-hook API used here | Multi behavior | | --- | --- | --- | -| Startup and registration | `session.start`, `$.command.register`, `$.tool.register` | Registers `/multi-usage`, the initial authenticated Mod session, and the display tools for native harness actions (see below). Multi registers no model-callable tools. | -| Model and permission boundary | `classic.SessionStart`, `classic.UserPromptSubmit`, `$.session.model/cwd/id` | Records the effective Claude permission snapshot at prompt boundaries; it does not redesign provider permissions. | -| Workers | `tool.call` (Agent), `tool.describe` (Agent), `agent.offer`, `agent.spawn`, `classic.SubagentStart`, `ui.render` (ToolUse, UserMessage) | Takes the Agent tool's `model` out before Claude's Agent schema check, resolves it against the provider worker catalog at spawn (labelling the task's description, which the running-agents list and its notification show), admits harness children against the current policy generation, and labels the Agent row with the resolved provider and model. The engine's compact line for parallel Agent calls reads the stored call, whose provider model its schema rejects, and draws those calls as a bare `Agent`; ctrl+o shows the labelled rows. | -| Native action rows | `$.tool.register`, `turn.step`, `tool.describe`, `tool.check`, `tool.call`, `ui.render` for `ToolUse` and `ToolResult` | Registers a display tool per native tool name the gateway offers (again before a harness step when a harness announced new ones), defers them behind ToolSearch, allows only calls carrying a gateway-issued token, answers those with the native output from `POST /multi/mod/display`, and draws each row as Claude Code draws the built-in the native tool mirrors (Read, Bash, Grep, Glob, LS, Edit, Write), under the native tool's name. `ToolGroup` folding stays the engine's. | -| Usage UI | `command.run`, `$.ui.open`, `$.ui.invalidate`, `ui.render` for `Pane`, `ui.message` | Opens the interactive usage pane, renders its literal `Client` module, and handles bounded refresh/receipt/toggle messages. | -| Lifecycle | `turn.step`, `turn.complete`, `session.detach`, `$.ui.status` | Updates telemetry, the status line and receipts, completes/cancels runs, and forgets session-scoped state. | +| Startup and registration | `session.start`, `$.command.register`, `$.state`, `$.tool.register` | Registers `/multi-usage` and greets the gateway with the session (which also tells the launcher the Mod is live). The first prompt after a `/clear`, which fires no `session.start`, greets again. Multi registers no model-callable tools, and no display tool at start (see below). | +| Model and permission boundary | `classic.SessionStart`, `classic.UserPromptSubmit`, `$.session.model/cwd/id`, `$.state` | Records the effective Claude permission snapshot at prompt boundaries; it does not redesign provider permissions. A harness prompt always admits its settings policy; any other model's snapshot is posted only when the mode, workspace or model changed, and always kept in `$.state` so a harness worker spawned later admits against it. | +| Workers | `tool.call` (Agent), `tool.describe` (Agent), `agent.offer`, `agent.spawn`, `classic.SubagentStart`, `$.ui.invalidate('tool.describe')`, `ui.render` (ToolUse, UserMessage) | Takes the Agent tool's `model` out before Claude's Agent schema check, resolves it against the provider worker catalog at spawn (labelling the task's description, which the running-agents list and its notification show), admits harness children against the current policy generation, and labels the Agent row with the resolved provider and model, read from `$.state` so a resolved spawn redraws it. The Agent description names provider models only while a provider worker type is offered. An agent type skips gateway registration only when it is natively Claude: a built-in on a Claude session is classified locally, any other type by the gateway's offer (its model must not be a Multi model, so a definition pinned to `multi/openai/...` keeps registration), and `classic.SubagentStart` keeps the record while any loop of the session runs a Multi model. | +| Native action rows | `$.tool.register`, `turn.step`, `tool.describe`, `tool.check`, `tool.call`, `ui.render` for `ToolUse` and `ToolResult` | Registers a display tool per native tool name the gateway offers, lazily: at a harness prompt, before a harness worker spawns, and before a harness step when the catalog changed. A session that never runs a harness lists none. It defers them behind ToolSearch, allows only calls carrying a gateway-issued token, answers those with the native output from `POST /multi/mod/display`, and draws each row as Claude Code draws the built-in the native tool mirrors (Read, Bash, Grep, Glob, LS, Edit, Write), under the native tool's name. `ToolGroup` folding stays the engine's. | +| Usage UI | `command.run`, `$.ui.open` (`isPlaced`), `$.ui.toast`, `$.state`, `ui.render` for `Pane`, `ui.message` | Opens the interactive usage pane, renders its literal `Client` module from `$.state` (a write redraws it; nothing invalidates), and handles bounded refresh/receipt/toggle messages. A pane that waits undrawn is announced with a toast and its numbers are returned as text. | +| Lifecycle | `turn.step`, `turn.complete`, `session.end`, `$.clock.every`, `$.ui.status` | Records each loop's model, sends telemetry for a Multi step only, polls a harness run's status on a `$.clock` timer cancelled at `turn.complete` and `session.end`, completes/cancels runs, and forgets session-scoped state when the session ends (exit, `/clear` and resume alike). `session.detach` fires when any client leaves the roster, a session still running, so it is not hooked. | +| Reviewer attribution | `tool.call` | A provider loop's tool call (its `agentId` names the worker) is attributed to the gateway's reviewer with the session's snapshot mode; a Claude loop's call costs the gateway nothing. `classic.PreToolUse` carries neither the agent id nor a mode. | | Compaction | `session.compact` | Keeps native-harness compaction provider-owned and generation-bound. | -| Local control plane | `$.http.fetch`, `$.env.get` | Calls authenticated loopback `/multi/mod/*` routes with bounded bodies and timeouts. | +| Local control plane | `$.http.fetch`, `$.env.get`, `$.clock.sleep` | `gateway.ts` calls authenticated loopback `/multi/mod/*` routes with an explicit method, a body bounded in bytes, and a `$.clock` deadline (1.5 s, 8.5 s for usage). Policy readiness is one request the gateway holds until discovery ends (bounded at 8 s), so no hook loops. | +| Hook state | `$.state` (`atom`, `read`, `update`), `types/multi-core.d.ts` | The policy snapshot, loop and spawn models, offer classification, display tools and usage panes live in `$.state`, declared by the `types` file `.claude-plugin/plugin.json` names, so a hot reload keeps them and a drawing that reads one is redrawn when it changes. Only per-call memos (the Agent model between `tool.call` and `agent.spawn`, a row's input between draws) and timers stay in module memory. | The `classic.*` events are compatibility bridges for Claude Code's classic hook payloads. New UI and extensibility still belongs in Mods; classic events do not @@ -114,7 +139,8 @@ The implementation is deliberately split: | Layer | Responsibility | | --- | --- | -| `plugins/multi-core/hooks/*.ts` | Register function hooks, react to Claude events, open/invalidate surfaces, and exchange UI messages. | +| `plugins/multi-core/hooks/*.ts` | Register function hooks, react to Claude events, open surfaces, and exchange UI messages. `gateway.ts` is the one gateway client, `state.ts` the bounded-record helpers, `policy.ts` the prompt-boundary admission. | +| `plugins/multi-core/types/multi-core.d.ts` | The `$.state` values the hooks keep (`PluginState`), named by `.claude-plugin/plugin.json` `types`. | | `plugins/multi-core/hooks/*-view.ts` | Render `Client` surfaces (the usage pane); module paths at call sites stay literal. | | `plugins/multi-core/hooks/rows.ts` | Register, gate, answer and draw the display rows of native harness actions. | | `plugins/multi-core/hooks/rows-view.ts` | Draw a display row as the Claude Code built-in it mirrors. | @@ -158,10 +184,15 @@ harness reports it: Cursor's SDK tool-call type (`shell`, `read`, `edit`, `grep` `mcp__multi-core__`; that is engine naming, not an MCP server. The gateway offers the static sets of the harnesses it runs (`GET /multi/mod/display-tools`), adds any name a harness announces at run time, and bounds them (160 names, -`[A-Za-z0-9_-]`, 47 characters). The mod registers them at `session.start` and -again before a harness `turn.step` when the catalog changed, then acknowledges the -registered set (`POST /multi/mod/display-tools`); the gateway emits rows only for -acknowledged names, so a session without the mod gets the closing summary alone. +`[A-Za-z0-9_-]`, 47 characters). The mod registers them lazily, so a +pure Claude session lists none (ToolSearch could otherwise surface them): at a harness +prompt, before a harness worker spawns, and before a harness `turn.step` when the +catalog changed (a registration takes effect from the next prompt, so the prompt hook +is what lets the first harness run's rows anchor). It then acknowledges the registered +set per session (`POST /multi/mod/display-tools`); the gateway emits rows only for names +the requesting session acknowledged, so a session without the mod gets the closing +summary alone. A session's end clears what it registered, and the next session +registers and acknowledges again. Each harness worker definition lists `mcp__multi-core` in its tools, because the engine dispatches a subagent's tool_use only for a tool its definition admits; the Cursor, Antigravity, and Grok permission mappers drop that entry, so it grants no @@ -233,7 +264,8 @@ would be folded, and is counted in no parent's line. **No fake tools for any model.** The gateway removes every display tool from each forwarded `tools` list, every display tool_use and its tool_result from each forwarded history (closing up the turns), and the display names from Claude Code's -ToolSearch catalogue and worker tool lists, for every provider, Anthropic included. +ToolSearch catalogue, ToolSearch `tool_reference` results and worker tool lists, for +every provider, Anthropic included. `tool.describe` returns `isDeferred: true`, so no prompt lists them. `tool.check` allows a call only when `POST /multi/mod/display` confirms its token for that session and tool_use id, and `tool.call` refuses the rest, so a model-originated call @@ -271,6 +303,24 @@ permission snapshots, model and worker selection, progress/lifecycle, compaction, session cleanup, and quota advice through the same authenticated Mods control plane. +The gateway bounds what the Mod can make it hold: a session's snapshot and policy +tables evict the least recently used idle session instead of refusing a new one, an +unconsumed policy job is reused for a minute at most, and a policy request may carry +`wait: true` to be held until discovery ends. + +## Testing the hooks + +`npm run test:mod` type-checks the hooks and their tests against the engine's own +declarations (`.claude-plugin/types/`, written when the mod loads from this checkout; +`/plugin-types` writes the same files elsewhere) and runs `claude plugin test` with the +installed Claude executable, without provider inference. Mods are on by default in +Claude Code 2.1.287, but `claude plugin test` follows the server rollout flag, which is +served off for some accounts; the script therefore sets +`CLAUDE_INTERNAL_FC_OVERRIDES={"tengu_plugin_hooks_modules":true}` for that process +only (no `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` is involved). A test that reaches the +gateway client installs `mock.clock(on)`, because each request is bounded by +`$.clock`; a test of a timer advances that clock. + ## Rules for changes Any Claude Code harness UI or extensibility change must use function-hook Mods, @@ -290,6 +340,7 @@ When adding a surface: as a literal string. 3. Avoid a local `h` binding in JSX surface modules. 4. Preserve `next(event)` behavior for events the Mod does not own. -5. Bound retained state, payload size, refresh rate, and gateway latency. +5. Bound retained state (keep it in `$.state`, not module memory), payload size, + refresh rate, and gateway latency (wait on `$.clock`, never a timer). 6. Add hook tests and gateway route tests for session isolation, stale state, authentication, and failure fallback. From aa853cf87b6682527b3c572614a599f9a8e7268c Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:02:47 -0400 Subject: [PATCH 22/36] Pass the codex credential store value without cmd.exe-unsafe quotes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-core/src/account.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/multi-core/src/account.ts b/plugins/multi-core/src/account.ts index 7791404..a8ed603 100644 --- a/plugins/multi-core/src/account.ts +++ b/plugins/multi-core/src/account.ts @@ -76,7 +76,7 @@ async function main() { ); } if (provider === 'openai' && args.every((arg) => arg === '--device-auth')) { - return run('codex', ['-c', 'cli_auth_credentials_store="file"', 'login', ...args]); + return run('codex', ['-c', 'cli_auth_credentials_store=file', 'login', ...args]); } if (args.length) { throw new Error('Unsupported login arguments'); From 664bc2f6365415c82ea0e9c30db6e9c7c7fbde15 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:10:49 -0400 Subject: [PATCH 23/36] Record the review fixes in the changelog Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- CHANGELOG.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8589641..bc26661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,98 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and ## Unreleased +- **Leave Claude sessions alone until a harness runs.** Multi registered about + 70 hidden display tools (named after Cursor, Antigravity, and Grok tools, such + as `web_search`) at every session start, so ToolSearch could surface them to + Claude and the gateway rewrote every native request to hide them. They now + register only when a harness prompt, worker, or step needs them, + acknowledgement is per session, and display `tool_reference` blocks are + stripped from forwarded history. The Agent tool's provider-model paragraph + appears only while a provider worker is offered, and Claude's recorded Agent + input is no longer rewritten. +- **Stop rejecting Claude's own auto-mode checks in mixed sessions.** A + classifier request for a Claude action that the gateway had not observed + returned "Missing or ambiguous pending review action" once the session had run + a provider. It now goes to Anthropic unless a provider-owned action positively + matches; a request routed to provider review keeps the session mismatch check. +- **Fix provider workers in Claude sessions.** A custom agent pinned to a + `multi/openai` or `multi/zen` model, or a worker inheriting a provider model, + was treated as a native Claude subagent, skipped gateway registration, and had + every tool call denied in auto mode. Worker tool calls are now attributed + through the `tool.call` hook (the classic `PreToolUse` event carries no agent + id), with the session's permission mode. +- **Forget session state when the session ends, not when a client detaches.** + Multi treated `session.detach`, which fires when any client such as the phone + or desktop app leaves, as the end of the session and dropped its policy, + worker models, and held follow-up text mid-run. Cleanup now runs on + `session.end`. +- **Use the engine's state and clock in the Mod.** Hook state lives in + `$.state`, so it survives a hot reload; timeouts, the status poll, and the + policy wait use `$.clock` instead of timers the hooks environment does not + provide; one gateway client replaces eight request helpers; the policy wait is + one long-poll request instead of a sleep loop inside the hook's budget. The + usage pane shows a toast when the terminal cannot place it. `npm run test:mod` + forces the Mods rollout flag that `claude plugin test` follows, and Mods are + no longer described as opt-in. +- **Cut gateway round trips on Claude turns.** Unchanged Claude prompt + snapshots, Claude step telemetry, and offers of built-in agents no longer call + the gateway. +- **Keep slow non-streamed provider replies alive.** A non-streamed harness + request sent nothing until the run finished, so Claude Code could time out and + re-run native actions. After 30 s the gateway sends headers and JSON whitespace + keepalives. +- **Honor proxies, custom base URLs, and every Anthropic path.** The gateway + respects `HTTPS_PROXY` (the bootstrap starts Node with + `NODE_USE_ENV_PROXY=1`), forwards to a caller-set `ANTHROPIC_BASE_URL` + instead of refusing to launch, forwards every path other than `/multi/*` and + Messages raw, keeps bodyless requests bodyless, and passes the client's + compression preference upstream. +- **Keep nested `claude` runs native.** A `claude` started by a Bash command or + script inherited the gateway URL and token and broke when the parent exited. A + PATH shim and the bootstrap now restore the session-start environment for + nested plain runs. +- **Bind the Mod control plane to the Mod.** The gateway mints a per-session key + on the Mod's first request and, once the Mod echoes it, refuses requests that + carry only the environment token, so a shell command cannot loosen a session's + permissions. +- **Launch more robustly.** A failing `claude plugin list` no longer aborts + launch, provider discovery runs in parallel, and a Claude that exits early ends + the launch with its own exit code instead of a 30-second wait and a misleading + version error. Agent view is disabled through one setting instead of a setting + and an environment variable. +- **Scope compaction to the main loop.** Arming a main-session compaction turned + every worker resolved meanwhile into a toolless compaction turn, and a failed + compaction stayed armed. Workers are now unaffected and failure or cancel + clears it. Mod session and permission tables evict idle entries instead of + asking for a gateway restart, and unused policy jobs expire. +- **Enforce Antigravity tool grants in every mode.** Outside plan mode only + mapped tools were denied, so a Read-only worker could still drive the browser, + schedule tasks, or send messages. agy now runs an allowlist built from the + Claude grant; scheduling, messaging, inbox, task, resource, image, and + knowledge-deletion tools are always denied; write tools cannot touch agy's own + hook or config files; and `multi uninstall` removes the global agy hook. +- **Fail closed on Grok tools and stops.** Grok's toolset check now refuses any + announced tool that is neither granted nor MCP-namespaced, and a cancelled, + refused, or truncated turn is an error instead of a cached success. Grok and + Antigravity keep their interruption notice after a failed run so a retry does + not silently repeat side effects, and a missing CLI is a 400 on both. +- **Recover provider state across restarts and long sessions.** Cursor follow-up + rows survive a gateway restart, idle Antigravity and Grok session records are + evicted, reply archives are pruned, unsupported-version session records are + kept aside instead of overwritten, and native action summaries report actions + past the 512-row cap. Long Grok and Antigravity runs are no longer killed at + 8 MiB of total output. +- **Keep provider bookkeeping out of the model's context.** Grok and Antigravity + no longer append timing and billing lines to model-visible text; receipts and + the status line already show them. Zen Chat emits its content blocks in order + and joins text blocks with newlines, and OpenAI replies that end on a stop + sequence record their billed usage. +- **Harden Windows process handling and state files.** Arguments containing + cmd.exe metacharacters are refused for non-shim `.cmd`/`.bat` launchers, + cancellation asks the process tree to close before forcing it and never kills + a reused PID, policy probes run `reg`, `powershell`, and `defaults` from system + paths with a timeout, state locks survive hostname changes and recheck racing + takeovers, and atomic writes are fsynced. - **Windows `.cmd` launchers no longer receive unsafe prompts.** Grok and Antigravity deliver prompts containing `"`, `%`, `&` and similar through a file or stdin when the CLI is a non-shim `.cmd`/`.bat`, and both use the From bdd6408bb9709a2b904ad35cb275fe1df975c4bb Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:15:20 -0400 Subject: [PATCH 24/36] Move Responses translation and SSE reader into multi-core Zen and OpenAI now share gateway/responses.ts; forAnthropic takes the provider signature prefixes to strip instead of hard-coding Zen's. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-core/src/gateway/responses.ts | 1034 ++++++++++++++++++ plugins/multi-core/src/gateway/tokens.ts | 5 +- plugins/multi-core/src/launcher.ts | 2 +- plugins/multi-grok/src/models.ts | 2 +- plugins/multi-openai/src/approval.ts | 2 +- plugins/multi-openai/src/models.ts | 2 +- plugins/multi-openai/src/responses.ts | 1052 +------------------ plugins/multi-zen/src/chat.ts | 8 +- plugins/multi-zen/src/models.ts | 2 +- plugins/multi-zen/src/request.ts | 11 +- test/live/native-openai-cache.ts | 4 +- test/live/native-provider-approval.ts | 2 +- test/live/native-zen.ts | 2 +- test/unit/native-gateway.test.ts | 36 +- test/unit/native-openai-stream.test.ts | 2 +- test/unit/native-tool-discovery.test.ts | 2 +- test/unit/native-zen-request.test.ts | 4 +- 17 files changed, 1101 insertions(+), 1071 deletions(-) create mode 100644 plugins/multi-core/src/gateway/responses.ts diff --git a/plugins/multi-core/src/gateway/responses.ts b/plugins/multi-core/src/gateway/responses.ts new file mode 100644 index 0000000..d84ae3f --- /dev/null +++ b/plugins/multi-core/src/gateway/responses.ts @@ -0,0 +1,1034 @@ +import { isDeepStrictEqual } from 'node:util'; +import { type NormalizedContent, normalizeConversation, textContent } from './conversation.ts'; +import { isDirectToolAvailable } from './direct-tools.ts'; +import type { + Emit, + MessagesRequest, + MessagesResponse, + ResponseContentBlock, + StopReason, +} from './messages.ts'; +import { type SafeguardProvider, safeguardResults } from './safeguards.ts'; +import { callId, toolName } from './tools.ts'; + +// Anthropic Messages <-> OpenAI Responses, for native Claude Code workers. +/** Signature prefix OpenAI reasoning state carries; other providers pass their own. */ +export const OPENAI_SIGNATURE_PREFIX = 'multi-openai:'; +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; + +export type Effort = (typeof EFFORTS)[number]; + +// --------------------------------------------------------------------------- +// OpenAI Responses, as the gateway sends and reads them. +// --------------------------------------------------------------------------- + +export type ResponsesInputContent = NormalizedContent; + +export type ResponsesInputItem = + | { role: 'user' | 'assistant' | 'developer'; content: ResponsesInputContent[] } + | { type: 'function_call'; call_id: string; name: string; arguments: string } + | { type: 'function_call_output'; call_id: string; output: string | ResponsesInputContent[] } + | { type: 'reasoning'; id?: string; encrypted_content: string; summary: unknown }; + +interface ResponsesTool { + type: 'function'; + name: string; + description: string; + parameters: unknown; + strict: boolean; +} + +type ResponsesToolChoice = 'auto' | 'none' | 'required' | { type: 'function'; name?: string }; + +export interface ResponsesRequest { + model: string; + prompt_cache_key?: string; + instructions: string; + input: ResponsesInputItem[]; + tools: ResponsesTool[]; + text?: { format: { type: 'json_schema'; name: string; schema: unknown; strict: boolean } }; + tool_choice: ResponsesToolChoice; + parallel_tool_calls: boolean; + reasoning: { effort: Effort; summary: 'auto' }; + include: string[]; + store: boolean; + stream: boolean; +} + +interface ResponsesUsage { + input_tokens?: number; + output_tokens?: number; + input_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; +} + +interface ResponsesResponse { + id: string; + status?: string; + output?: ResponsesOutputItem[]; + usage?: ResponsesUsage | null; + incomplete_details?: { reason?: string }; + error?: { message?: string }; +} + +/** Part of a completed `message` output item. */ +interface ResponsesOutputContent { + type: string; + text?: string; + refusal?: string; +} + +/** The output items this gateway understands; any other `type` is rejected. */ +type ResponsesOutputItem = ( + | { type: 'message'; content?: ResponsesOutputContent[] } + | { type: 'function_call'; call_id?: string; name?: string; arguments?: string } + | { type: 'reasoning'; encrypted_content?: string | null; summary?: unknown } +) & { id?: string }; + +/** Streamed events the translation acts on. Any other `type` is ignored, exactly + * as an unrecognised event was before it had a name here. */ +type ResponseStreamEvent = + | { type: 'response.created'; response: ResponsesResponse } + | { type: 'response.output_item.added'; output_index: number; item: ResponsesOutputItem } + | { type: 'response.output_item.done'; output_index: number; item: ResponsesOutputItem } + | { type: 'response.output_text.delta'; output_index: number; delta: string } + | { type: 'response.refusal.delta'; output_index: number; delta: string } + | { type: 'response.function_call_arguments.delta'; output_index: number; delta: string } + | { type: 'response.reasoning_summary_text.delta'; output_index: number; delta: string } + | { type: 'response.completed'; response: ResponsesResponse } + | { type: 'response.done'; response: ResponsesResponse } + | { type: 'response.incomplete'; response: ResponsesResponse } + | { type: 'response.failed'; response?: Pick; message?: string } + | { type: 'error'; response?: Pick; message?: string }; + +/** Provider reasoning state, round-tripped through an opaque Claude signature. */ +interface ReasoningState { + type: 'reasoning'; + id?: string; + encrypted_content: string; + summary?: unknown; +} + +// Boundary guards: JSON.parse and the provider stream hand us `unknown`. +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isOutputItem(value: unknown, done: boolean): value is ResponsesOutputItem { + if ( + !isRecord(value) || + typeof value.type !== 'string' || + (value.id !== undefined && typeof value.id !== 'string') + ) { + return false; + } + if (value.type === 'function_call') { + return ['call_id', 'name', 'arguments'].every((key) => + value[key] === undefined ? !done : typeof value[key] === 'string', + ); + } + if (value.type === 'message') { + return ( + value.content === undefined || + (Array.isArray(value.content) && + value.content.every( + (part) => + isRecord(part) && + (part.type === 'output_text' + ? typeof part.text === 'string' + : part.type === 'refusal' && typeof part.refusal === 'string'), + )) + ); + } + if (value.type === 'reasoning') { + return ( + (value.encrypted_content === undefined || + (!done && value.encrypted_content === null) || + typeof value.encrypted_content === 'string') && + (value.summary === undefined || + (Array.isArray(value.summary) && + value.summary.every( + (part) => + isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string', + ))) + ); + } + return false; +} + +function validResponse(value: unknown): boolean { + if (!isRecord(value) || typeof value.id !== 'string') { + return false; + } + if ( + value.output !== undefined && + (!Array.isArray(value.output) || !value.output.every((item) => isOutputItem(item, true))) + ) { + return false; + } + if (value.status !== undefined && typeof value.status !== 'string') { + return false; + } + if (value.usage == null) { + return true; + } + if (!isRecord(value.usage)) { + return false; + } + const count = (v: unknown) => + v === undefined || (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0); + const usage = value.usage; + return ( + count(usage.input_tokens) && + count(usage.output_tokens) && + (usage.input_tokens_details === undefined || + (isRecord(usage.input_tokens_details) && + count(usage.input_tokens_details.cached_tokens) && + count(usage.input_tokens_details.cache_write_tokens))) + ); +} + +/** Ignore new event types; validate every known field before narrowing JSON. */ +function isStreamEvent(value: unknown): value is ResponseStreamEvent { + if (!isRecord(value) || typeof value.type !== 'string') { + throw new Error('Malformed OpenAI stream event'); + } + const indexed = Number.isSafeInteger(value.output_index) && Number(value.output_index) >= 0; + let valid: boolean; + switch (value.type) { + case 'response.created': + case 'response.completed': + case 'response.done': + case 'response.incomplete': + valid = validResponse(value.response); + break; + case 'response.output_item.added': + case 'response.output_item.done': + valid = indexed && isOutputItem(value.item, value.type.endsWith('.done')); + break; + case 'response.output_text.delta': + case 'response.refusal.delta': + case 'response.function_call_arguments.delta': + case 'response.reasoning_summary_text.delta': + valid = indexed && typeof value.delta === 'string'; + break; + case 'response.failed': + case 'error': + valid = + (value.message === undefined || typeof value.message === 'string') && + (value.response === undefined || + (isRecord(value.response) && + (value.response.error === undefined || + (isRecord(value.response.error) && + (value.response.error.message === undefined || + typeof value.response.error.message === 'string'))))); + break; + default: + return false; + } + if (!valid) { + throw new Error(`OpenAI sent a malformed ${value.type} event`); + } + return true; +} + +function isReasoningState(value: unknown): value is ReasoningState { + return ( + isRecord(value) && + isOutputItem(value, true) && + value.type === 'reasoning' && + typeof value.encrypted_content === 'string' && + value.encrypted_content.length > 0 + ); +} + +function isEffort(value: string): value is Effort { + return (EFFORTS as readonly string[]).includes(value); +} + +// Only rewrite Claude-bound history when it contains a provider's opaque state; +// the caller supplies every provider signature prefix to strip. +export function forAnthropic( + body: MessagesRequest, + foreignPrefixes: readonly string[], +): MessagesRequest { + let changed = false; + const messages = body.messages + ?.map((message) => { + if (message.role !== 'assistant' || !Array.isArray(message.content)) { + return message; + } + const content = message.content.filter((block) => { + const foreign = + block.type === 'thinking' && + foreignPrefixes.some((prefix) => block.signature?.startsWith(prefix)); + changed ||= Boolean(foreign); + return !foreign; + }); + return { ...message, content }; + }) + .filter((message) => !Array.isArray(message.content) || message.content.length); + return changed ? { ...body, messages } : body; +} + +function validateRequestOptions(body: MessagesRequest) { + if ( + body.stop_sequences !== undefined && + (!Array.isArray(body.stop_sequences) || + body.stop_sequences.some((s) => typeof s !== 'string' || !s.length)) + ) { + throw new Error('Invalid stop sequences'); + } + if (body.tools !== undefined && !Array.isArray(body.tools)) { + throw new Error('tools must be an array'); + } + if (body.stream !== undefined && typeof body.stream !== 'boolean') { + throw new Error('stream must be boolean'); + } + for (const key of ['output_config', 'output_format', 'thinking', 'tool_choice'] as const) { + if (body[key] != null && !isRecord(body[key])) { + throw new Error(`Invalid ${key}`); + } + } +} + +function outputFormat(body: MessagesRequest) { + const format = body.output_config?.format ?? body.output_format; + if ( + format != null && + (format.type !== 'json_schema' || + !format.schema || + typeof format.schema !== 'object' || + Array.isArray(format.schema)) + ) { + throw new Error('Unsupported output format: expected json_schema with an object schema'); + } + return format; +} + +function inputTool(tool: unknown): ResponsesTool { + if (!isRecord(tool)) { + throw new Error('Invalid tool'); + } + if (tool.type && tool.type !== 'custom') { + throw new Error(`Unsupported server tool: ${tool.type}`); + } + if (typeof tool.name !== 'string' || !tool.name.trim() || !isRecord(tool.input_schema)) { + throw new Error('Invalid function tool'); + } + if (tool.description !== undefined && typeof tool.description !== 'string') { + throw new Error('Invalid tool description'); + } + return { + type: 'function', + name: toolName(tool.name), + description: tool.description ?? '', + parameters: tool.input_schema, + strict: false, + }; +} + +function toolChoice( + choice: MessagesRequest['tool_choice'], + tools: ResponsesTool[], +): ResponsesToolChoice { + const wanted = choice?.type ?? 'auto'; + if (!['auto', 'any', 'none', 'tool'].includes(wanted)) { + throw new Error('Unsupported tool choice'); + } + const name = choice?.name; + if ( + wanted === 'tool' && + (typeof name !== 'string' || !tools.some((tool) => tool.name === toolName(name))) + ) { + throw new Error('Named tool choice must reference a declared tool'); + } + if ( + choice?.disable_parallel_tool_use !== undefined && + typeof choice.disable_parallel_tool_use !== 'boolean' + ) { + throw new Error('Invalid parallel tool choice'); + } + switch (wanted) { + case 'tool': + if (typeof name !== 'string') { + throw new Error('Missing named tool choice'); + } + return { type: 'function', name: toolName(name) }; + case 'any': + return 'required'; + case 'none': + return 'none'; + default: + return 'auto'; + } +} + +function budgetEffort(thinking: MessagesRequest['thinking']): Effort { + if (thinking?.type === 'disabled') { + return 'low'; + } + const budget = thinking?.budget_tokens; + if (budget === undefined) { + return 'medium'; + } + if (budget <= 1024) { + return 'low'; + } + if (budget <= 8192) { + return 'medium'; + } + if (budget <= 24576) { + return 'high'; + } + return 'xhigh'; +} + +function reasoningEffort(body: MessagesRequest): Effort { + const thinking = body.thinking; + if (thinking && !['enabled', 'adaptive', 'disabled', 'auto'].includes(thinking.type)) { + throw new Error('Unsupported thinking configuration'); + } + const budget = thinking?.budget_tokens; + if (budget !== undefined && (!Number.isSafeInteger(budget) || budget < 0)) { + throw new Error('Invalid thinking budget'); + } + const effort = body.output_config?.effort ?? budgetEffort(thinking); + if (!isEffort(effort)) { + throw new Error(`Unsupported reasoning effort: ${effort}`); + } + return effort; +} + +function decodeReasoning(signaturePrefix: string) { + return (block: { type: string; signature?: string }): ResponsesInputItem | undefined => { + if (block.type === 'redacted_thinking' || !block.signature?.startsWith(signaturePrefix)) { + return undefined; + } + const item: unknown = JSON.parse( + Buffer.from(block.signature.slice(signaturePrefix.length), 'base64url').toString(), + ); + if (!isReasoningState(item)) { + throw new Error('Invalid reasoning state'); + } + return { + type: 'reasoning', + id: item.id, + encrypted_content: item.encrypted_content, + summary: item.summary ?? [], + }; + }; +} + +export function toResponses( + body: MessagesRequest, + model: string, + signaturePrefix = OPENAI_SIGNATURE_PREFIX, +): ResponsesRequest { + validateRequestOptions(body); + const format = outputFormat(body); + const input = normalizeConversation(body.messages, decodeReasoning(signaturePrefix)); + // Claude's tool-search flow keeps deferred schemas out of the initial model + // request. A loaded tool is resent without defer_loading on the next turn. + const tools = (body.tools ?? []) + .filter( + (tool) => + !isDeferredTool(tool) || + (typeof tool.name === 'string' && isDirectToolAvailable(body, tool.name)), + ) + .map(inputTool); + const choice = toolChoice(body.tool_choice, tools); + const effort = reasoningEffort(body); + return { + model, + instructions: textContent(body.system ?? ''), + input, + tools, + ...(format + ? { + text: { + format: { + type: 'json_schema' as const, + name: 'claude_output', + schema: format.schema, + strict: true, + }, + }, + } + : {}), + tool_choice: choice, + parallel_tool_calls: !body.tool_choice?.disable_parallel_tool_use, + reasoning: { effort, summary: 'auto' }, + include: ['reasoning.encrypted_content'], + // Codex subscriptions require store:false and streaming; max_tokens is unsupported. + store: false, + stream: true, + }; +} + +function isDeferredTool(tool: unknown): boolean { + return isRecord(tool) && tool.defer_loading === true; +} + +export async function* readSse(stream: AsyncIterable): AsyncGenerator { + const decoder = new TextDecoder(); + let pending = ''; + let data: string[] = []; + let dataBytes = 0; + let totalBytes = 0; + const addData = (line: string) => { + dataBytes += Buffer.byteLength(line); + if (dataBytes > 8 * 1024 * 1024) { + throw new Error('OpenAI SSE event exceeds 8 MiB'); + } + data.push(line); + }; + const parse = (): unknown => { + const value = data.join('\n'); + if (Buffer.byteLength(value) > 8 * 1024 * 1024) { + throw new Error('OpenAI SSE event exceeds 8 MiB'); + } + data = []; + dataBytes = 0; + return value && value !== '[DONE]' ? JSON.parse(value) : null; + }; + const consumeLine = (line: string): unknown => { + if (!line) { + return parse(); + } + if (line.startsWith('data:')) { + addData(line.slice(5).replace(/^ /, '')); + } + return null; + }; + for await (const chunk of stream) { + totalBytes += chunk.byteLength; + if (totalBytes > 32 * 1024 * 1024) { + throw new Error('OpenAI response exceeds 32 MiB'); + } + pending += decoder.decode(chunk, { stream: true }); + if (Buffer.byteLength(pending) > 8 * 1024 * 1024) { + throw new Error('OpenAI SSE buffer exceeds 8 MiB'); + } + for (let index = pending.indexOf('\n'); index !== -1; index = pending.indexOf('\n')) { + const line = pending.slice(0, index).replace(/\r$/, ''); + pending = pending.slice(index + 1); + const event = consumeLine(line); + if (event) { + yield event; + } + } + } + pending += decoder.decode(); + if (pending.startsWith('data:')) { + addData(pending.slice(5).trimStart()); + } + const event = parse(); + if (event) { + yield event; + } +} + +interface OutputSlot { + item: ResponsesOutputItem; + text: string; + arguments: string; + done: boolean; + block?: ResponseContentBlock; + index?: number; + emitted: number; +} + +function finalOutputItem( + previous: ResponsesOutputItem, + item: ResponsesOutputItem, +): ResponsesOutputItem { + if (previous.type !== item.type) { + throw new Error('OpenAI output item changed type'); + } + const before = [previous.id]; + const after = [item.id]; + if (previous.type === 'function_call' && item.type === 'function_call') { + before.push(previous.call_id, previous.name); + after.push(item.call_id, item.name); + } + if (before.some((value, index) => value && after[index] && value !== after[index])) { + throw new Error('OpenAI output item changed identity'); + } + // Prefer final encrypted state; retain an earlier snapshot only when omitted. + if (previous.type === 'reasoning' && item.type === 'reasoning') { + return { ...previous, ...item }; + } + return { ...item, id: item.id ?? previous.id }; +} + +function outputValue(item: ResponsesOutputItem): unknown { + switch (item.type) { + case 'message': + return item.content; + case 'function_call': + return [item.call_id, item.name, item.arguments]; + case 'reasoning': + // The provider may rotate encrypted_content between output_item.done and + // response.completed while preserving the visible reasoning summary. + // Already emitted signatures retain the completed item snapshot; opaque + // ciphertext is not a stable identity field for terminal reconciliation. + return [item.summary ?? []]; + } +} + +export interface ResponseOptions { + safeguards?: unknown; + safeguardProvider?: SafeguardProvider; + toolNames?: ReadonlyMap; + stopSequences?: readonly string[]; + signaturePrefix?: string; + requireUsage?: boolean; + /** Local input estimate for message_start; the provider only reports usage at completion. */ + inputTokens?: number; +} + +/** Assembles one ordered Claude response from possibly interleaved OpenAI output items. */ +class ResponseStream { + private content: ResponseContentBlock[] = []; + private slots = new Map(); + private message?: MessagesResponse; + private cursor = 0; + private stopped: string | null = null; + /** A stop sequence ended the content; the terminal event is awaited only for usage. */ + stopPending = false; + completed = false; + private model: string; + private emit: Emit; + private options: ResponseOptions; + + constructor(model: string, emit: Emit, options: ResponseOptions) { + this.model = model; + this.emit = emit; + this.options = options; + } + + result(): MessagesResponse { + if (!this.completed || !this.message) { + throw new Error('OpenAI stream ended before completion'); + } + return this.message; + } + + accept(event: ResponseStreamEvent) { + switch (event.type) { + case 'response.created': + this.start(event.response); + return; + case 'response.failed': + case 'error': + throw new Error(event.message ?? event.response?.error?.message ?? 'OpenAI stream failed'); + case 'response.completed': + case 'response.done': + case 'response.incomplete': + this.complete(event); + return; + default: + if (!this.message) { + throw new Error('OpenAI stream omitted response.created'); + } + if (this.stopPending) { + return; + } + this.update(event); + this.drain(); + if (this.stopped) { + this.stopPending = true; + } + } + } + + /** The terminal event never came in time; the answer is complete but its usage is unknown. */ + finishWithoutUsage() { + if (this.stopPending && !this.completed) { + this.finish('stop_sequence'); + } + } + + private start(response: ResponsesResponse) { + if (this.message) { + return; + } + this.message = { + id: response.id, + type: 'message', + role: 'assistant', + model: this.model, + content: this.content, + stop_reason: null, + stop_sequence: null, + // Claude Code reads the input count from message_start; the terminal + // message_delta replaces this estimate with the provider's real usage. + usage: { input_tokens: this.options.inputTokens ?? 0, output_tokens: 0 }, + }; + this.emit('message_start', { message: { ...this.message, content: [] } }); + } + + private finish(stopReason: StopReason, usage?: ResponsesUsage | null) { + const message = this.message; + if (!message) { + throw new Error('OpenAI stream omitted response.created'); + } + const cached = usage?.input_tokens_details?.cached_tokens ?? 0; + const written = usage?.input_tokens_details?.cache_write_tokens ?? 0; + message.usage = { + input_tokens: Math.max(0, (usage?.input_tokens ?? 0) - cached - written), + output_tokens: usage?.output_tokens ?? 0, + cache_read_input_tokens: cached, + cache_creation_input_tokens: written, + }; + message.multi_usage = { + source: usage ? 'provider' : 'unavailable', + ...(usage ? { total_tokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) } : {}), + model: this.model, + }; + message.stop_reason = stopReason; + message.stop_sequence = this.stopped; + const safeguard_results = safeguardResults( + this.options.safeguards, + message.content, + this.options.safeguardProvider ?? 'openai', + ); + if (safeguard_results) { + message.safeguard_results = safeguard_results; + } + this.emit('message_delta', { + delta: { + stop_reason: stopReason, + stop_sequence: this.stopped, + ...(safeguard_results === undefined ? {} : { safeguard_results }), + }, + usage: message.usage, + }); + this.emit('message_stop', {}); + this.completed = true; + } + + private complete( + event: Extract< + ResponseStreamEvent, + { type: 'response.completed' | 'response.incomplete' | 'response.done' } + >, + ) { + if (this.stopPending) { + this.start(event.response); + this.finish('stop_sequence', event.response.usage); + return; + } + if ( + this.options.requireUsage && + (event.response.usage?.input_tokens === undefined || + event.response.usage.output_tokens === undefined) + ) { + throw new Error('Provider completed without token usage; cost accounting is unavailable'); + } + this.start(event.response); + const incomplete = this.terminalIncomplete(event); + this.reconcile(event.response.output); + if ([...this.slots.values()].some((slot) => !slot.done)) { + throw new Error('OpenAI completed with an unfinished content block'); + } + this.drain(); + if (this.stopped) { + this.finish('stop_sequence', event.response.usage); + return; + } + if (!this.content.length || this.cursor !== this.slots.size) { + throw new Error('OpenAI completed with missing output'); + } + let stopReason: StopReason = this.content.some((block) => block.type === 'tool_use') + ? 'tool_use' + : 'end_turn'; + if (incomplete) { + stopReason = 'max_tokens'; + } + this.finish(stopReason, event.response.usage); + } + + /** Whether the terminal event is a max-output cut-off; any other failed state throws. */ + private terminalIncomplete( + event: Extract< + ResponseStreamEvent, + { type: 'response.completed' | 'response.incomplete' | 'response.done' } + >, + ): boolean { + if (event.response.status && !['completed', 'incomplete'].includes(event.response.status)) { + throw new Error(`OpenAI terminal response has status ${event.response.status}`); + } + const incomplete = + event.type === 'response.incomplete' || event.response.status === 'incomplete'; + if (incomplete && event.response.incomplete_details?.reason !== 'max_output_tokens') { + throw new Error( + `OpenAI response incomplete: ${event.response.incomplete_details?.reason ?? 'unknown'}`, + ); + } + return incomplete; + } + + private reconcile(output?: ResponsesOutputItem[]) { + // Some streams omit the redundant output array or send []; their finished + // incremental items remain authoritative. A populated array must agree. + if (!output?.length) { + return; + } + if ([...this.slots.keys()].some((index) => index >= output.length)) { + throw new Error('OpenAI terminal output omitted a streamed item'); + } + for (const [index, item] of output.entries()) { + const slot = this.slots.get(index) ?? this.addSlot(index, item); + this.finishItem(slot, item); + } + } + + private addSlot(index: number, item: ResponsesOutputItem): OutputSlot { + if (this.slots.has(index)) { + throw new Error('Duplicate OpenAI output item'); + } + const slot = { item, text: '', arguments: '', done: false, emitted: 0 }; + this.slots.set(index, slot); + return slot; + } + + private update(event: Extract) { + if (event.type === 'response.output_item.added') { + this.addSlot(event.output_index, event.item); + return; + } + if (event.type === 'response.output_item.done') { + const slot = + this.slots.get(event.output_index) ?? this.addSlot(event.output_index, event.item); + this.finishItem(slot, event.item); + return; + } + const slot = this.slots.get(event.output_index); + if (!slot || slot.done) { + throw new Error('OpenAI event without an active output item'); + } + if (event.type === 'response.function_call_arguments.delta') { + if (slot.item.type !== 'function_call') { + throw new Error('Arguments without function call'); + } + slot.arguments += event.delta; + } else { + const thinking = event.type === 'response.reasoning_summary_text.delta'; + if (slot.item.type !== (thinking ? 'reasoning' : 'message')) { + throw new Error('OpenAI delta has the wrong output type'); + } + slot.text += event.delta; + } + } + + private finishItem(slot: OutputSlot, item: ResponsesOutputItem) { + const final = finalOutputItem(slot.item, item); + if (slot.done) { + if (!isDeepStrictEqual(outputValue(slot.item), outputValue(final))) { + throw new Error('OpenAI terminal output changed a completed item'); + } + return; + } + if (final.type === 'message') { + const text = (final.content ?? []) + .map((block) => (block.type === 'refusal' ? block.refusal : block.text)) + .join(''); + if (!text.startsWith(slot.text)) { + throw new Error('OpenAI text changed after streaming'); + } + slot.text = text; + } else if (final.type === 'function_call') { + if (!final.arguments?.startsWith(slot.arguments)) { + throw new Error('OpenAI function arguments changed after streaming'); + } + slot.arguments = final.arguments; + } else if (!slot.text && Array.isArray(final.summary)) { + slot.text = final.summary.map((part) => (part as { text: string }).text).join('\n'); + } + slot.item = final; + slot.done = true; + } + + private createBlock(slot: OutputSlot): ResponseContentBlock { + const item = slot.item; + if (item.type === 'reasoning') { + return { type: 'thinking', thinking: '', signature: '' }; + } + if (item.type === 'message') { + return { type: 'text', text: '' }; + } + if (!item.call_id || !item.name || typeof item.arguments !== 'string') { + throw new Error('Incomplete function call'); + } + if (slot.arguments && slot.arguments !== item.arguments) { + throw new Error('OpenAI function arguments changed after streaming'); + } + const input: unknown = JSON.parse(item.arguments); + if (!isRecord(input)) { + throw new Error('OpenAI function arguments must be an object'); + } + const name = this.options.toolNames?.get(item.name) ?? item.name; + if (this.options.toolNames && !this.options.toolNames.has(item.name)) { + throw new Error('OpenAI returned an undeclared tool'); + } + const id = callId(item.call_id); + if (this.content.some((block) => block.type === 'tool_use' && block.id === id)) { + throw new Error('OpenAI repeated a tool call ID'); + } + return { type: 'tool_use', id, name, input }; + } + + private beginBlock(slot: OutputSlot) { + if (slot.block && slot.index !== undefined) { + return { block: slot.block, index: slot.index }; + } + const block = this.createBlock(slot); + const index = this.content.length; + slot.block = block; + slot.index = index; + this.content.push(block); + this.emit('content_block_start', { + index, + content_block: block.type === 'tool_use' ? { ...block, input: {} } : { ...block }, + }); + return { block, index }; + } + + private textLimit(slot: OutputSlot): number { + let matchAt = Infinity; + for (const stop of this.options.stopSequences ?? []) { + const at = slot.text.indexOf(stop); + if (at >= 0 && at < matchAt) { + matchAt = at; + this.stopped = stop; + } + } + if (this.stopped) { + return matchAt; + } + if (slot.done) { + return slot.text.length; + } + return prefixSafeLength(slot.text, this.options.stopSequences ?? []); + } + + private writeBlock(slot: OutputSlot, block: ResponseContentBlock, index: number) { + if (block.type === 'tool_use') { + this.emit('content_block_delta', { + index, + delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) }, + }); + return; + } + const limit = block.type === 'text' ? this.textLimit(slot) : slot.text.length; + const text = slot.text.slice(slot.emitted, limit); + if (text) { + this.emit('content_block_delta', { + index, + delta: + block.type === 'text' + ? { type: 'text_delta', text } + : { type: 'thinking_delta', thinking: text }, + }); + } + slot.emitted = limit; + if (block.type === 'text') { + block.text = slot.text.slice(0, limit); + } else { + block.thinking = slot.text.slice(0, limit); + } + } + + private closeBlock(slot: OutputSlot, block: ResponseContentBlock, index: number) { + if (slot.item.type === 'reasoning' && block.type === 'thinking') { + if (!isReasoningState(slot.item)) { + throw new Error('OpenAI omitted encrypted reasoning state'); + } + block.signature = + (this.options.signaturePrefix ?? OPENAI_SIGNATURE_PREFIX) + + Buffer.from(JSON.stringify(slot.item)).toString('base64url'); + this.emit('content_block_delta', { + index, + delta: { type: 'signature_delta', signature: block.signature }, + }); + } + this.emit('content_block_stop', { index }); + } + + private drain() { + while (this.slots.has(this.cursor) && !this.stopped) { + const slot = this.slots.get(this.cursor); + if (!slot) { + return; + } + if (slot.item.type === 'function_call' && !slot.done) { + return; + } + const { block, index } = this.beginBlock(slot); + this.writeBlock(slot, block, index); + if (!slot.done && !this.stopped) { + return; + } + this.closeBlock(slot, block, index); + this.cursor++; + } + } +} + +/** Hold suffixes that might become a stop sequence in a later text delta. */ +export function prefixSafeLength(text: string, stops: readonly string[]): number { + let limit = text.length; + for (const stop of stops) { + for (let length = 1; length < stop.length; length++) { + if (text.endsWith(stop.slice(0, length))) { + limit = Math.min(limit, text.length - length); + } + } + } + return limit; +} + +/** How long a stream that already hit a stop sequence may take to report its usage. */ +const STOP_USAGE_WAIT_MS = 5000; + +export async function fromResponses( + stream: AsyncIterable, + model: string, + emit: Emit = () => {}, + options: ResponseOptions = {}, +): Promise { + const response = new ResponseStream(model, emit, options); + const events = readSse(stream)[Symbol.asyncIterator](); + let deadline: Promise<'late'> | undefined; + let timer: NodeJS.Timeout | undefined; + try { + while (!response.completed) { + const next = events.next(); + const step = deadline ? await Promise.race([next, deadline]) : await next; + if (step === 'late') { + response.finishWithoutUsage(); + break; + } + if (step.done) { + break; + } + if (!isStreamEvent(step.value)) { + continue; + } + response.accept(step.value); + if (response.stopPending && !deadline) { + // The text is final, but the provider bills the whole response: keep reading to its usage. + deadline = new Promise<'late'>((resolve) => { + timer = setTimeout(() => resolve('late'), STOP_USAGE_WAIT_MS); + }); + } + } + } finally { + clearTimeout(timer); + // A stream abandoned early is released without waiting on its next chunk. + events.return?.(undefined).catch(() => undefined); + } + response.finishWithoutUsage(); + return response.result(); +} diff --git a/plugins/multi-core/src/gateway/tokens.ts b/plugins/multi-core/src/gateway/tokens.ts index 1fa651a..18c8f58 100644 --- a/plugins/multi-core/src/gateway/tokens.ts +++ b/plugins/multi-core/src/gateway/tokens.ts @@ -1,8 +1,5 @@ import { getEncoding } from 'js-tiktoken'; -import type { - ResponsesInputContent, - ResponsesRequest, -} from '../../../multi-openai/src/responses.ts'; +import type { ResponsesInputContent, ResponsesRequest } from './responses.ts'; let encoding: ReturnType | undefined; /** Shared local text estimate; providers may use different tokenizers. */ diff --git a/plugins/multi-core/src/launcher.ts b/plugins/multi-core/src/launcher.ts index 4f8df42..26899ad 100644 --- a/plugins/multi-core/src/launcher.ts +++ b/plugins/multi-core/src/launcher.ts @@ -22,6 +22,7 @@ import { import { antigravityPermissionPolicy } from '../../multi-antigravity/src/permissions.ts'; import { ANTIGRAVITY_TOOLS } from '../../multi-antigravity/src/progress.ts'; import { antigravityUsageReader } from '../../multi-antigravity/src/usage-adapter.ts'; +import type { Effort } from '../../multi-core/src/gateway/responses.ts'; import { CursorHarness } from '../../multi-cursor/src/harness.ts'; import type { CursorModelOption } from '../../multi-cursor/src/models.ts'; import { @@ -52,7 +53,6 @@ import { OPENAI_DEFAULT_WORKER_MODEL, OPENAI_WORKER_EFFORT, } from '../../multi-openai/src/models.ts'; -import type { Effort } from '../../multi-openai/src/responses.ts'; import { openAIUsageReader } from '../../multi-openai/src/usage-adapter.ts'; import { readZenKey } from '../../multi-zen/src/auth.ts'; import { diff --git a/plugins/multi-grok/src/models.ts b/plugins/multi-grok/src/models.ts index d416bfa..ad4d1c1 100644 --- a/plugins/multi-grok/src/models.ts +++ b/plugins/multi-grok/src/models.ts @@ -4,7 +4,7 @@ import { executableInvocation, resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; -import type { Effort } from '../../multi-openai/src/responses.ts'; +import type { Effort } from '../../multi-core/src/gateway/responses.ts'; import { grokEnvironment } from './cli.ts'; /** diff --git a/plugins/multi-openai/src/approval.ts b/plugins/multi-openai/src/approval.ts index e794198..168b825 100644 --- a/plugins/multi-openai/src/approval.ts +++ b/plugins/multi-openai/src/approval.ts @@ -9,8 +9,8 @@ import type { } from '../../multi-core/src/gateway/approval.ts'; import { NativeApprovalBridge } from '../../multi-core/src/gateway/approval.ts'; import type { GatewayFetch } from '../../multi-core/src/gateway/fetch.ts'; +import { readSse } from '../../multi-core/src/gateway/responses.ts'; import { codexRequest } from './auth.ts'; -import { readSse } from './responses.ts'; const REVIEW_FAILURE_EVENTS: readonly unknown[] = [ 'response.failed', diff --git a/plugins/multi-openai/src/models.ts b/plugins/multi-openai/src/models.ts index 32e3604..c45df5e 100644 --- a/plugins/multi-openai/src/models.ts +++ b/plugins/multi-openai/src/models.ts @@ -1,4 +1,4 @@ -import type { Effort } from './responses.ts'; +import type { Effort } from '../../multi-core/src/gateway/responses.ts'; export const MODELS = { 'openai-native': 'gpt-6-astra', diff --git a/plugins/multi-openai/src/responses.ts b/plugins/multi-openai/src/responses.ts index c31a3ea..5e8c19f 100644 --- a/plugins/multi-openai/src/responses.ts +++ b/plugins/multi-openai/src/responses.ts @@ -1,1038 +1,20 @@ -import { isDeepStrictEqual } from 'node:util'; +import type { MessagesRequest } from '../../multi-core/src/gateway/messages.ts'; import { - type NormalizedContent, - normalizeConversation, - textContent, -} from '../../multi-core/src/gateway/conversation.ts'; -import { isDirectToolAvailable } from '../../multi-core/src/gateway/direct-tools.ts'; -import type { - Emit, - MessagesRequest, - MessagesResponse, - ResponseContentBlock, - StopReason, -} from '../../multi-core/src/gateway/messages.ts'; -import { - type SafeguardProvider, - safeguardResults, -} from '../../multi-core/src/gateway/safeguards.ts'; -import { callId, toolName } from '../../multi-core/src/gateway/tools.ts'; - -// Anthropic Messages <-> OpenAI Responses, for native Claude Code workers. -const SIGNATURE_PREFIX = 'multi-openai:'; -const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; - -export type Effort = (typeof EFFORTS)[number]; - -// --------------------------------------------------------------------------- -// OpenAI Responses, as the gateway sends and reads them. -// --------------------------------------------------------------------------- - -export type ResponsesInputContent = NormalizedContent; - -export type ResponsesInputItem = - | { role: 'user' | 'assistant' | 'developer'; content: ResponsesInputContent[] } - | { type: 'function_call'; call_id: string; name: string; arguments: string } - | { type: 'function_call_output'; call_id: string; output: string | ResponsesInputContent[] } - | { type: 'reasoning'; id?: string; encrypted_content: string; summary: unknown }; - -interface ResponsesTool { - type: 'function'; - name: string; - description: string; - parameters: unknown; - strict: boolean; -} - -type ResponsesToolChoice = 'auto' | 'none' | 'required' | { type: 'function'; name?: string }; - -export interface ResponsesRequest { - model: string; - prompt_cache_key?: string; - instructions: string; - input: ResponsesInputItem[]; - tools: ResponsesTool[]; - text?: { format: { type: 'json_schema'; name: string; schema: unknown; strict: boolean } }; - tool_choice: ResponsesToolChoice; - parallel_tool_calls: boolean; - reasoning: { effort: Effort; summary: 'auto' }; - include: string[]; - store: boolean; - stream: boolean; -} - -interface ResponsesUsage { - input_tokens?: number; - output_tokens?: number; - input_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; -} - -interface ResponsesResponse { - id: string; - status?: string; - output?: ResponsesOutputItem[]; - usage?: ResponsesUsage | null; - incomplete_details?: { reason?: string }; - error?: { message?: string }; -} - -/** Part of a completed `message` output item. */ -interface ResponsesOutputContent { - type: string; - text?: string; - refusal?: string; -} - -/** The output items this gateway understands; any other `type` is rejected. */ -type ResponsesOutputItem = ( - | { type: 'message'; content?: ResponsesOutputContent[] } - | { type: 'function_call'; call_id?: string; name?: string; arguments?: string } - | { type: 'reasoning'; encrypted_content?: string | null; summary?: unknown } -) & { id?: string }; - -/** Streamed events the translation acts on. Any other `type` is ignored, exactly - * as an unrecognised event was before it had a name here. */ -type ResponseStreamEvent = - | { type: 'response.created'; response: ResponsesResponse } - | { type: 'response.output_item.added'; output_index: number; item: ResponsesOutputItem } - | { type: 'response.output_item.done'; output_index: number; item: ResponsesOutputItem } - | { type: 'response.output_text.delta'; output_index: number; delta: string } - | { type: 'response.refusal.delta'; output_index: number; delta: string } - | { type: 'response.function_call_arguments.delta'; output_index: number; delta: string } - | { type: 'response.reasoning_summary_text.delta'; output_index: number; delta: string } - | { type: 'response.completed'; response: ResponsesResponse } - | { type: 'response.done'; response: ResponsesResponse } - | { type: 'response.incomplete'; response: ResponsesResponse } - | { type: 'response.failed'; response?: Pick; message?: string } - | { type: 'error'; response?: Pick; message?: string }; - -/** Provider reasoning state, round-tripped through an opaque Claude signature. */ -interface ReasoningState { - type: 'reasoning'; - id?: string; - encrypted_content: string; - summary?: unknown; -} - -// Boundary guards: JSON.parse and the provider stream hand us `unknown`. -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isOutputItem(value: unknown, done: boolean): value is ResponsesOutputItem { - if ( - !isRecord(value) || - typeof value.type !== 'string' || - (value.id !== undefined && typeof value.id !== 'string') - ) { - return false; - } - if (value.type === 'function_call') { - return ['call_id', 'name', 'arguments'].every((key) => - value[key] === undefined ? !done : typeof value[key] === 'string', - ); - } - if (value.type === 'message') { - return ( - value.content === undefined || - (Array.isArray(value.content) && - value.content.every( - (part) => - isRecord(part) && - (part.type === 'output_text' - ? typeof part.text === 'string' - : part.type === 'refusal' && typeof part.refusal === 'string'), - )) - ); - } - if (value.type === 'reasoning') { - return ( - (value.encrypted_content === undefined || - (!done && value.encrypted_content === null) || - typeof value.encrypted_content === 'string') && - (value.summary === undefined || - (Array.isArray(value.summary) && - value.summary.every( - (part) => - isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string', - ))) - ); - } - return false; -} - -function validResponse(value: unknown): boolean { - if (!isRecord(value) || typeof value.id !== 'string') { - return false; - } - if ( - value.output !== undefined && - (!Array.isArray(value.output) || !value.output.every((item) => isOutputItem(item, true))) - ) { - return false; - } - if (value.status !== undefined && typeof value.status !== 'string') { - return false; - } - if (value.usage == null) { - return true; - } - if (!isRecord(value.usage)) { - return false; - } - const count = (v: unknown) => - v === undefined || (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0); - const usage = value.usage; - return ( - count(usage.input_tokens) && - count(usage.output_tokens) && - (usage.input_tokens_details === undefined || - (isRecord(usage.input_tokens_details) && - count(usage.input_tokens_details.cached_tokens) && - count(usage.input_tokens_details.cache_write_tokens))) - ); -} - -/** Ignore new event types; validate every known field before narrowing JSON. */ -function isStreamEvent(value: unknown): value is ResponseStreamEvent { - if (!isRecord(value) || typeof value.type !== 'string') { - throw new Error('Malformed OpenAI stream event'); - } - const indexed = Number.isSafeInteger(value.output_index) && Number(value.output_index) >= 0; - let valid: boolean; - switch (value.type) { - case 'response.created': - case 'response.completed': - case 'response.done': - case 'response.incomplete': - valid = validResponse(value.response); - break; - case 'response.output_item.added': - case 'response.output_item.done': - valid = indexed && isOutputItem(value.item, value.type.endsWith('.done')); - break; - case 'response.output_text.delta': - case 'response.refusal.delta': - case 'response.function_call_arguments.delta': - case 'response.reasoning_summary_text.delta': - valid = indexed && typeof value.delta === 'string'; - break; - case 'response.failed': - case 'error': - valid = - (value.message === undefined || typeof value.message === 'string') && - (value.response === undefined || - (isRecord(value.response) && - (value.response.error === undefined || - (isRecord(value.response.error) && - (value.response.error.message === undefined || - typeof value.response.error.message === 'string'))))); - break; - default: - return false; - } - if (!valid) { - throw new Error(`OpenAI sent a malformed ${value.type} event`); - } - return true; -} - -function isReasoningState(value: unknown): value is ReasoningState { - return ( - isRecord(value) && - isOutputItem(value, true) && - value.type === 'reasoning' && - typeof value.encrypted_content === 'string' && - value.encrypted_content.length > 0 - ); -} - -function isEffort(value: string): value is Effort { - return (EFFORTS as readonly string[]).includes(value); -} - -// Only rewrite Claude-bound history when it contains our provider's opaque state. + OPENAI_SIGNATURE_PREFIX, + forAnthropic as stripProviderSignatures, +} from '../../multi-core/src/gateway/responses.ts'; +import { ZEN_SIGNATURE_PREFIXES } from '../../multi-zen/src/chat.ts'; + +export { + fromResponses, + type ResponsesRequest, + toResponses, +} from '../../multi-core/src/gateway/responses.ts'; + +/** + * Compatibility entry for the gateway router, which still imports this module. + * The gateway owns the provider list; the shared translation takes it as a parameter. + */ export function forAnthropic(body: MessagesRequest): MessagesRequest { - let changed = false; - const messages = body.messages - ?.map((message) => { - if (message.role !== 'assistant' || !Array.isArray(message.content)) { - return message; - } - const content = message.content.filter((block) => { - const foreign = - block.type === 'thinking' && - [SIGNATURE_PREFIX, 'multi-zen-responses:', 'multi-zen-chat:'].some((prefix) => - block.signature?.startsWith(prefix), - ); - changed ||= Boolean(foreign); - return !foreign; - }); - return { ...message, content }; - }) - .filter((message) => !Array.isArray(message.content) || message.content.length); - return changed ? { ...body, messages } : body; -} - -function validateRequestOptions(body: MessagesRequest) { - if ( - body.stop_sequences !== undefined && - (!Array.isArray(body.stop_sequences) || - body.stop_sequences.some((s) => typeof s !== 'string' || !s.length)) - ) { - throw new Error('Invalid stop sequences'); - } - if (body.tools !== undefined && !Array.isArray(body.tools)) { - throw new Error('tools must be an array'); - } - if (body.stream !== undefined && typeof body.stream !== 'boolean') { - throw new Error('stream must be boolean'); - } - for (const key of ['output_config', 'output_format', 'thinking', 'tool_choice'] as const) { - if (body[key] != null && !isRecord(body[key])) { - throw new Error(`Invalid ${key}`); - } - } -} - -function outputFormat(body: MessagesRequest) { - const format = body.output_config?.format ?? body.output_format; - if ( - format != null && - (format.type !== 'json_schema' || - !format.schema || - typeof format.schema !== 'object' || - Array.isArray(format.schema)) - ) { - throw new Error('Unsupported output format: expected json_schema with an object schema'); - } - return format; -} - -function inputTool(tool: unknown): ResponsesTool { - if (!isRecord(tool)) { - throw new Error('Invalid tool'); - } - if (tool.type && tool.type !== 'custom') { - throw new Error(`Unsupported server tool: ${tool.type}`); - } - if (typeof tool.name !== 'string' || !tool.name.trim() || !isRecord(tool.input_schema)) { - throw new Error('Invalid function tool'); - } - if (tool.description !== undefined && typeof tool.description !== 'string') { - throw new Error('Invalid tool description'); - } - return { - type: 'function', - name: toolName(tool.name), - description: tool.description ?? '', - parameters: tool.input_schema, - strict: false, - }; -} - -function toolChoice( - choice: MessagesRequest['tool_choice'], - tools: ResponsesTool[], -): ResponsesToolChoice { - const wanted = choice?.type ?? 'auto'; - if (!['auto', 'any', 'none', 'tool'].includes(wanted)) { - throw new Error('Unsupported tool choice'); - } - const name = choice?.name; - if ( - wanted === 'tool' && - (typeof name !== 'string' || !tools.some((tool) => tool.name === toolName(name))) - ) { - throw new Error('Named tool choice must reference a declared tool'); - } - if ( - choice?.disable_parallel_tool_use !== undefined && - typeof choice.disable_parallel_tool_use !== 'boolean' - ) { - throw new Error('Invalid parallel tool choice'); - } - switch (wanted) { - case 'tool': - if (typeof name !== 'string') { - throw new Error('Missing named tool choice'); - } - return { type: 'function', name: toolName(name) }; - case 'any': - return 'required'; - case 'none': - return 'none'; - default: - return 'auto'; - } -} - -function budgetEffort(thinking: MessagesRequest['thinking']): Effort { - if (thinking?.type === 'disabled') { - return 'low'; - } - const budget = thinking?.budget_tokens; - if (budget === undefined) { - return 'medium'; - } - if (budget <= 1024) { - return 'low'; - } - if (budget <= 8192) { - return 'medium'; - } - if (budget <= 24576) { - return 'high'; - } - return 'xhigh'; -} - -function reasoningEffort(body: MessagesRequest): Effort { - const thinking = body.thinking; - if (thinking && !['enabled', 'adaptive', 'disabled', 'auto'].includes(thinking.type)) { - throw new Error('Unsupported thinking configuration'); - } - const budget = thinking?.budget_tokens; - if (budget !== undefined && (!Number.isSafeInteger(budget) || budget < 0)) { - throw new Error('Invalid thinking budget'); - } - const effort = body.output_config?.effort ?? budgetEffort(thinking); - if (!isEffort(effort)) { - throw new Error(`Unsupported reasoning effort: ${effort}`); - } - return effort; -} - -function decodeReasoning(signaturePrefix: string) { - return (block: { type: string; signature?: string }): ResponsesInputItem | undefined => { - if (block.type === 'redacted_thinking' || !block.signature?.startsWith(signaturePrefix)) { - return undefined; - } - const item: unknown = JSON.parse( - Buffer.from(block.signature.slice(signaturePrefix.length), 'base64url').toString(), - ); - if (!isReasoningState(item)) { - throw new Error('Invalid reasoning state'); - } - return { - type: 'reasoning', - id: item.id, - encrypted_content: item.encrypted_content, - summary: item.summary ?? [], - }; - }; -} - -export function toResponses( - body: MessagesRequest, - model: string, - signaturePrefix = SIGNATURE_PREFIX, -): ResponsesRequest { - validateRequestOptions(body); - const format = outputFormat(body); - const input = normalizeConversation(body.messages, decodeReasoning(signaturePrefix)); - // Claude's tool-search flow keeps deferred schemas out of the initial model - // request. A loaded tool is resent without defer_loading on the next turn. - const tools = (body.tools ?? []) - .filter( - (tool) => - !isDeferredTool(tool) || - (typeof tool.name === 'string' && isDirectToolAvailable(body, tool.name)), - ) - .map(inputTool); - const choice = toolChoice(body.tool_choice, tools); - const effort = reasoningEffort(body); - return { - model, - instructions: textContent(body.system ?? ''), - input, - tools, - ...(format - ? { - text: { - format: { - type: 'json_schema' as const, - name: 'claude_output', - schema: format.schema, - strict: true, - }, - }, - } - : {}), - tool_choice: choice, - parallel_tool_calls: !body.tool_choice?.disable_parallel_tool_use, - reasoning: { effort, summary: 'auto' }, - include: ['reasoning.encrypted_content'], - // Codex subscriptions require store:false and streaming; max_tokens is unsupported. - store: false, - stream: true, - }; -} - -function isDeferredTool(tool: unknown): boolean { - return isRecord(tool) && tool.defer_loading === true; -} - -export async function* readSse(stream: AsyncIterable): AsyncGenerator { - const decoder = new TextDecoder(); - let pending = ''; - let data: string[] = []; - let dataBytes = 0; - let totalBytes = 0; - const addData = (line: string) => { - dataBytes += Buffer.byteLength(line); - if (dataBytes > 8 * 1024 * 1024) { - throw new Error('OpenAI SSE event exceeds 8 MiB'); - } - data.push(line); - }; - const parse = (): unknown => { - const value = data.join('\n'); - if (Buffer.byteLength(value) > 8 * 1024 * 1024) { - throw new Error('OpenAI SSE event exceeds 8 MiB'); - } - data = []; - dataBytes = 0; - return value && value !== '[DONE]' ? JSON.parse(value) : null; - }; - const consumeLine = (line: string): unknown => { - if (!line) { - return parse(); - } - if (line.startsWith('data:')) { - addData(line.slice(5).replace(/^ /, '')); - } - return null; - }; - for await (const chunk of stream) { - totalBytes += chunk.byteLength; - if (totalBytes > 32 * 1024 * 1024) { - throw new Error('OpenAI response exceeds 32 MiB'); - } - pending += decoder.decode(chunk, { stream: true }); - if (Buffer.byteLength(pending) > 8 * 1024 * 1024) { - throw new Error('OpenAI SSE buffer exceeds 8 MiB'); - } - for (let index = pending.indexOf('\n'); index !== -1; index = pending.indexOf('\n')) { - const line = pending.slice(0, index).replace(/\r$/, ''); - pending = pending.slice(index + 1); - const event = consumeLine(line); - if (event) { - yield event; - } - } - } - pending += decoder.decode(); - if (pending.startsWith('data:')) { - addData(pending.slice(5).trimStart()); - } - const event = parse(); - if (event) { - yield event; - } -} - -interface OutputSlot { - item: ResponsesOutputItem; - text: string; - arguments: string; - done: boolean; - block?: ResponseContentBlock; - index?: number; - emitted: number; -} - -function finalOutputItem( - previous: ResponsesOutputItem, - item: ResponsesOutputItem, -): ResponsesOutputItem { - if (previous.type !== item.type) { - throw new Error('OpenAI output item changed type'); - } - const before = [previous.id]; - const after = [item.id]; - if (previous.type === 'function_call' && item.type === 'function_call') { - before.push(previous.call_id, previous.name); - after.push(item.call_id, item.name); - } - if (before.some((value, index) => value && after[index] && value !== after[index])) { - throw new Error('OpenAI output item changed identity'); - } - // Prefer final encrypted state; retain an earlier snapshot only when omitted. - if (previous.type === 'reasoning' && item.type === 'reasoning') { - return { ...previous, ...item }; - } - return { ...item, id: item.id ?? previous.id }; -} - -function outputValue(item: ResponsesOutputItem): unknown { - switch (item.type) { - case 'message': - return item.content; - case 'function_call': - return [item.call_id, item.name, item.arguments]; - case 'reasoning': - // The provider may rotate encrypted_content between output_item.done and - // response.completed while preserving the visible reasoning summary. - // Already emitted signatures retain the completed item snapshot; opaque - // ciphertext is not a stable identity field for terminal reconciliation. - return [item.summary ?? []]; - } -} - -export interface ResponseOptions { - safeguards?: unknown; - safeguardProvider?: SafeguardProvider; - toolNames?: ReadonlyMap; - stopSequences?: readonly string[]; - signaturePrefix?: string; - requireUsage?: boolean; - /** Local input estimate for message_start; the provider only reports usage at completion. */ - inputTokens?: number; -} - -/** Assembles one ordered Claude response from possibly interleaved OpenAI output items. */ -class ResponseStream { - private content: ResponseContentBlock[] = []; - private slots = new Map(); - private message?: MessagesResponse; - private cursor = 0; - private stopped: string | null = null; - /** A stop sequence ended the content; the terminal event is awaited only for usage. */ - stopPending = false; - completed = false; - private model: string; - private emit: Emit; - private options: ResponseOptions; - - constructor(model: string, emit: Emit, options: ResponseOptions) { - this.model = model; - this.emit = emit; - this.options = options; - } - - result(): MessagesResponse { - if (!this.completed || !this.message) { - throw new Error('OpenAI stream ended before completion'); - } - return this.message; - } - - accept(event: ResponseStreamEvent) { - switch (event.type) { - case 'response.created': - this.start(event.response); - return; - case 'response.failed': - case 'error': - throw new Error(event.message ?? event.response?.error?.message ?? 'OpenAI stream failed'); - case 'response.completed': - case 'response.done': - case 'response.incomplete': - this.complete(event); - return; - default: - if (!this.message) { - throw new Error('OpenAI stream omitted response.created'); - } - if (this.stopPending) { - return; - } - this.update(event); - this.drain(); - if (this.stopped) { - this.stopPending = true; - } - } - } - - /** The terminal event never came in time; the answer is complete but its usage is unknown. */ - finishWithoutUsage() { - if (this.stopPending && !this.completed) { - this.finish('stop_sequence'); - } - } - - private start(response: ResponsesResponse) { - if (this.message) { - return; - } - this.message = { - id: response.id, - type: 'message', - role: 'assistant', - model: this.model, - content: this.content, - stop_reason: null, - stop_sequence: null, - // Claude Code reads the input count from message_start; the terminal - // message_delta replaces this estimate with the provider's real usage. - usage: { input_tokens: this.options.inputTokens ?? 0, output_tokens: 0 }, - }; - this.emit('message_start', { message: { ...this.message, content: [] } }); - } - - private finish(stopReason: StopReason, usage?: ResponsesUsage | null) { - const message = this.message; - if (!message) { - throw new Error('OpenAI stream omitted response.created'); - } - const cached = usage?.input_tokens_details?.cached_tokens ?? 0; - const written = usage?.input_tokens_details?.cache_write_tokens ?? 0; - message.usage = { - input_tokens: Math.max(0, (usage?.input_tokens ?? 0) - cached - written), - output_tokens: usage?.output_tokens ?? 0, - cache_read_input_tokens: cached, - cache_creation_input_tokens: written, - }; - message.multi_usage = { - source: usage ? 'provider' : 'unavailable', - ...(usage ? { total_tokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) } : {}), - model: this.model, - }; - message.stop_reason = stopReason; - message.stop_sequence = this.stopped; - const safeguard_results = safeguardResults( - this.options.safeguards, - message.content, - this.options.safeguardProvider ?? 'openai', - ); - if (safeguard_results) { - message.safeguard_results = safeguard_results; - } - this.emit('message_delta', { - delta: { - stop_reason: stopReason, - stop_sequence: this.stopped, - ...(safeguard_results === undefined ? {} : { safeguard_results }), - }, - usage: message.usage, - }); - this.emit('message_stop', {}); - this.completed = true; - } - - private complete( - event: Extract< - ResponseStreamEvent, - { type: 'response.completed' | 'response.incomplete' | 'response.done' } - >, - ) { - if (this.stopPending) { - this.start(event.response); - this.finish('stop_sequence', event.response.usage); - return; - } - if ( - this.options.requireUsage && - (event.response.usage?.input_tokens === undefined || - event.response.usage.output_tokens === undefined) - ) { - throw new Error('Provider completed without token usage; cost accounting is unavailable'); - } - this.start(event.response); - const incomplete = this.terminalIncomplete(event); - this.reconcile(event.response.output); - if ([...this.slots.values()].some((slot) => !slot.done)) { - throw new Error('OpenAI completed with an unfinished content block'); - } - this.drain(); - if (this.stopped) { - this.finish('stop_sequence', event.response.usage); - return; - } - if (!this.content.length || this.cursor !== this.slots.size) { - throw new Error('OpenAI completed with missing output'); - } - let stopReason: StopReason = this.content.some((block) => block.type === 'tool_use') - ? 'tool_use' - : 'end_turn'; - if (incomplete) { - stopReason = 'max_tokens'; - } - this.finish(stopReason, event.response.usage); - } - - /** Whether the terminal event is a max-output cut-off; any other failed state throws. */ - private terminalIncomplete( - event: Extract< - ResponseStreamEvent, - { type: 'response.completed' | 'response.incomplete' | 'response.done' } - >, - ): boolean { - if (event.response.status && !['completed', 'incomplete'].includes(event.response.status)) { - throw new Error(`OpenAI terminal response has status ${event.response.status}`); - } - const incomplete = - event.type === 'response.incomplete' || event.response.status === 'incomplete'; - if (incomplete && event.response.incomplete_details?.reason !== 'max_output_tokens') { - throw new Error( - `OpenAI response incomplete: ${event.response.incomplete_details?.reason ?? 'unknown'}`, - ); - } - return incomplete; - } - - private reconcile(output?: ResponsesOutputItem[]) { - // Some streams omit the redundant output array or send []; their finished - // incremental items remain authoritative. A populated array must agree. - if (!output?.length) { - return; - } - if ([...this.slots.keys()].some((index) => index >= output.length)) { - throw new Error('OpenAI terminal output omitted a streamed item'); - } - for (const [index, item] of output.entries()) { - const slot = this.slots.get(index) ?? this.addSlot(index, item); - this.finishItem(slot, item); - } - } - - private addSlot(index: number, item: ResponsesOutputItem): OutputSlot { - if (this.slots.has(index)) { - throw new Error('Duplicate OpenAI output item'); - } - const slot = { item, text: '', arguments: '', done: false, emitted: 0 }; - this.slots.set(index, slot); - return slot; - } - - private update(event: Extract) { - if (event.type === 'response.output_item.added') { - this.addSlot(event.output_index, event.item); - return; - } - if (event.type === 'response.output_item.done') { - const slot = - this.slots.get(event.output_index) ?? this.addSlot(event.output_index, event.item); - this.finishItem(slot, event.item); - return; - } - const slot = this.slots.get(event.output_index); - if (!slot || slot.done) { - throw new Error('OpenAI event without an active output item'); - } - if (event.type === 'response.function_call_arguments.delta') { - if (slot.item.type !== 'function_call') { - throw new Error('Arguments without function call'); - } - slot.arguments += event.delta; - } else { - const thinking = event.type === 'response.reasoning_summary_text.delta'; - if (slot.item.type !== (thinking ? 'reasoning' : 'message')) { - throw new Error('OpenAI delta has the wrong output type'); - } - slot.text += event.delta; - } - } - - private finishItem(slot: OutputSlot, item: ResponsesOutputItem) { - const final = finalOutputItem(slot.item, item); - if (slot.done) { - if (!isDeepStrictEqual(outputValue(slot.item), outputValue(final))) { - throw new Error('OpenAI terminal output changed a completed item'); - } - return; - } - if (final.type === 'message') { - const text = (final.content ?? []) - .map((block) => (block.type === 'refusal' ? block.refusal : block.text)) - .join(''); - if (!text.startsWith(slot.text)) { - throw new Error('OpenAI text changed after streaming'); - } - slot.text = text; - } else if (final.type === 'function_call') { - if (!final.arguments?.startsWith(slot.arguments)) { - throw new Error('OpenAI function arguments changed after streaming'); - } - slot.arguments = final.arguments; - } else if (!slot.text && Array.isArray(final.summary)) { - slot.text = final.summary.map((part) => (part as { text: string }).text).join('\n'); - } - slot.item = final; - slot.done = true; - } - - private createBlock(slot: OutputSlot): ResponseContentBlock { - const item = slot.item; - if (item.type === 'reasoning') { - return { type: 'thinking', thinking: '', signature: '' }; - } - if (item.type === 'message') { - return { type: 'text', text: '' }; - } - if (!item.call_id || !item.name || typeof item.arguments !== 'string') { - throw new Error('Incomplete function call'); - } - if (slot.arguments && slot.arguments !== item.arguments) { - throw new Error('OpenAI function arguments changed after streaming'); - } - const input: unknown = JSON.parse(item.arguments); - if (!isRecord(input)) { - throw new Error('OpenAI function arguments must be an object'); - } - const name = this.options.toolNames?.get(item.name) ?? item.name; - if (this.options.toolNames && !this.options.toolNames.has(item.name)) { - throw new Error('OpenAI returned an undeclared tool'); - } - const id = callId(item.call_id); - if (this.content.some((block) => block.type === 'tool_use' && block.id === id)) { - throw new Error('OpenAI repeated a tool call ID'); - } - return { type: 'tool_use', id, name, input }; - } - - private beginBlock(slot: OutputSlot) { - if (slot.block && slot.index !== undefined) { - return { block: slot.block, index: slot.index }; - } - const block = this.createBlock(slot); - const index = this.content.length; - slot.block = block; - slot.index = index; - this.content.push(block); - this.emit('content_block_start', { - index, - content_block: block.type === 'tool_use' ? { ...block, input: {} } : { ...block }, - }); - return { block, index }; - } - - private textLimit(slot: OutputSlot): number { - let matchAt = Infinity; - for (const stop of this.options.stopSequences ?? []) { - const at = slot.text.indexOf(stop); - if (at >= 0 && at < matchAt) { - matchAt = at; - this.stopped = stop; - } - } - if (this.stopped) { - return matchAt; - } - if (slot.done) { - return slot.text.length; - } - return prefixSafeLength(slot.text, this.options.stopSequences ?? []); - } - - private writeBlock(slot: OutputSlot, block: ResponseContentBlock, index: number) { - if (block.type === 'tool_use') { - this.emit('content_block_delta', { - index, - delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) }, - }); - return; - } - const limit = block.type === 'text' ? this.textLimit(slot) : slot.text.length; - const text = slot.text.slice(slot.emitted, limit); - if (text) { - this.emit('content_block_delta', { - index, - delta: - block.type === 'text' - ? { type: 'text_delta', text } - : { type: 'thinking_delta', thinking: text }, - }); - } - slot.emitted = limit; - if (block.type === 'text') { - block.text = slot.text.slice(0, limit); - } else { - block.thinking = slot.text.slice(0, limit); - } - } - - private closeBlock(slot: OutputSlot, block: ResponseContentBlock, index: number) { - if (slot.item.type === 'reasoning' && block.type === 'thinking') { - if (!isReasoningState(slot.item)) { - throw new Error('OpenAI omitted encrypted reasoning state'); - } - block.signature = - (this.options.signaturePrefix ?? SIGNATURE_PREFIX) + - Buffer.from(JSON.stringify(slot.item)).toString('base64url'); - this.emit('content_block_delta', { - index, - delta: { type: 'signature_delta', signature: block.signature }, - }); - } - this.emit('content_block_stop', { index }); - } - - private drain() { - while (this.slots.has(this.cursor) && !this.stopped) { - const slot = this.slots.get(this.cursor); - if (!slot) { - return; - } - if (slot.item.type === 'function_call' && !slot.done) { - return; - } - const { block, index } = this.beginBlock(slot); - this.writeBlock(slot, block, index); - if (!slot.done && !this.stopped) { - return; - } - this.closeBlock(slot, block, index); - this.cursor++; - } - } -} - -/** Hold suffixes that might become a stop sequence in a later text delta. */ -export function prefixSafeLength(text: string, stops: readonly string[]): number { - let limit = text.length; - for (const stop of stops) { - for (let length = 1; length < stop.length; length++) { - if (text.endsWith(stop.slice(0, length))) { - limit = Math.min(limit, text.length - length); - } - } - } - return limit; -} - -/** How long a stream that already hit a stop sequence may take to report its usage. */ -const STOP_USAGE_WAIT_MS = 5000; - -export async function fromResponses( - stream: AsyncIterable, - model: string, - emit: Emit = () => {}, - options: ResponseOptions = {}, -): Promise { - const response = new ResponseStream(model, emit, options); - const events = readSse(stream)[Symbol.asyncIterator](); - let deadline: Promise<'late'> | undefined; - let timer: NodeJS.Timeout | undefined; - try { - while (!response.completed) { - const next = events.next(); - const step = deadline ? await Promise.race([next, deadline]) : await next; - if (step === 'late') { - response.finishWithoutUsage(); - break; - } - if (step.done) { - break; - } - if (!isStreamEvent(step.value)) { - continue; - } - response.accept(step.value); - if (response.stopPending && !deadline) { - // The text is final, but the provider bills the whole response: keep reading to its usage. - deadline = new Promise<'late'>((resolve) => { - timer = setTimeout(() => resolve('late'), STOP_USAGE_WAIT_MS); - }); - } - } - } finally { - clearTimeout(timer); - // A stream abandoned early is released without waiting on its next chunk. - events.return?.(undefined).catch(() => undefined); - } - response.finishWithoutUsage(); - return response.result(); + return stripProviderSignatures(body, [OPENAI_SIGNATURE_PREFIX, ...ZEN_SIGNATURE_PREFIXES]); } diff --git a/plugins/multi-zen/src/chat.ts b/plugins/multi-zen/src/chat.ts index c0fa23c..fb0a105 100644 --- a/plugins/multi-zen/src/chat.ts +++ b/plugins/multi-zen/src/chat.ts @@ -7,11 +7,17 @@ import type { ResponseContentBlock, StopReason, } from '../../multi-core/src/gateway/messages.ts'; +import { prefixSafeLength, readSse } from '../../multi-core/src/gateway/responses.ts'; import { safeguardResults } from '../../multi-core/src/gateway/safeguards.ts'; import { callId, toolName } from '../../multi-core/src/gateway/tools.ts'; -import { prefixSafeLength, readSse } from '../../multi-openai/src/responses.ts'; const SIGNATURE_PREFIX = 'multi-zen-chat:'; +export const ZEN_RESPONSES_SIGNATURE_PREFIX = 'multi-zen-responses:'; +/** Every opaque reasoning signature Zen emits, for stripping from Claude-bound history. */ +export const ZEN_SIGNATURE_PREFIXES: readonly string[] = [ + ZEN_RESPONSES_SIGNATURE_PREFIX, + SIGNATURE_PREFIX, +]; const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); export interface ChatRequest { diff --git a/plugins/multi-zen/src/models.ts b/plugins/multi-zen/src/models.ts index 9cbed33..7574529 100644 --- a/plugins/multi-zen/src/models.ts +++ b/plugins/multi-zen/src/models.ts @@ -1,4 +1,4 @@ -import type { Effort } from '../../multi-openai/src/responses.ts'; +import type { Effort } from '../../multi-core/src/gateway/responses.ts'; type ZenProtocol = 'responses' | 'chat'; diff --git a/plugins/multi-zen/src/request.ts b/plugins/multi-zen/src/request.ts index 0f78423..4dd6379 100644 --- a/plugins/multi-zen/src/request.ts +++ b/plugins/multi-zen/src/request.ts @@ -1,8 +1,11 @@ import type { MessagesRequest } from '../../multi-core/src/gateway/messages.ts'; +import type { + ResponsesInputContent, + ResponsesRequest, +} from '../../multi-core/src/gateway/responses.ts'; +import { toResponses } from '../../multi-core/src/gateway/responses.ts'; import { estimateInputTokens, estimateTextTokens } from '../../multi-core/src/gateway/tokens.ts'; -import type { ResponsesInputContent, ResponsesRequest } from '../../multi-openai/src/responses.ts'; -import { toResponses } from '../../multi-openai/src/responses.ts'; -import { toChat } from './chat.ts'; +import { toChat, ZEN_RESPONSES_SIGNATURE_PREFIX } from './chat.ts'; import type { ZenModel } from './models.ts'; import { zenModel } from './models.ts'; @@ -42,7 +45,7 @@ export function zenRequest(body: MessagesRequest, cacheKey: string) { ) { throw new Error(`Zen max_tokens must be between 1 and ${model.maxOutputTokens}`); } - const signaturePrefix = `multi-zen-responses:${model.id}:`; + const signaturePrefix = `${ZEN_RESPONSES_SIGNATURE_PREFIX}${model.id}:`; const normalized = toResponses(body, model.id, signaturePrefix); validateMedia(normalized, model); const effort = body.output_config?.effort; diff --git a/test/live/native-openai-cache.ts b/test/live/native-openai-cache.ts index d2c2385..030dd5e 100644 --- a/test/live/native-openai-cache.ts +++ b/test/live/native-openai-cache.ts @@ -6,9 +6,9 @@ import { mkdtemp, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import type { MessagesResponse } from '../../plugins/multi-core/src/gateway/messages.ts'; +import type { ResponsesRequest } from '../../plugins/multi-core/src/gateway/responses.ts'; +import { readSse } from '../../plugins/multi-core/src/gateway/responses.ts'; import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; -import type { ResponsesRequest } from '../../plugins/multi-openai/src/responses.ts'; -import { readSse } from '../../plugins/multi-openai/src/responses.ts'; import { isolatedEnvironment } from './environment.ts'; interface Sample { diff --git a/test/live/native-provider-approval.ts b/test/live/native-provider-approval.ts index 3b62930..7f644d0 100644 --- a/test/live/native-provider-approval.ts +++ b/test/live/native-provider-approval.ts @@ -12,10 +12,10 @@ import { stripVTControlCharacters } from 'node:util'; import { NativeApprovalBridge } from '../../plugins/multi-core/src/gateway/approval.ts'; import { hookCommand } from '../../plugins/multi-core/src/gateway/permission-hook.ts'; import { terminateProcessTree } from '../../plugins/multi-core/src/gateway/process-tree.ts'; +import { readSse } from '../../plugins/multi-core/src/gateway/responses.ts'; import type { GatewayEvent } from '../../plugins/multi-core/src/gateway/server.ts'; import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; import { readCodexAuth } from '../../plugins/multi-openai/src/auth.ts'; -import { readSse } from '../../plugins/multi-openai/src/responses.ts'; import { isolatedEnvironment } from './environment.ts'; import type { HookInput } from './native-events.ts'; import { pty } from './native-pty.ts'; diff --git a/test/live/native-zen.ts b/test/live/native-zen.ts index c76b2e7..f392347 100644 --- a/test/live/native-zen.ts +++ b/test/live/native-zen.ts @@ -8,11 +8,11 @@ import os from 'node:os'; import path from 'node:path'; import type { GatewayFetch } from '../../plugins/multi-core/src/gateway/fetch.ts'; import type { MessagesResponse } from '../../plugins/multi-core/src/gateway/messages.ts'; +import { readSse } from '../../plugins/multi-core/src/gateway/responses.ts'; import { createNativeGateway, type GatewayOptions, } from '../../plugins/multi-core/src/gateway/server.ts'; -import { readSse } from '../../plugins/multi-openai/src/responses.ts'; import { readZenKey } from '../../plugins/multi-zen/src/auth.ts'; import { zenModel, zenPickerOptions } from '../../plugins/multi-zen/src/models.ts'; import { isolatedEnvironment } from './environment.ts'; diff --git a/test/unit/native-gateway.test.ts b/test/unit/native-gateway.test.ts index 2c95b26..014193f 100644 --- a/test/unit/native-gateway.test.ts +++ b/test/unit/native-gateway.test.ts @@ -18,6 +18,18 @@ import type { } from '../../plugins/multi-core/src/gateway/messages.ts'; import { PermissionModes } from '../../plugins/multi-core/src/gateway/mode-hook.ts'; import { ReceiptLedger } from '../../plugins/multi-core/src/gateway/receipts.ts'; +import type { + ResponsesInputContent, + ResponsesInputItem, + ResponsesRequest, +} from '../../plugins/multi-core/src/gateway/responses.ts'; +import { + forAnthropic, + fromResponses, + OPENAI_SIGNATURE_PREFIX, + readSse, + toResponses, +} from '../../plugins/multi-core/src/gateway/responses.ts'; import type { GatewayEvent, GatewayOptions } from '../../plugins/multi-core/src/gateway/server.ts'; import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; import { estimateInputTokens } from '../../plugins/multi-core/src/gateway/tokens.ts'; @@ -29,19 +41,11 @@ import { import { readCodexAuth } from '../../plugins/multi-openai/src/auth.ts'; import { openaiInstructions } from '../../plugins/multi-openai/src/instructions.ts'; import { MODELS, OPENAI_WORKER_EFFORT } from '../../plugins/multi-openai/src/models.ts'; -import type { - ResponsesInputContent, - ResponsesInputItem, - ResponsesRequest, -} from '../../plugins/multi-openai/src/responses.ts'; -import { - forAnthropic, - fromResponses, - readSse, - toResponses, -} from '../../plugins/multi-openai/src/responses.ts'; +import { ZEN_SIGNATURE_PREFIXES } from '../../plugins/multi-zen/src/chat.ts'; import { removeTemporary } from '../temporary.ts'; +const FOREIGN_PREFIXES = [OPENAI_SIGNATURE_PREFIX, ...ZEN_SIGNATURE_PREFIXES]; + /** A test double for one OpenAI Responses SSE event; sent as JSON, never typed upstream. */ interface SseEvent { type: string; @@ -262,7 +266,7 @@ test('images retain their order and tool-result association across provider swit { type: 'input_text', text: 'Capture timed out' }, ], }); - assert.equal(forAnthropic(request), request); + assert.equal(forAnthropic(request, FOREIGN_PREFIXES), request); assert.deepEqual(request, snapshot, 'Conversion must not rewrite the stored transcript'); }); @@ -377,7 +381,7 @@ test('switching back to Claude removes OpenAI reasoning while preserving message }, ]; const mixed = { ...body, messages: stored }; - const cleaned = forAnthropic(mixed).messages ?? []; + const cleaned = forAnthropic(mixed, FOREIGN_PREFIXES).messages ?? []; assert.equal(cleaned.length, 3); assert.deepEqual(cleaned[0], stored[0]); assert.deepEqual(cleaned[1].content, [tool]); @@ -389,7 +393,11 @@ test('switching back to Claude removes OpenAI reasoning while preserving message 2, 'Stored transcript must not be mutated', ); - assert.equal(forAnthropic(body), body, 'Unmixed Claude requests retain byte-exact passthrough'); + assert.equal( + forAnthropic(body, FOREIGN_PREFIXES), + body, + 'Unmixed Claude requests retain byte-exact passthrough', + ); }); test('fragmented SSE and truncated or failed responses never become successful completions', async () => { diff --git a/test/unit/native-openai-stream.test.ts b/test/unit/native-openai-stream.test.ts index 9b98c01..407dbad 100644 --- a/test/unit/native-openai-stream.test.ts +++ b/test/unit/native-openai-stream.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { Emit, StreamEventBody } from '../../plugins/multi-core/src/gateway/messages.ts'; +import { fromResponses, toResponses } from '../../plugins/multi-core/src/gateway/responses.ts'; import { safeguardResults } from '../../plugins/multi-core/src/gateway/safeguards.ts'; -import { fromResponses, toResponses } from '../../plugins/multi-openai/src/responses.ts'; const model = 'multi/openai/gpt-6-astra'; const created = { type: 'response.created', response: { id: 'resp' } }; diff --git a/test/unit/native-tool-discovery.test.ts b/test/unit/native-tool-discovery.test.ts index 757c56e..a61ef0e 100644 --- a/test/unit/native-tool-discovery.test.ts +++ b/test/unit/native-tool-discovery.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { toResponses } from '../../plugins/multi-openai/src/responses.ts'; +import { toResponses } from '../../plugins/multi-core/src/gateway/responses.ts'; import { toChat } from '../../plugins/multi-zen/src/chat.ts'; const body = { diff --git a/test/unit/native-zen-request.test.ts b/test/unit/native-zen-request.test.ts index 78a28b6..4adaa5e 100644 --- a/test/unit/native-zen-request.test.ts +++ b/test/unit/native-zen-request.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { MessagesRequest } from '../../plugins/multi-core/src/gateway/messages.ts'; -import type { ResponsesInputItem } from '../../plugins/multi-openai/src/responses.ts'; -import { fromResponses } from '../../plugins/multi-openai/src/responses.ts'; +import type { ResponsesInputItem } from '../../plugins/multi-core/src/gateway/responses.ts'; +import { fromResponses } from '../../plugins/multi-core/src/gateway/responses.ts'; import { toChat } from '../../plugins/multi-zen/src/chat.ts'; import { zenRequest } from '../../plugins/multi-zen/src/request.ts'; From 02347df6f6e6454462028cfc8f11949a33c1c555 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:19:00 -0400 Subject: [PATCH 25/36] Hold and echo the gateway's per-session mod key in the hooks client Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- docs/claude-mods.md | 12 +++ plugins/multi-core/hooks/compact.ts | 8 +- plugins/multi-core/hooks/gateway.ts | 64 ++++++++++++- plugins/multi-core/hooks/lifecycle.ts | 10 +- plugins/multi-core/hooks/register.ts | 6 ++ plugins/multi-core/hooks/rows.ts | 8 ++ .../hooks/tests/gateway-key.test.ts | 94 +++++++++++++++++++ .../multi-core/hooks/tests/lifecycle.test.ts | 39 ++++++++ plugins/multi-core/hooks/tests/rows.test.ts | 1 + plugins/multi-core/hooks/usage.ts | 6 ++ plugins/multi-core/hooks/workers.ts | 6 ++ plugins/multi-core/types/multi-core.d.ts | 2 + 12 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 plugins/multi-core/hooks/tests/gateway-key.test.ts diff --git a/docs/claude-mods.md b/docs/claude-mods.md index 18a2866..52eb794 100644 --- a/docs/claude-mods.md +++ b/docs/claude-mods.md @@ -303,6 +303,18 @@ permission snapshots, model and worker selection, progress/lifecycle, compaction, session cleanup, and quota advice through the same authenticated Mods control plane. +The gateway token is in the environment of every Bash command, so a token alone does +not admit a session's `/multi/mod/*` POSTs. The gateway mints a per-session key on the +first POST it sees for a `sessionId` and returns it in an `x-multi-mod-key` response +header. `gateway.ts` captures it from any reply, keeps it by session id in the host-held +`$.state` value `multi-core.modKeys` (so a hot reload keeps it; a key held only in module +memory would lock the Mod out, since the gateway never re-issues one after the first echo), +and sends it as `x-multi-mod-key` on every later request that names that session. After the +first echo the gateway refuses token-only POSTs for that session with 403. The key is +never put in the environment, a log, or a Client surface payload; other plugins could read +the state value, but they already run in the engine with the same environment. `session.end` +sends `/multi/mod/detach` with the key, then forgets it. + The gateway bounds what the Mod can make it hold: a session's snapshot and policy tables evict the least recently used idle session instead of refusing a new one, an unconsumed policy job is reused for a minute at most, and a policy request may carry diff --git a/plugins/multi-core/hooks/compact.ts b/plugins/multi-core/hooks/compact.ts index 79e2fe5..ab74298 100644 --- a/plugins/multi-core/hooks/compact.ts +++ b/plugins/multi-core/hooks/compact.ts @@ -1,5 +1,5 @@ import type { EngineInterface, Register } from 'claude-code'; -import { atom, read } from 'claude-code'; +import { atom, read, update } from 'claude-code'; import { accepted, getJson, isActive, postJson, type Wire } from './gateway.ts'; import { isHarnessModel } from './provider.ts'; @@ -10,11 +10,17 @@ const agentModels = atom( {} as Record, ); +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + const wire = ($: EngineInterface): Wire => ({ url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), token: () => $.env.get('MULTI_GATEWAY_TOKEN'), fetch: (url, init) => $.http.fetch(url, init), sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, }); export const register = (on: Parameters[0], _options: Parameters[1]) => { diff --git a/plugins/multi-core/hooks/gateway.ts b/plugins/multi-core/hooks/gateway.ts index 4b024cc..9f73f55 100644 --- a/plugins/multi-core/hooks/gateway.ts +++ b/plugins/multi-core/hooks/gateway.ts @@ -39,7 +39,7 @@ export type GatewayOptions = { }; /** - * What the client needs of the engine, as four calls. The engine follows `$` only into + * What the client needs of the engine, as four calls and the session keys. The engine follows `$` only into * functions of the file that hooks, never across an import, and wants each `$.env.get` * name and `$.noun.event(...)` spelled at its call site, so each hooks file builds this * from its own `$` and hands it here: @@ -49,15 +49,46 @@ export type GatewayOptions = { * token: () => $.env.get('MULTI_GATEWAY_TOKEN'), * fetch: (url, init) => $.http.fetch(url, init), * sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + * keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, * }); */ export type Wire = { + /** + * The per-session keys the gateway issued, held in `$.state` so a hot reload keeps them. The + * gateway refuses a session's token-only POSTs once its key has been echoed, so a lost key + * would lock the mod out of that session. + */ + keys: ModKeys; url: () => Promise; token: () => Promise; fetch: (url: string, init: HttpInit) => Promise; sleep: (ms: number, signal: AbortSignal) => Promise; }; +export type ModKeys = { + read: () => Promise>>; + save: (change: (held: Record) => Record) => Promise; +}; + +/** Sent on every request that names a session once the gateway has issued that session's key. */ +const keyHeader = 'x-multi-mod-key'; +const maxKeys = 256; + +/** Forgets the key of an ended session. */ +export async function forgetKey(wire: Wire, sessionId: string): Promise { + await wire.keys.save((held) => { + const { [sessionId]: _ended, ...others } = held; + return others; + }); +} + +async function rememberKey(wire: Wire, sessionId: string, key: string): Promise { + await wire.keys.save((held) => { + const { [sessionId]: _replaced, ...others } = held; + return Object.fromEntries([...Object.entries(others).slice(-(maxKeys - 1)), [sessionId, key]]); + }); +} + type Environment = { base: string; token: string }; async function environment(wire: Wire): Promise { @@ -78,7 +109,14 @@ export function getJson( options: GatewayOptions = {}, ): Promise { const search = new URLSearchParams(query).toString(); - return send(wire, 'GET', search ? `${route}?${search}` : route, undefined, options); + return send( + wire, + 'GET', + search ? `${route}?${search}` : route, + undefined, + options, + query.sessionId, + ); } export function postJson( @@ -87,7 +125,15 @@ export function postJson( payload: object, options: GatewayOptions = {}, ): Promise { - return send(wire, 'POST', route, JSON.stringify(payload), options); + const { sessionId } = payload as { sessionId?: unknown }; + return send( + wire, + 'POST', + route, + JSON.stringify(payload), + options, + typeof sessionId === 'string' ? sessionId : undefined, + ); } /** The reply only when the gateway accepted the request. */ @@ -101,6 +147,7 @@ async function send( route: string, body: string | undefined, options: GatewayOptions, + sessionId: string | undefined, ): Promise { const env = await environment(wire); if (!env || (body !== undefined && new TextEncoder().encode(body).length > maxBody)) { @@ -110,9 +157,14 @@ async function send( const cancel = () => waiting.abort(); options.signal?.addEventListener('abort', cancel, { once: true }); try { + const key = sessionId ? (await wire.keys.read())[sessionId] : undefined; const fetched = wire.fetch(`${env.base}${route}`, { method, - headers: { 'content-type': 'application/json', 'x-multi-gateway-token': env.token }, + headers: { + 'content-type': 'application/json', + 'x-multi-gateway-token': env.token, + ...(key ? { [keyHeader]: key } : {}), + }, ...(body === undefined ? {} : { body }), }); // A late failure of a fetch that lost the race to its deadline is not reported. @@ -122,6 +174,10 @@ async function send( timeoutMs > 0 ? await Promise.race([fetched, deadline(wire, timeoutMs, waiting.signal)]) : await fetched; + const issued = result.headers?.[keyHeader]; + if (sessionId && issued && issued !== key) { + await rememberKey(wire, sessionId, issued); + } return result.ok ? (JSON.parse(result.text) as T) : (refusal(result) as T); } catch { return undefined; diff --git a/plugins/multi-core/hooks/lifecycle.ts b/plugins/multi-core/hooks/lifecycle.ts index 8fdc579..832144c 100644 --- a/plugins/multi-core/hooks/lifecycle.ts +++ b/plugins/multi-core/hooks/lifecycle.ts @@ -5,7 +5,7 @@ import type { MultiCorePolicy, MultiCoreUsagePane, } from '../types/multi-core.d.ts'; -import { getJson, isActive, postJson, type Wire } from './gateway.ts'; +import { forgetKey, getJson, isActive, postJson, type Wire } from './gateway.ts'; import { isHarnessModel, isMultiModel } from './provider.ts'; import { type RowsClient, syncDisplayTools } from './rows.ts'; import { withBounded } from './state.ts'; @@ -47,11 +47,17 @@ const rowsClient = ($: EngineInterface): RowsClient => ({ register: (tool) => $.tool.register(tool), }); +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + const wire = ($: EngineInterface): Wire => ({ url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), token: () => $.env.get('MULTI_GATEWAY_TOKEN'), fetch: (url, init) => $.http.fetch(url, init), sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, }); type Status = { @@ -208,6 +214,8 @@ async function forgetSession($: EngineInterface, sessionId: string, signal: Abor if (await isActive(wire($))) { await postJson(wire($), '/multi/mod/detach', { sessionId }, { timeoutMs: 1000, signal }); } + // After the detach, which the gateway admits only with the key. + await forgetKey(wire($), sessionId); } function statusText(status: Status, agentId: string | undefined) { diff --git a/plugins/multi-core/hooks/register.ts b/plugins/multi-core/hooks/register.ts index 3a01823..cb2e213 100644 --- a/plugins/multi-core/hooks/register.ts +++ b/plugins/multi-core/hooks/register.ts @@ -17,11 +17,17 @@ import { defined } from './state.ts'; import { register as registerUsage } from './usage.ts'; import { register as registerWorkers } from './workers.ts'; +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + const wire = ($: EngineInterface): Wire => ({ url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), token: () => $.env.get('MULTI_GATEWAY_TOKEN'), fetch: (url, init) => $.http.fetch(url, init), sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, }); // State values are named where they are read: the engine's scan reads an atom's plugin and key diff --git a/plugins/multi-core/hooks/rows.ts b/plugins/multi-core/hooks/rows.ts index 4364360..f98023f 100644 --- a/plugins/multi-core/hooks/rows.ts +++ b/plugins/multi-core/hooks/rows.ts @@ -1,4 +1,5 @@ import type { EngineInterface, Register, ToolSpec } from 'claude-code'; +import { atom, read, update } from 'claude-code'; import type { MultiCoreDisplayTools } from '../types/multi-core.d.ts'; import { accepted, getJson, postJson, type Wire } from './gateway.ts'; import { @@ -45,12 +46,19 @@ const refusal = type On = Parameters[0]; type Row = { output: string; isError: boolean }; +// Named where it is read: the engine's scan reads an atom's plugin and key from this file. +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + const rowGateway = ($: EngineInterface): RowGateway => ({ wire: { url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), token: () => $.env.get('MULTI_GATEWAY_TOKEN'), fetch: (url, init) => $.http.fetch(url, init), sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, }, sessionId: () => $.session.id(), }); diff --git a/plugins/multi-core/hooks/tests/gateway-key.test.ts b/plugins/multi-core/hooks/tests/gateway-key.test.ts new file mode 100644 index 0000000..0c7d3ba --- /dev/null +++ b/plugins/multi-core/hooks/tests/gateway-key.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from 'claude-code/testing'; +import { forgetKey, getJson, postJson, type Wire } from '../gateway.ts'; + +// A stub gateway with the admission rule of the gateway's mod-keys.ts: it mints a key for a +// session's first POST and refuses every token-only POST of that session once the key is echoed. +function stubGateway() { + const entries = new Map(); + const refusal = { + ok: false, + status: 403, + headers: {}, + text: '{"error":"Mod session key required"}', + }; + return async (url: string, init: { headers?: Record; body?: string }) => { + const sessionId = (JSON.parse(init.body ?? '{}') as { sessionId?: string }).sessionId; + const presented = init.headers?.['x-multi-mod-key']; + const headers: Record = {}; + if (sessionId) { + const entry = entries.get(sessionId) ?? { key: `key-${entries.size}`, confirmed: false }; + entries.set(sessionId, entry); + if (presented === undefined ? entry.confirmed : presented !== entry.key) { + return refusal; + } + if (presented === undefined) { + headers['x-multi-mod-key'] = entry.key; + } else { + entry.confirmed = true; + } + } + return { ok: true, status: 200, headers, text: JSON.stringify({ accepted: true, url }) }; + }; +} + +function wireOver(store: { held: Record }, fetch: Wire['fetch']): Wire { + return { + url: async () => 'http://127.0.0.1:4000', + token: async () => 'token', + fetch, + sleep: () => new Promise(() => undefined), + keys: { + read: async () => store.held, + save: async (change) => { + store.held = change(store.held); + }, + }, + }; +} + +test('the key is captured from the first reply and echoed on later requests', async () => { + const store = { held: {} as Record }; + const wire = wireOver(store, stubGateway()); + expect(await postJson(wire, '/multi/mod/session', { sessionId: 's' })).toMatchObject({ + accepted: true, + }); + expect(Object.keys(store.held)).toEqual(['s']); + expect(await postJson(wire, '/multi/mod/session', { sessionId: 's' })).toMatchObject({ + accepted: true, + }); + expect(await getJson(wire, '/multi/mod/mode', { sessionId: 's' })).toMatchObject({ + accepted: true, + }); +}); + +test('a request without the key is refused once the mod has echoed it', async () => { + const fetch = stubGateway(); + const wire = wireOver({ held: {} }, fetch); + await postJson(wire, '/multi/mod/session', { sessionId: 's' }); + await postJson(wire, '/multi/mod/session', { sessionId: 's' }); + // An approved curl carries the token but not the key. + const forged = await fetch('http://x/multi/mod/session', { + headers: { 'x-multi-gateway-token': 'token' }, + body: JSON.stringify({ sessionId: 's', permissionMode: 'bypassPermissions' }), + }); + expect(forged.status).toBe(403); + // A mod holding no key for the session is refused the same way. + const other = wireOver({ held: {} }, fetch); + expect(await postJson(other, '/multi/mod/session', { sessionId: 's' })).toMatchObject({ + refused: true, + httpStatus: 403, + }); +}); + +test('keys survive a reload of the module and are forgotten at session end', async () => { + const fetch = stubGateway(); + const store = { held: {} as Record }; + await postJson(wireOver(store, fetch), '/multi/mod/session', { sessionId: 's' }); + // A reload builds a new wire over the same host-held state. + const reloaded = wireOver(store, fetch); + expect(await postJson(reloaded, '/multi/mod/session', { sessionId: 's' })).toMatchObject({ + accepted: true, + }); + await forgetKey(reloaded, 's'); + expect(store.held).toEqual({}); +}); diff --git a/plugins/multi-core/hooks/tests/lifecycle.test.ts b/plugins/multi-core/hooks/tests/lifecycle.test.ts index 78630db..50d885c 100644 --- a/plugins/multi-core/hooks/tests/lifecycle.test.ts +++ b/plugins/multi-core/hooks/tests/lifecycle.test.ts @@ -295,3 +295,42 @@ test('session end forgets the gateway session, while a client leaving does not', { url: 'http://127.0.0.1:4000/multi/mod/detach', body: { sessionId: 'ending' } }, ]); }); + +test('the gateway session key is captured, echoed, and cleared at session end', async ($, on) => { + mock.clock(on); + mock.env(on, gatewayEnv); + on('session.id', () => ({ value: 'keyed' })); + on('session.end', (_$, event) => ({ sessionId: event.sessionId })); + on('ui.status', () => ({ value: undefined })); + const sent: Array<{ route: string; key: string | undefined }> = []; + on('http.fetch', (_$, event) => { + const headers = event.init?.headers as Record | undefined; + sent.push({ route: new URL(event.url).pathname, key: headers?.['x-multi-mod-key'] }); + const issued: Record = headers?.['x-multi-mod-key'] + ? {} + : { 'x-multi-mod-key': 'issued-key' }; + return { value: { ok: true, status: 200, headers: issued, text: '{"accepted":true}' } }; + }); + on('turn.step', async function* (_$, event) { + yield { kind: 'text', index: 0, text: 'done' }; + return stepResult(event); + }); + const step = async (turnId: string) => { + for await (const _chunk of $.turn.step({ + turnId, + agentId: 'worker', + index: 0, + model: 'multi/openai/gpt-6-astra', + messageCount: 1, + })) { + // Each step posts telemetry for the session. + } + }; + await step('t1'); + await step('t2'); + expect(sent.map((request) => request.key)).toEqual([undefined, 'issued-key']); + await $.session.end({ reason: 'clear', sessionId: 'keyed', resume: { id: 'keyed' } } as never); + expect(sent.at(-1)).toEqual({ route: '/multi/mod/detach', key: 'issued-key' }); + await step('t3'); + expect(sent.at(-1)?.key).toBe(undefined); +}); diff --git a/plugins/multi-core/hooks/tests/rows.test.ts b/plugins/multi-core/hooks/tests/rows.test.ts index 3e11eb8..3fb60c3 100644 --- a/plugins/multi-core/hooks/tests/rows.test.ts +++ b/plugins/multi-core/hooks/tests/rows.test.ts @@ -597,6 +597,7 @@ test('a failed display tool acknowledgement is retried while the catalog is unch }); const client: RowsClient = { wire: { + keys: { read: async () => ({}), save: async () => undefined }, url: async () => 'http://127.0.0.1:4000', token: async () => 'token', sleep: () => new Promise(() => undefined), diff --git a/plugins/multi-core/hooks/usage.ts b/plugins/multi-core/hooks/usage.ts index b6377f1..861bd66 100644 --- a/plugins/multi-core/hooks/usage.ts +++ b/plugins/multi-core/hooks/usage.ts @@ -21,11 +21,17 @@ const usagePanes = atom( const usageTimeoutMs = 8500; const maximumPanes = 16; +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + const wire = ($: EngineInterface): Wire => ({ url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), token: () => $.env.get('MULTI_GATEWAY_TOKEN'), fetch: (url, init) => $.http.fetch(url, init), sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, }); export const register = (on: Parameters[0], _options?: Parameters[1]) => { diff --git a/plugins/multi-core/hooks/workers.ts b/plugins/multi-core/hooks/workers.ts index 316bc58..5d7054f 100644 --- a/plugins/multi-core/hooks/workers.ts +++ b/plugins/multi-core/hooks/workers.ts @@ -44,11 +44,17 @@ const rowsClient = ($: EngineInterface): RowsClient => ({ register: (tool) => $.tool.register(tool), }); +const modKeys = atom( + { plugin: 'multi-core', key: 'modKeys' } as const, + {} as Record, +); + const wire = ($: EngineInterface): Wire => ({ url: () => $.env.get('MULTI_MOD_GATEWAY_URL'), token: () => $.env.get('MULTI_GATEWAY_TOKEN'), fetch: (url, init) => $.http.fetch(url, init), sleep: (ms, signal) => $.clock.sleep(ms, { signal }), + keys: { read: () => read($, modKeys), save: (change) => update($, modKeys, change) }, }); const issues = diff --git a/plugins/multi-core/types/multi-core.d.ts b/plugins/multi-core/types/multi-core.d.ts index 53717a0..6d297b5 100644 --- a/plugins/multi-core/types/multi-core.d.ts +++ b/plugins/multi-core/types/multi-core.d.ts @@ -46,6 +46,8 @@ declare module 'claude-code' { displayTools: MultiCoreDisplayTools; /** The usage pane's props, by session id. */ usagePanes: Record; + /** The gateway's per-session mod key, by session id. Never logged or drawn. */ + modKeys: Record; /** Sessions whose Agent calls carry the quota advice. */ advisorySessions: string[]; }; From 5063988c2c2717942dcc7670268fbf3455360f76 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:19:06 -0400 Subject: [PATCH 26/36] Share the CLI harness request flow between Antigravity and Grok harness-cli.ts owns handle, exchange keying, leases, eviction, close and the bounded wait (Cursor reuses it with its own timeout). Providers supply model selection, key fingerprint, error type and the turn. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-antigravity/src/harness.ts | 214 +++-------------- plugins/multi-core/src/gateway/harness-cli.ts | 227 ++++++++++++++++++ plugins/multi-cursor/src/harness.ts | 20 +- plugins/multi-grok/src/harness.ts | 224 +++-------------- test/unit/native-harness-cli.test.ts | 21 ++ 5 files changed, 328 insertions(+), 378 deletions(-) create mode 100644 plugins/multi-core/src/gateway/harness-cli.ts create mode 100644 test/unit/native-harness-cli.test.ts diff --git a/plugins/multi-antigravity/src/harness.ts b/plugins/multi-antigravity/src/harness.ts index 4cb96cb..509390f 100644 --- a/plugins/multi-antigravity/src/harness.ts +++ b/plugins/multi-antigravity/src/harness.ts @@ -1,17 +1,13 @@ -import { createHash } from 'node:crypto'; -import { realpath } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { - archiveHarnessReply, - commitHarnessResponse, -} from '../../multi-core/src/gateway/harness-completion.ts'; -import { - ExchangeRegistry, - type HarnessExchange, - replayPersisted, -} from '../../multi-core/src/gateway/harness-exchange.ts'; -import { classifyHarnessFailure } from '../../multi-core/src/gateway/harness-failure.ts'; + CliHarness, + type CliTurn, + digest, + harnessFailure, +} from '../../multi-core/src/gateway/harness-cli.ts'; +import { commitHarnessResponse } from '../../multi-core/src/gateway/harness-completion.ts'; +import type { HarnessExchange } from '../../multi-core/src/gateway/harness-exchange.ts'; import { continuation, historyRewound, @@ -21,10 +17,7 @@ import { terminalSuffix, writeNotices, } from '../../multi-core/src/gateway/harness-notices.ts'; -import { - NativeActionTracker, - type NativeProgressObserver, -} from '../../multi-core/src/gateway/harness-progress.ts'; +import { NativeActionTracker } from '../../multi-core/src/gateway/harness-progress.ts'; import { HarnessModelCalls, HarnessResponse, @@ -33,7 +26,6 @@ import { import { type HarnessSession, type HarnessSessionBase, - HarnessSessionStore, isRecord, } from '../../multi-core/src/gateway/harness-session.ts'; import type { @@ -42,7 +34,7 @@ import type { MessagesResponse, } from '../../multi-core/src/gateway/messages.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; -import { abortGraceMs, settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; +import { settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import type { AntigravityResult, AntigravityRunOptions, @@ -80,19 +72,6 @@ type Saved = HarnessSessionBase & { }; type Session = HarnessSession; -/** Everything one native turn is addressed by, fixed before the exchange starts. */ -type Turn = { - body: MessagesRequest; - context: PermissionContext; - model: AntigravityModel; - cwd: string; - identity: string; - key: string; - observe?: NativeProgressObserver; -}; - -const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); - function modelEffort(model: AntigravityModel): AntigravityRunOptions['effort'] { if (model.effort) { return model.effort; @@ -125,16 +104,10 @@ function noticeForRun(policy: AntigravityPolicy, context: PermissionContext) { : 'Compaction summary; native tools disabled.'; } -export class AntigravityHarness { +export class AntigravityHarness extends CliHarness { private readonly models: readonly AntigravityModel[]; - private readonly defaultCwd: string; - private readonly stateDirectory: string; private readonly run: AntigravityRunner; private readonly checkPermissions: CheckAntigravityPermissions; - private readonly platform: NodeJS.Platform; - private readonly store: HarnessSessionStore; - private readonly exchanges = new ExchangeRegistry({ provider: TAG, createMeta: () => ({}) }); - private closed = false; private unindexedSteps = 0; constructor( @@ -156,19 +129,13 @@ export class AntigravityHarness { platform?: NodeJS.Platform; } = {}, ) { - this.models = models; - this.defaultCwd = cwd; - this.stateDirectory = stateDirectory; - this.platform = platform; - this.run = run; - this.checkPermissions = checkPermissions ?? missingPermissions; - this.store = new HarnessSessionStore({ - provider: PROVIDER, + super({ tag: TAG, + storeProvider: PROVIDER, stateDirectory, + defaultCwd: cwd, platform, version: SESSION_VERSION, - runtime: () => ({}), fresh: (identity) => ({ version: SESSION_VERSION, provider: PROVIDER, @@ -177,9 +144,12 @@ export class AntigravityHarness { }), validate: (saved) => validUsage(saved.usage), }); + this.models = models; + this.run = run; + this.checkPermissions = checkPermissions ?? missingPermissions; } - private selection(body: MessagesRequest) { + protected selection(body: MessagesRequest) { return selectAntigravityModel(this.models, body.model, body.output_config?.effort); } @@ -188,101 +158,25 @@ export class AntigravityHarness { return prepareAntigravityRequest(body, model.id).inputTokens; } - /** The scope's last reply from its session record, located as `handle` locates it. */ - async recordedResponse(scope: string, context?: PermissionContext) { - const cwd = await realpath(context?.cwd ?? this.defaultCwd); - return this.store.recordedResponse(`${cwd}\0${scope}`); + protected requestKey(body: MessagesRequest) { + return { + ...body, + // Both spellings of a model name the same native request. Keying on the tagged one + // would let the plain spelling miss a completed exchange and dispatch it a second + // time, which is exactly what happens across a MULTI_DISABLE_1M_CONTEXT change. + model: nativeSpelling(body.model), + stream: undefined, + messages: antigravityHistoryHash(body.messages), + system: undefined, + }; } - async handle( - body: MessagesRequest, - scope: string, - signal: AbortSignal, - emit?: Emit, - context?: PermissionContext, - observe?: NativeProgressObserver, - ): Promise { - if (this.closed) { - throw new Error('Antigravity harness is closed'); - } - signal.throwIfAborted(); - if (!context) { - throw new Error('Antigravity requires an explicit Claude permission context'); - } - const model = this.selection(body); - this.validate(body); - const cwd = await realpath(context.cwd ?? this.defaultCwd); - const identity = `${cwd}\0${scope}`; - const key = digest([ - PROVIDER, - identity, - { - ...body, - // Both spellings of a model name the same native request. Keying on the tagged one - // would let the plain spelling miss a completed exchange and dispatch it a second - // time, which is exactly what happens across a MULTI_DISABLE_1M_CONTEXT change. - model: nativeSpelling(body.model), - stream: undefined, - messages: antigravityHistoryHash(body.messages), - system: undefined, - }, - { - permissionMode: context.permissionMode, - tools: context.tools, - disallowedTools: context.disallowedTools, - nativePermissionError: context.nativePermissionError, - compaction: context.compaction, - }, - ]); - const turn: Turn = { body, context, model, cwd, identity, key, observe }; - let exchange = this.exchanges.get(key); - if (!exchange) { - if (this.exchanges.size >= 256) { - throw new Error('Too many concurrent Antigravity requests'); - } - exchange = this.exchanges.start(key, (started, forward) => - this.cachedExecute(turn, started, forward), - ); - } - return this.exchanges.observe(exchange, signal, emit); + protected providerError(error: unknown) { + return new AntigravityProviderError(error); } - private async cachedExecute(turn: Turn, exchange: HarnessExchange, emit: Emit) { - let lease: Awaited['acquireLease']>>; - try { - // A first-load race is a deterministic conflict, so it must leave here as - // this provider's own failure: a bare busy error reads as a retryable 502. - lease = await this.store.acquireLease(turn.identity); - } catch (error) { - throw new AntigravityProviderError(error); - } - const session = lease.session; - try { - const replayed = await replayPersisted({ - stateDirectory: this.stateDirectory, - key: turn.key, - saved: session.saved, - emit, - provider: TAG, - }); - if (replayed) { - return replayed; - } - await archiveHarnessReply({ - session, - stateDirectory: this.stateDirectory, - platform: this.platform, - }); - return await this.execute(turn, session, exchange, emit); - } finally { - await lease.release(); - // The durable record holds everything; an idle attachment only pins its lock and replay events. - await this.store.evictIdle(session); - } - } - - private async execute( - turn: Turn, + protected async execute( + turn: CliTurn, session: Session, exchange: HarnessExchange, emit: Emit, @@ -309,12 +203,13 @@ export class AntigravityHarness { } private async runTurn( - turn: Turn, + turn: CliTurn, acquired: { session: Session; messages: MessagesRequest['messages']; rewound: boolean }, exchange: HarnessExchange, emit: Emit, ): Promise { - const { body, context, model, cwd, key } = turn; + const { body, context, cwd, key } = turn; + const model = turn.selection; const { session, messages, rewound } = acquired; const signal = exchange.controller.signal; let initConversationId: string | undefined; @@ -491,19 +386,6 @@ export class AntigravityHarness { } observeAntigravityStep(update, actions, () => `tool-${++this.unindexedSteps}`); } - - async close() { - this.closed = true; - const running: Promise[] = []; - for (const exchange of this.exchanges.all()) { - if (!exchange.settled) { - exchange.controller.abort(new Error('Antigravity gateway closed')); - running.push(exchange.result); - } - } - await bounded(Promise.allSettled(running)); - await this.store.closeAll(); - } } function appendDiagnostics(response: HarnessResponse, result: AntigravityResult, stderr: string) { @@ -573,20 +455,6 @@ function validUsage(usage: AntigravityUsage | undefined) { ); } -async function bounded(operation: Promise) { - let timer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - operation, - new Promise((resolve) => { - timer = setTimeout(resolve, abortGraceMs); - }), - ]); - } finally { - clearTimeout(timer); - } -} - const missingPermissions: CheckAntigravityPermissions = async () => { throw new Error('Antigravity native permission policy is not configured'); }; @@ -594,15 +462,11 @@ const missingPermissions: CheckAntigravityPermissions = async () => { export class AntigravityProviderError extends Error { readonly failure: { status: number; message: string }; constructor(error: unknown) { - const detail = error && typeof error === 'object' && 'message' in error ? error.message : error; - const message = String(detail ?? 'unknown Antigravity failure') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 500); - super(message, { cause: error }); - this.name = 'AntigravityProviderError'; // Deterministic refusals are request errors: a retryable status turned one // into ten paid attempts. - this.failure = { status: classifyHarnessFailure(error).status, message }; + const failure = harnessFailure(error, 'unknown Antigravity failure'); + super(failure.message, { cause: error }); + this.name = 'AntigravityProviderError'; + this.failure = failure; } } diff --git a/plugins/multi-core/src/gateway/harness-cli.ts b/plugins/multi-core/src/gateway/harness-cli.ts new file mode 100644 index 0000000..ba1d394 --- /dev/null +++ b/plugins/multi-core/src/gateway/harness-cli.ts @@ -0,0 +1,227 @@ +import { createHash } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; +import { archiveHarnessReply } from './harness-completion.ts'; +import { ExchangeRegistry, type HarnessExchange, replayPersisted } from './harness-exchange.ts'; +import { classifyHarnessFailure } from './harness-failure.ts'; +import type { NativeProgressObserver } from './harness-progress.ts'; +import { + type HarnessSession, + type HarnessSessionBase, + HarnessSessionStore, +} from './harness-session.ts'; +import type { Emit, MessagesRequest, MessagesResponse } from './messages.ts'; +import type { PermissionContext } from './mode-hook.ts'; +import { abortGraceMs } from './settle.ts'; + +const MAX_CONCURRENT_EXCHANGES = 256; + +export const digest = (value: unknown) => + createHash('sha256').update(JSON.stringify(value)).digest('hex'); + +/** Waits for `operation`, but never longer than `timeoutMs`; it never rejects on timeout. */ +export async function boundedWait(operation: Promise, timeoutMs = abortGraceMs) { + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + operation, + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** + * The failure a CLI harness reports: a one-line message capped at 500 characters, and + * the status the shared policy gives it. `advise` may append provider guidance. + */ +export function harnessFailure( + error: unknown, + fallback: string, + advise?: (reported: string) => string | undefined, +): { status: number; message: string } { + const detail = error && typeof error === 'object' && 'message' in error ? error.message : error; + const reported = String(detail ?? fallback); + const advice = advise?.(reported); + const message = `${reported}${advice ? ` ${advice}` : ''}` + .replace(/\s+/g, ' ') + .trim() + .slice(0, 500); + return { status: classifyHarnessFailure(error).status, message }; +} + +/** Everything one native turn is addressed by, fixed before the exchange starts. */ +export type CliTurn = { + body: MessagesRequest; + context: PermissionContext; + selection: Selection; + cwd: string; + identity: string; + key: string; + observe?: NativeProgressObserver; +}; + +export type CliHarnessOptions = { + /** Display name used in every message the caller reads. */ + tag: string; + /** The persisted session discriminator and the leading element of the exchange key. */ + storeProvider: string; + stateDirectory: string; + defaultCwd: string; + platform: NodeJS.Platform; + version: number; + fresh: (identity: string) => Saved; + validate: (saved: Partial) => boolean; +}; + +/** + * The request, exchange, lease and shutdown flow shared by the CLI-backed harnesses + * (Antigravity, Grok). A provider supplies its model selection, the request part of + * the exchange key, its error type, and the turn itself. + */ +export abstract class CliHarness { + protected readonly tag: string; + protected readonly stateDirectory: string; + protected readonly platform: NodeJS.Platform; + protected readonly store: HarnessSessionStore; + private readonly storeProvider: string; + private readonly defaultCwd: string; + private readonly exchanges: ExchangeRegistry; + private closed = false; + + constructor(options: CliHarnessOptions) { + this.tag = options.tag; + this.storeProvider = options.storeProvider; + this.stateDirectory = options.stateDirectory; + this.defaultCwd = options.defaultCwd; + this.platform = options.platform; + this.exchanges = new ExchangeRegistry({ provider: options.tag, createMeta: () => ({}) }); + this.store = new HarnessSessionStore({ + provider: options.storeProvider, + tag: options.tag, + stateDirectory: options.stateDirectory, + platform: options.platform, + version: options.version, + runtime: () => ({}), + fresh: options.fresh, + validate: options.validate, + }); + } + + protected abstract selection(body: MessagesRequest): Selection; + + /** The part of the exchange key that names the request itself, before permissions. */ + protected abstract requestKey(body: MessagesRequest): unknown; + + /** This provider's error for a failed turn; its `failure.status` follows the shared policy. */ + protected abstract providerError(error: unknown): Error; + + /** Runs one turn on a held session. Failures leave as this provider's own error. */ + protected abstract execute( + turn: CliTurn, + session: HarnessSession, + exchange: HarnessExchange, + emit: Emit, + ): Promise; + + abstract validate(body: MessagesRequest): number; + + /** The scope's last reply from its session record, located as `handle` locates it. */ + async recordedResponse(scope: string, context?: PermissionContext) { + const cwd = await realpath(context?.cwd ?? this.defaultCwd); + return this.store.recordedResponse(`${cwd}\0${scope}`); + } + + async handle( + body: MessagesRequest, + scope: string, + signal: AbortSignal, + emit?: Emit, + context?: PermissionContext, + observe?: NativeProgressObserver, + ): Promise { + if (this.closed) { + throw new Error(`${this.tag} harness is closed`); + } + signal.throwIfAborted(); + if (!context) { + throw new Error(`${this.tag} requires an explicit Claude permission context`); + } + const selection = this.selection(body); + this.validate(body); + const cwd = await realpath(context.cwd ?? this.defaultCwd); + const identity = `${cwd}\0${scope}`; + const key = digest([ + this.storeProvider, + identity, + this.requestKey(body), + { + permissionMode: context.permissionMode, + tools: context.tools, + disallowedTools: context.disallowedTools, + nativePermissionError: context.nativePermissionError, + compaction: context.compaction, + }, + ]); + const turn: CliTurn = { body, context, selection, cwd, identity, key, observe }; + let exchange = this.exchanges.get(key); + if (!exchange) { + if (this.exchanges.size >= MAX_CONCURRENT_EXCHANGES) { + throw new Error(`Too many concurrent ${this.tag} requests`); + } + exchange = this.exchanges.start(key, (started, forward) => + this.serve(turn, started, forward), + ); + } + return this.exchanges.observe(exchange, signal, emit); + } + + private async serve(turn: CliTurn, exchange: HarnessExchange, emit: Emit) { + let lease: Awaited['acquireLease']>>; + try { + // A first-load race is a deterministic conflict, so it must leave here as + // this provider's own failure: a bare busy error reads as a retryable 502. + lease = await this.store.acquireLease(turn.identity); + } catch (error) { + throw this.providerError(error); + } + const session = lease.session; + try { + const replayed = await replayPersisted({ + stateDirectory: this.stateDirectory, + key: turn.key, + saved: session.saved, + emit, + provider: this.tag, + }); + if (replayed !== undefined) { + return replayed; + } + await archiveHarnessReply({ + session, + stateDirectory: this.stateDirectory, + platform: this.platform, + }); + return await this.execute(turn, session, exchange, emit); + } finally { + await lease.release(); + // The durable record holds everything; an idle attachment only pins its lock and replay events. + await this.store.evictIdle(session); + } + } + + async close() { + this.closed = true; + const running: Promise[] = []; + for (const exchange of this.exchanges.all()) { + if (!exchange.settled) { + exchange.controller.abort(new Error(`${this.tag} gateway closed`)); + running.push(exchange.result); + } + } + await boundedWait(Promise.allSettled(running)); + await this.store.closeAll(); + } +} diff --git a/plugins/multi-cursor/src/harness.ts b/plugins/multi-cursor/src/harness.ts index ba97097..f970378 100644 --- a/plugins/multi-cursor/src/harness.ts +++ b/plugins/multi-cursor/src/harness.ts @@ -11,6 +11,7 @@ import type { TokenUsage, } from '@cursor/sdk'; import type { WorkerPermissions } from '../../multi-core/src/gateway/agent-definitions.ts'; +import { boundedWait } from '../../multi-core/src/gateway/harness-cli.ts'; import { archiveHarnessReply, commitHarnessResponse, @@ -864,7 +865,10 @@ export class CursorHarness { exchange.controller.abort(new Error('Cursor gateway closed')); } } - await bounded(Promise.allSettled(this.registry.all().map((exchange) => exchange.result))); + await boundedWait( + Promise.allSettled(this.registry.all().map((exchange) => exchange.result)), + 1000, + ); for (const session of [...this.store.sessions()]) { this.closeAgent(session.runtime.agent); } @@ -898,17 +902,3 @@ function savedAgentId(saved: unknown): string | undefined { } return saved.agentId; } - -async function bounded(operation: Promise) { - let timer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - operation, - new Promise((resolve) => { - timer = setTimeout(resolve, 1000); - }), - ]); - } finally { - clearTimeout(timer); - } -} diff --git a/plugins/multi-grok/src/harness.ts b/plugins/multi-grok/src/harness.ts index e2ea1c2..d1ec42e 100644 --- a/plugins/multi-grok/src/harness.ts +++ b/plugins/multi-grok/src/harness.ts @@ -1,17 +1,14 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { realpath } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import { - archiveHarnessReply, - commitHarnessResponse, -} from '../../multi-core/src/gateway/harness-completion.ts'; + CliHarness, + type CliTurn, + digest, + harnessFailure, +} from '../../multi-core/src/gateway/harness-cli.ts'; +import { commitHarnessResponse } from '../../multi-core/src/gateway/harness-completion.ts'; import type { HarnessExchange } from '../../multi-core/src/gateway/harness-exchange.ts'; -import { - ExchangeRegistry, - replayPersisted, -} from '../../multi-core/src/gateway/harness-exchange.ts'; -import { classifyHarnessFailure } from '../../multi-core/src/gateway/harness-failure.ts'; import { continuation, historyRewound, @@ -19,7 +16,6 @@ import { stderrDiagnostics, writeNotices, } from '../../multi-core/src/gateway/harness-notices.ts'; -import type { NativeProgressObserver } from '../../multi-core/src/gateway/harness-progress.ts'; import { NativeActionTracker } from '../../multi-core/src/gateway/harness-progress.ts'; import type { HarnessUsageFields } from '../../multi-core/src/gateway/harness-response.ts'; import { @@ -30,14 +26,13 @@ import type { HarnessSession, HarnessSessionBase, } from '../../multi-core/src/gateway/harness-session.ts'; -import { HarnessSessionStore } from '../../multi-core/src/gateway/harness-session.ts'; import type { Emit, MessagesRequest, MessagesResponse, } from '../../multi-core/src/gateway/messages.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; -import { abortGraceMs, settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; +import { settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import type { GrokRunOptions, GrokRunResult, GrokStreamEvent, GrokUsage } from './cli.ts'; import { runGrok } from './cli.ts'; import { grokFailureAdvice } from './errors.ts'; @@ -65,21 +60,12 @@ type GrokSaved = HarnessSessionBase & { }; type GrokSession = HarnessSession; -const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); +type GrokSelection = ReturnType; -export class GrokHarness { +export class GrokHarness extends CliHarness { private readonly models: readonly GrokModel[]; - private readonly defaultCwd: string; - private readonly stateDirectory: string; - private readonly platform: NodeJS.Platform; private readonly run: GrokRunner; private readonly checkPermissions: CheckGrokPermissions; - private readonly store: HarnessSessionStore; - private readonly exchanges = new ExchangeRegistry({ - provider: PROVIDER, - createMeta: () => ({}), - }); - private closed = false; constructor( models: readonly GrokModel[], @@ -97,27 +83,24 @@ export class GrokHarness { platform?: NodeJS.Platform; } = {}, ) { - this.models = models; - this.defaultCwd = cwd; - this.stateDirectory = stateDirectory; - this.platform = platform; - this.run = run; - this.checkPermissions = checkPermissions ?? missingPermissions; - this.store = new HarnessSessionStore({ - provider: STORE_PROVIDER, + super({ tag: PROVIDER, + storeProvider: STORE_PROVIDER, stateDirectory, + defaultCwd: cwd, platform, version: 1, - runtime: () => ({}), fresh: (identity) => ({ version: 1, provider: STORE_PROVIDER, identity, interrupted: false }), validate: (saved) => (saved.sessionId === undefined || isUuid(saved.sessionId)) && (saved.cost === undefined || (typeof saved.cost === 'number' && saved.cost >= 0)), }); + this.models = models; + this.run = run; + this.checkPermissions = checkPermissions ?? missingPermissions; } - private selection(body: MessagesRequest) { + protected selection(body: MessagesRequest) { return selectGrokModel(this.models, body.model, body.output_config?.effort); } @@ -126,110 +109,17 @@ export class GrokHarness { return prepareGrokRequest(body, selection.model.id).inputTokens; } - /** The scope's last reply from its session record, located as `handle` locates it. */ - async recordedResponse(scope: string, context?: PermissionContext) { - const cwd = await realpath(context?.cwd ?? this.defaultCwd); - return this.store.recordedResponse(`${cwd}\0${scope}`); + protected requestKey(body: MessagesRequest) { + return { + ...body, + stream: undefined, + messages: grokHistoryHash(body.messages), + system: undefined, + }; } - async handle( - body: MessagesRequest, - scope: string, - signal: AbortSignal, - emit?: Emit, - context?: PermissionContext, - observe?: NativeProgressObserver, - ): Promise { - if (this.closed) { - throw new Error('Grok harness is closed'); - } - signal.throwIfAborted(); - if (!context) { - throw new Error('Grok requires an explicit Claude permission context'); - } - const selection = this.selection(body); - this.validate(body); - const cwd = await realpath(context.cwd ?? this.defaultCwd); - const identity = `${cwd}\0${scope}`; - const key = digest([ - 'grok', - identity, - { - ...body, - stream: undefined, - messages: grokHistoryHash(body.messages), - system: undefined, - }, - { - permissionMode: context.permissionMode, - tools: context.tools, - disallowedTools: context.disallowedTools, - nativePermissionError: context.nativePermissionError, - compaction: context.compaction, - }, - ]); - let exchange = this.exchanges.get(key); - if (!exchange) { - if (this.exchanges.size >= 256) { - throw new Error('Too many concurrent Grok requests'); - } - exchange = this.exchanges.start(key, (startedExchange, forward) => - this.serve(body, context, selection, cwd, identity, key, startedExchange, forward, observe), - ); - } - return this.exchanges.observe(exchange, signal, emit); - } - - private async serve( - body: MessagesRequest, - context: PermissionContext, - selection: ReturnType, - cwd: string, - identity: string, - key: string, - exchange: HarnessExchange, - emit: Emit, - observe?: NativeProgressObserver, - ): Promise { - let lease: Awaited['acquireLease']>>; - try { - lease = await this.store.acquireLease(identity); - } catch (error) { - throw new GrokProviderError(error); - } - const session = lease.session; - try { - const replayed = await replayPersisted({ - stateDirectory: this.stateDirectory, - key, - saved: session.saved, - emit, - provider: PROVIDER, - }); - if (replayed !== undefined) { - return replayed; - } - await archiveHarnessReply({ - session, - stateDirectory: this.stateDirectory, - platform: this.platform, - }); - return await this.execute( - body, - context, - selection, - cwd, - session, - key, - exchange, - emit, - observe, - ); - } finally { - await lease.release(); - // The durable record holds everything; an idle attachment only pins its lock and replay events. - await this.store.evictIdle(session); - } + protected providerError(error: unknown) { + return new GrokProviderError(error); } /** @@ -238,10 +128,7 @@ export class GrokHarness { * `execute` to keep its own branching under the cognitive-complexity limit. */ private async openTurn( - body: MessagesRequest, - context: PermissionContext, - selection: ReturnType, - cwd: string, + turn: CliTurn, session: GrokSession, wasInterrupted: boolean, emit: Emit, @@ -250,6 +137,7 @@ export class GrokHarness { policy: GrokPolicy; response: HarnessResponse; }> { + const { body, context, selection, cwd } = turn; // Computed only once the record is held, so a continuation error still releases // it in `execute`'s `finally` instead of leaving the session busy forever. const messages = session.saved.sessionId ? continuation(body, PROVIDER) : (body.messages ?? []); @@ -277,26 +165,19 @@ export class GrokHarness { return { prepared, policy, response }; } - private async execute( - body: MessagesRequest, - context: PermissionContext, - selection: ReturnType, - cwd: string, + protected async execute( + turn: CliTurn, session: GrokSession, - key: string, exchange: HarnessExchange, emit: Emit, - observe?: NativeProgressObserver, ): Promise { + const { selection, cwd, key, observe } = turn; const signal = exchange.controller.signal; const wasInterrupted = session.saved.interrupted; let started = false; try { const { prepared, policy, response } = await this.openTurn( - body, - context, - selection, - cwd, + turn, session, wasInterrupted, emit, @@ -379,7 +260,7 @@ export class GrokHarness { session: GrokSession; response: HarnessResponse; outcome: GrokRunResult; - selection: ReturnType; + selection: GrokSelection; key: string; exchange: HarnessExchange; emit: Emit; @@ -425,19 +306,6 @@ export class GrokHarness { }, }); } - - async close() { - this.closed = true; - const running: Promise[] = []; - for (const exchange of this.exchanges.all()) { - if (!exchange.settled) { - exchange.controller.abort(new Error('Grok gateway closed')); - running.push(exchange.result); - } - } - await bounded(Promise.allSettled(running)); - await this.store.closeAll(); - } } /** @@ -495,20 +363,6 @@ function isUuid(value: unknown): value is string { return typeof value === 'string' && /^[0-9a-f-]{36}$/.test(value); } -async function bounded(operation: Promise) { - let timer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - operation, - new Promise((resolve) => { - timer = setTimeout(resolve, abortGraceMs); - }), - ]); - } finally { - clearTimeout(timer); - } -} - const missingPermissions: CheckGrokPermissions = async () => { throw new Error('Grok native permission policy is not configured'); }; @@ -517,15 +371,9 @@ export class GrokProviderError extends Error { readonly failure: { status: number; message: string }; constructor(error: unknown) { // Deterministic failures are request errors; retrying them repeats a paid run. - const detail = error && typeof error === 'object' && 'message' in error ? error.message : error; - const reported = String(detail ?? 'unknown Grok failure'); - const advice = grokFailureAdvice(reported); - const message = `${reported}${advice ? ` ${advice}` : ''}` - .replace(/\s+/g, ' ') - .trim() - .slice(0, 500); - super(message, { cause: error }); + const failure = harnessFailure(error, 'unknown Grok failure', grokFailureAdvice); + super(failure.message, { cause: error }); this.name = 'GrokProviderError'; - this.failure = { status: classifyHarnessFailure(error).status, message }; + this.failure = failure; } } diff --git a/test/unit/native-harness-cli.test.ts b/test/unit/native-harness-cli.test.ts new file mode 100644 index 0000000..8949365 --- /dev/null +++ b/test/unit/native-harness-cli.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { boundedWait, harnessFailure } from '../../plugins/multi-core/src/gateway/harness-cli.ts'; +import { HarnessBusyError } from '../../plugins/multi-core/src/gateway/harness-session.ts'; + +test('boundedWait returns at its timeout without rejecting a slow operation', async () => { + const started = Date.now(); + await boundedWait(new Promise(() => undefined), 20); + assert(Date.now() - started < 1000); + await boundedWait(Promise.resolve('done'), 20); +}); + +test('harnessFailure caps one line, appends advice, and applies the shared status policy', () => { + const busy = harnessFailure(new HarnessBusyError('agent already running\nnow'), 'unknown X'); + assert.equal(busy.status, 400); + assert.equal(busy.message.includes('\n'), false); + const advised = harnessFailure(new Error('boom'), 'unknown X', () => 'Try again.'); + assert.deepEqual(advised, { status: 502, message: 'boom Try again.' }); + assert.equal(harnessFailure(undefined, 'unknown X').message, 'unknown X'); + assert.equal(harnessFailure(new Error('x'.repeat(900)), 'unknown X').message.length, 500); +}); From f33ecaa8e999a543be889637d97ff05345f9d2e7 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:20:49 -0400 Subject: [PATCH 27/36] Move provider request construction out of the gateway and share routing helpers OpenAI and Zen request construction, endpoints, model lookup and auth help move behind a PreparedRequest interface in their provider folders. One providerOf table replaces the duplicated prefix routing. The shared settings check and permission merge get neutral names in core. handleModRoute takes a context object, and cancelling a compaction now also clears the main loop's permission boundary. isRecord and the bounded-map eviction helpers each have one definition. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-core/src/gateway/approval.ts | 35 +- plugins/multi-core/src/gateway/bounded.ts | 47 ++ .../multi-core/src/gateway/conversation.ts | 5 +- .../multi-core/src/gateway/display-rows.ts | 27 +- ...cursor-settings.ts => harness-settings.ts} | 39 +- plugins/multi-core/src/gateway/mod-bridge.ts | 12 +- plugins/multi-core/src/gateway/mod-routes.ts | 72 ++-- plugins/multi-core/src/gateway/mode-hook.ts | 38 +- .../src/gateway/provider-request.ts | 61 +++ plugins/multi-core/src/gateway/provider.ts | 55 +++ plugins/multi-core/src/gateway/receipts.ts | 16 +- plugins/multi-core/src/gateway/record.ts | 4 + plugins/multi-core/src/gateway/safeguards.ts | 9 +- plugins/multi-core/src/gateway/server.ts | 404 +++++------------- .../multi-core/src/gateway/tool-observer.ts | 11 +- plugins/multi-core/src/launcher.ts | 27 +- plugins/multi-cursor/src/harness.ts | 9 +- plugins/multi-cursor/src/permissions.ts | 16 - plugins/multi-openai/src/gateway-request.ts | 101 +++++ plugins/multi-zen/src/gateway-request.ts | 72 ++++ test/unit/gateway-provider-bounded.test.ts | 49 +++ test/unit/native-cursor-permissions.test.ts | 6 +- test/unit/native-cursor-settings.test.ts | 12 +- test/unit/native-mod-routes.test.ts | 16 + 24 files changed, 649 insertions(+), 494 deletions(-) create mode 100644 plugins/multi-core/src/gateway/bounded.ts rename plugins/multi-core/src/gateway/{cursor-settings.ts => harness-settings.ts} (93%) create mode 100644 plugins/multi-core/src/gateway/provider-request.ts create mode 100644 plugins/multi-core/src/gateway/provider.ts create mode 100644 plugins/multi-core/src/gateway/record.ts create mode 100644 plugins/multi-openai/src/gateway-request.ts create mode 100644 plugins/multi-zen/src/gateway-request.ts create mode 100644 test/unit/gateway-provider-bounded.test.ts diff --git a/plugins/multi-core/src/gateway/approval.ts b/plugins/multi-core/src/gateway/approval.ts index 5fb0300..06ccec7 100644 --- a/plugins/multi-core/src/gateway/approval.ts +++ b/plugins/multi-core/src/gateway/approval.ts @@ -1,5 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; +import { setBounded } from './bounded.ts'; import type { MessagesRequest } from './messages.ts'; +import { isRecord } from './record.ts'; /** Normalize a workspace path for the classifier's omitted-cd comparison. */ export function approvalCwdForComparison( @@ -42,20 +44,17 @@ export interface ApprovalVerdict { outcome: 'allow' | 'deny'; } -const record = (value: unknown): value is Record => - Boolean(value) && typeof value === 'object' && !Array.isArray(value); - /** Claude can retry classification using the working model's ID. */ export function isApprovalRequest(body: unknown): boolean { - if (!record(body) || !Array.isArray(body.messages)) { + if (!isRecord(body) || !Array.isArray(body.messages)) { return false; } return body.messages.some( (message) => - record(message) && + isRecord(message) && Array.isArray(message.content) && message.content.some( - (block) => record(block) && block.type === 'text' && block.text === '\n', + (block) => isRecord(block) && block.type === 'text' && block.text === '\n', ), ); } @@ -63,7 +62,7 @@ export function isApprovalRequest(body: unknown): boolean { /** Strict, version-sensitive Claude 2.1.263 classifier envelope. Unknown formats fail closed. */ function approvalEnvelope(body: unknown) { if ( - !record(body) || + !isRecord(body) || body.stream || (body.tools !== undefined && (!Array.isArray(body.tools) || body.tools.length)) || !Array.isArray(body.messages) || @@ -72,17 +71,17 @@ function approvalEnvelope(body: unknown) { throw new Error('Unsupported approval request'); } const message = body.messages[0]; - if (!record(message) || message.role !== 'user' || !Array.isArray(message.content)) { + if (!isRecord(message) || message.role !== 'user' || !Array.isArray(message.content)) { throw new Error('Unsupported approval message'); } const text = message.content.map((block) => { - if (!record(block) || block.type !== 'text' || typeof block.text !== 'string') { + if (!isRecord(block) || block.type !== 'text' || typeof block.text !== 'string') { throw new Error('Unsupported approval content'); } return block.text; }); if ( - !record(body.metadata) || + !isRecord(body.metadata) || typeof body.metadata.user_id !== 'string' || !body.metadata.user_id ) { @@ -109,7 +108,7 @@ function approvalPolicy(value: unknown): string | undefined { !Array.isArray(value) || value.some( (block) => - !record(block) || + !isRecord(block) || block.type !== 'text' || typeof block.text !== 'string' || block.text.length > 131072, @@ -151,7 +150,7 @@ export function parseApprovalRequest(body: unknown): ApprovalAction & { key: str .filter(Boolean) .map((line) => { const entry: unknown = JSON.parse(line); - if (!record(entry)) { + if (!isRecord(entry)) { throw new Error('Unsupported approval transcript entry'); } return entry; @@ -263,10 +262,12 @@ export class NativeApprovalBridge { return; } // Only denials cross stages; the oldest one is evicted at the fixed cache limit. - const oldest = this.denied.keys().next(); - if (this.denied.size >= 64 && !oldest.done) { - this.denied.delete(oldest.value); - } - this.denied.set(key, { verdict: { ...verdict }, expires: Date.now() + 60000 }); + setBounded( + this.denied, + key, + { verdict: { ...verdict }, expires: Date.now() + 60000 }, + 64, + 'insertion', + ); } } diff --git a/plugins/multi-core/src/gateway/bounded.ts b/plugins/multi-core/src/gateway/bounded.ts new file mode 100644 index 0000000..bccf92f --- /dev/null +++ b/plugins/multi-core/src/gateway/bounded.ts @@ -0,0 +1,47 @@ +// Capacity-bounded Map and Set writes. A Map iterates in insertion order, so the first key +// is the oldest. Callers choose the recency rule explicitly; none of these refuses a write. + +/** + * `lru`: a write makes the key the newest, and the least recently written entry makes room. + * `insertion`: only a new key can evict, and an existing key keeps its place in the order. + */ +export type EvictionOrder = 'lru' | 'insertion'; + +function evictOldest(map: Map, capacity: number) { + while (map.size >= capacity) { + const oldest = map.keys().next(); + if (oldest.done) { + return; + } + map.delete(oldest.value); + } +} + +/** Stores `value` and keeps the map at or below `capacity` entries. */ +export function setBounded( + map: Map, + key: K, + value: V, + capacity: number, + order: EvictionOrder, +): void { + if (order === 'lru') { + map.delete(key); + } + if (!map.has(key)) { + evictOldest(map, capacity); + } + map.set(key, value); +} + +/** Adds `key`, evicting the oldest members once the set exceeds `capacity`. */ +export function addBounded(set: Set, key: K, capacity: number): void { + set.add(key); + while (set.size > capacity) { + const oldest = set.values().next(); + if (oldest.done) { + return; + } + set.delete(oldest.value); + } +} diff --git a/plugins/multi-core/src/gateway/conversation.ts b/plugins/multi-core/src/gateway/conversation.ts index 1b3b93a..ab7e8e9 100644 --- a/plugins/multi-core/src/gateway/conversation.ts +++ b/plugins/multi-core/src/gateway/conversation.ts @@ -1,4 +1,5 @@ import type { ContentBlock, RequestMessage } from './messages.ts'; +import { isRecord } from './record.ts'; import { callId, toolName } from './tools.ts'; const IMAGE_MEDIA_TYPES: readonly unknown[] = [ @@ -21,10 +22,6 @@ export type NormalizedConversationItem = export type AssistantReasoningDecoder = (block: ContentBlock) => R | undefined; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function contentBlocks(value: unknown): ContentBlock[] { if (typeof value === 'string') { return [{ type: 'text', text: value }]; diff --git a/plugins/multi-core/src/gateway/display-rows.ts b/plugins/multi-core/src/gateway/display-rows.ts index 505320a..8e2ff51 100644 --- a/plugins/multi-core/src/gateway/display-rows.ts +++ b/plugins/multi-core/src/gateway/display-rows.ts @@ -1,10 +1,12 @@ import { randomBytes, randomUUID } from 'node:crypto'; +import { setBounded } from './bounded.ts'; import type { ContentBlock, MessagesRequest, MessagesResponse, RequestMessage, } from './messages.ts'; +import { isRecord } from './record.ts'; /** * Native harness actions drawn as Claude Code tool rows. @@ -341,14 +343,6 @@ function boundedInput(input: unknown): Record { return output; } -function remember(map: Map, key: string, value: T, capacity: number) { - map.delete(key); - map.set(key, value); - while (map.size > capacity) { - map.delete(map.keys().next().value as string); - } -} - /** * The gateway's side of display rows: the native tool names offered to the mod, * the names it acknowledged registering, the tokens it issued, and the text each @@ -390,7 +384,7 @@ export class DisplayRows { own.add(name); } } - remember(this.registered, session, own, maximumSessions); + setBounded(this.registered, session, own, maximumSessions, 'lru'); return { registered: own.size }; } @@ -407,11 +401,12 @@ export class DisplayRows { } const id = `toolu_multi_${randomUUID().replaceAll('-', '')}`; const token = randomBytes(16).toString('hex'); - remember( + setBounded( this.rows, token, { session, id, output: bounded(row.output, maximumOutput), isError: row.error }, maximumRows, + 'lru', ); return { type: 'tool_use', @@ -545,10 +540,6 @@ export function recordedFollowUp( return { text: response.multi_followup, usage: response.usage }; } -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** * Only a well-formed request is rewritten or answered here; anything else passes * unchanged to the validation that refuses it with the provider's own error. @@ -557,15 +548,15 @@ function wellFormed(body: MessagesRequest): boolean { const tools: unknown = body.tools; const messages: unknown = body.messages; return ( - (tools === undefined || (Array.isArray(tools) && tools.every(isObject))) && + (tools === undefined || (Array.isArray(tools) && tools.every(isRecord))) && (messages === undefined || (Array.isArray(messages) && messages.every( (message) => - isObject(message) && + isRecord(message) && typeof message.role === 'string' && (typeof message.content === 'string' || - (Array.isArray(message.content) && message.content.every(isObject))), + (Array.isArray(message.content) && message.content.every(isRecord))), ))) ); } @@ -668,7 +659,7 @@ function withoutReferences(block: ContentBlock): ContentBlock { } function isReferenceToDisplay(item: unknown): boolean { - return isObject(item) && item.type === 'tool_reference' && isDisplayTool(item.tool_name); + return isRecord(item) && item.type === 'tool_reference' && isDisplayTool(item.tool_name); } function withoutRows(message: RequestMessage, rows: ReadonlySet): RequestMessage { diff --git a/plugins/multi-core/src/gateway/cursor-settings.ts b/plugins/multi-core/src/gateway/harness-settings.ts similarity index 93% rename from plugins/multi-core/src/gateway/cursor-settings.ts rename to plugins/multi-core/src/gateway/harness-settings.ts index 72c7054..321efea 100644 --- a/plugins/multi-core/src/gateway/cursor-settings.ts +++ b/plugins/multi-core/src/gateway/harness-settings.ts @@ -5,12 +5,11 @@ import path from 'node:path'; import { assertCursorClaudeSettings, cursorPermissionPolicy, - mergeCursorPermissions, } from '../../../multi-cursor/src/permissions.ts'; import { pluginPermissions, type WorkerPermissions } from './agent-definitions.ts'; import type { PermissionContext } from './mode-hook.ts'; -export interface CursorSettingsOptions { +export interface HarnessSettingsOptions { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; osRelease?: string; @@ -29,7 +28,7 @@ export interface CursorSettingsOptions { } /** Every discovery option resolved to a value; not part of the public surface. */ -type SettingsDiscovery = Required; +type SettingsDiscovery = Required; export const policyCommandTimeoutMs = 10_000; @@ -71,16 +70,32 @@ const defaultRunCommand = (command: string, args: readonly string[]): Promise rules.tools?.includes(tool)); + } + return { + ...context, + tools, + disallowedTools: [...(context.disallowedTools ?? []), ...(rules.disallowedTools ?? [])], + }; +} + /** Re-read on each native dispatch; Claude-side rules cannot constrain SDK tools. */ -export async function checkCursorSettings( +export async function checkHarnessSettings( cwd: string, args: readonly string[], inlineSettings: Record, - options: CursorSettingsOptions = {}, + options: HarnessSettingsOptions = {}, ): Promise { const { sources, restrictions } = settingSources(args); await pluginPermissions(cwd, [...args, '--settings', JSON.stringify(inlineSettings)]); - let context = mergeCursorPermissions({ permissionMode: 'auto' }, restrictions); + let context = mergePermissions({ permissionMode: 'auto' }, restrictions); const { validate = cursorPermissionPolicy, cursorToolRules = options.validate === undefined } = options; const settingsOptions: SettingsDiscovery = { @@ -94,12 +109,12 @@ export async function checkCursorSettings( cursorToolRules, }; context = mergePolicies(context, await managedSettings(settingsOptions)); - context = mergeCursorPermissions( + context = mergePermissions( context, assertCursorClaudeSettings(inlineSettings, { cursorToolRules }), ); if (sources.has('user')) { - context = mergeCursorPermissions( + context = mergePermissions( context, await checkFile( path.join( @@ -113,7 +128,7 @@ export async function checkCursorSettings( } for (let directory = path.resolve(cwd); ; directory = path.dirname(directory)) { if (sources.has('project')) { - context = mergeCursorPermissions( + context = mergePermissions( context, await checkFile( path.join(directory, '.claude', 'settings.json'), @@ -123,7 +138,7 @@ export async function checkCursorSettings( ); } if (sources.has('local')) { - context = mergeCursorPermissions( + context = mergePermissions( context, await checkFile( path.join(directory, '.claude', 'settings.local.json'), @@ -146,7 +161,7 @@ function mergePolicies( ): PermissionContext { let context = initial; for (const policy of policies) { - context = mergeCursorPermissions(context, policy); + context = mergePermissions(context, policy); } return context; } @@ -177,7 +192,7 @@ function settingSources(args: readonly string[]): { } else { const [list, lastIndex] = toolArguments(value, args, index); index = lastIndex; - context = mergeCursorPermissions(context, toolRestriction(name, list)); + context = mergePermissions(context, toolRestriction(name, list)); } } return { sources: selectedSources(sources), restrictions: context }; diff --git a/plugins/multi-core/src/gateway/mod-bridge.ts b/plugins/multi-core/src/gateway/mod-bridge.ts index 37a9c5e..f299f6c 100644 --- a/plugins/multi-core/src/gateway/mod-bridge.ts +++ b/plugins/multi-core/src/gateway/mod-bridge.ts @@ -1,3 +1,4 @@ +import { setBounded } from './bounded.ts'; import type { NativeObservation } from './harness-progress.ts'; const MAX_LINE = 160; @@ -55,10 +56,6 @@ export class ModBridge { private readonly lifecycle = new Map(); private readonly telemetry = new Map(); - recordSession(key: string, value: { effective: Effective; cwd?: string; generation?: number }) { - return this.record(key, value); - } - mode(key: string) { const snapshot = this.snapshots.get(key); if (snapshot) { @@ -80,7 +77,7 @@ export class ModBridge { } } - private record(key: string, value: { effective: Effective; cwd?: string; generation?: number }) { + recordSession(key: string, value: { effective: Effective; cwd?: string; generation?: number }) { if ( value.generation !== undefined && value.generation !== this.snapshots.get(key)?.generation @@ -197,10 +194,7 @@ export class ModBridge { } observeStep(key: string, value: { model: string; effort?: string | number }) { - if (!this.telemetry.has(key) && this.telemetry.size >= MAX_KEYS) { - this.telemetry.delete(this.telemetry.keys().next().value as string); - } - this.telemetry.set(key, value); + setBounded(this.telemetry, key, value, MAX_KEYS, 'insertion'); } /** diff --git a/plugins/multi-core/src/gateway/mod-routes.ts b/plugins/multi-core/src/gateway/mod-routes.ts index 97ce266..efa890b 100644 --- a/plugins/multi-core/src/gateway/mod-routes.ts +++ b/plugins/multi-core/src/gateway/mod-routes.ts @@ -6,10 +6,8 @@ import { usageRoute } from './mod-usage.ts'; import type { PermissionContext, PermissionModes } from './mode-hook.ts'; import type { ProviderUsageDashboard } from './provider-usage.ts'; import type { ReceiptLedger } from './receipts.ts'; +import { isRecord } from './record.ts'; -function record(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} function text(value: unknown, name: string) { if (typeof value !== 'string' || value.length === 0 || value.length > 512) { throw new Error(`Invalid ${name}`); @@ -32,19 +30,25 @@ function method(req: IncomingMessage, expected: string) { } } +/** The gateway state the mod control plane reads and acts on. */ +export interface ModRouteContext { + bridge: ModBridge; + permissionModes?: PermissionModes; + compactions?: ModCompactions; + receipts?: ReceiptLedger; + billedUsage?: (session: string) => Promise; + dashboard?: ProviderUsageDashboard; + rows?: DisplayRows; +} + export async function handleModRoute( req: IncomingMessage, res: ServerResponse, url: URL, parsed: unknown, - bridge: ModBridge, - permissionModes?: PermissionModes, - compactions?: ModCompactions, - receipts?: ReceiptLedger, - billedUsage?: (session: string) => Promise, - dashboard?: ProviderUsageDashboard, - rows?: DisplayRows, + context: ModRouteContext, ) { + const { bridge, receipts, billedUsage, dashboard, rows } = context; try { if ( ['/multi/mod/usage', '/multi/mod/receipts', '/multi/mod/usage/complete'].includes( @@ -59,16 +63,7 @@ export async function handleModRoute( switch (url.pathname) { case '/multi/mod/telemetry': if (req.method === 'POST') { - return handlePostRoute( - req, - res, - url.pathname, - parsed, - bridge, - permissionModes, - compactions, - rows, - ); + return handlePostRoute(req, res, url.pathname, parsed, context); } method(req, 'GET'); return reply( @@ -107,16 +102,7 @@ export async function handleModRoute( bridge, ); default: - return await handlePostRoute( - req, - res, - url.pathname, - parsed, - bridge, - permissionModes, - compactions, - rows, - ); + return await handlePostRoute(req, res, url.pathname, parsed, context); } } catch (error) { return reply( @@ -132,19 +118,17 @@ async function handlePostRoute( res: ServerResponse, route: string, parsed: unknown, - bridge: ModBridge, - permissionModes?: PermissionModes, - compactions?: ModCompactions, - rows?: DisplayRows, + context: ModRouteContext, ) { + const { bridge, permissionModes, compactions, rows } = context; method(req, 'POST'); - if (!record(parsed)) { + if (!isRecord(parsed)) { throw new Error('Expected an object'); } const sessionId = text(parsed.sessionId, 'sessionId'); const key = sessionKey(sessionId, parsed.agentId); if (route.startsWith('/multi/mod/compact/')) { - return compactRoute(res, route, parsed, bridge, permissionModes, compactions); + return compactRoute(res, route, parsed, context); } switch (route) { case '/multi/mod/policy': @@ -228,7 +212,7 @@ function displayRoute( rows: DisplayRows | undefined, ) { method(req, 'POST'); - if (!record(parsed)) { + if (!isRecord(parsed)) { throw new Error('Expected an object'); } const sessionId = text(parsed.sessionId, 'sessionId'); @@ -387,16 +371,14 @@ function compactRoute( res: ServerResponse, route: string, value: Record, - bridge: ModBridge, - modes?: PermissionModes, - compactions?: ModCompactions, + { bridge, permissionModes: modes, compactions }: ModRouteContext, ) { const session = text(value.sessionId, 'sessionId'); if (route === '/multi/mod/compact/cancel') { - compactions?.cancelScope( - session, - value.agentId === undefined ? undefined : text(value.agentId, 'agentId'), - ); + const agentId = value.agentId === undefined ? undefined : text(value.agentId, 'agentId'); + compactions?.cancelScope(session, agentId); + // A cancelled compaction also disarms its permission boundary, the main loop's included. + modes?.cancelModCompaction(session, agentId); return reply(res, { accepted: true }); } const generation = optionalGeneration(value); @@ -445,7 +427,7 @@ function transcript(value: unknown): Record[] { !Array.isArray(value) || value.length === 0 || value.length > 256 || - value.some((item) => !record(item)) + value.some((item) => !isRecord(item)) ) { throw new Error('Invalid bounded compaction transcript'); } diff --git a/plugins/multi-core/src/gateway/mode-hook.ts b/plugins/multi-core/src/gateway/mode-hook.ts index 1d732ea..780ec8a 100644 --- a/plugins/multi-core/src/gateway/mode-hook.ts +++ b/plugins/multi-core/src/gateway/mode-hook.ts @@ -1,14 +1,16 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; -import { nativeSpelling } from '../../../multi-antigravity/src/models.ts'; -import { mergeCursorPermissions } from '../../../multi-cursor/src/permissions.ts'; import type { WorkerPermissions } from './agent-definitions.ts'; +import { setBounded } from './bounded.ts'; +import { mergePermissions } from './harness-settings.ts'; import { ModPolicies } from './mod-policy.ts'; +import { harnessProvider, nativeSpelling } from './provider.ts'; import { type ResolvedWorker, resolveWorker, type WorkerCatalog } from './worker-catalog.ts'; const MODES = ['default', 'acceptEdits', 'auto', 'dontAsk', 'bypassPermissions', 'plan'] as const; type PermissionMode = (typeof MODES)[number]; type WorkerExecution = 'claude' | 'harness'; +const MAX_ENTRIES = 4096; export interface PermissionContext extends WorkerPermissions { permissionMode: PermissionMode; cwd?: string; @@ -31,14 +33,7 @@ function requiredString(value: unknown, name: string): string { } function executionForModel(model: unknown): WorkerExecution { - if (typeof model !== 'string') { - return 'claude'; - } - return model.startsWith('multi/cursor/') || - model.startsWith('multi/antigravity/') || - model.startsWith('multi/grok/') - ? 'harness' - : 'claude'; + return harnessProvider(model) ? 'harness' : 'claude'; } /** Prompt-time snapshots: the existing selector takes effect at the next prompt. */ @@ -104,7 +99,7 @@ export class PermissionModes { const policy = this.policies.consume(session, generation, requiredString(context.cwd, 'cwd')); remember(this.catalogs, policy.cwd, policy.workers); this.recordModSession(session, { - ...mergeCursorPermissions(context, policy.restrictions), + ...mergePermissions(context, policy.restrictions), nativePermissionError: policy.restrictions.nativePermissionError, }); remember(this.admitted, session, structuredClone(policy.restrictions)); @@ -165,7 +160,7 @@ export class PermissionModes { private withAdmittedPolicy(session: string, context: PermissionContext): PermissionContext { const restrictions = this.admitted.get(session) ?? {}; return { - ...mergeCursorPermissions(context, restrictions), + ...mergePermissions(context, restrictions), nativePermissionError: context.nativePermissionError ?? restrictions.nativePermissionError, }; } @@ -212,7 +207,7 @@ export class PermissionModes { } this.prunePendingWorkers(); const token = randomUUID(); - const inherited = input.parentAgentId ? mergeCursorPermissions(parent, definition) : definition; + const inherited = input.parentAgentId ? mergePermissions(parent, definition) : definition; remember(this.pendingWorkers, JSON.stringify([session, token]), { ...inherited, cwd, @@ -433,7 +428,7 @@ export class PermissionModes { const inherited = ['auto', 'acceptEdits', 'bypassPermissions', 'plan'].includes( parent.permissionMode, ); - return mergeCursorPermissions( + return mergePermissions( { ...worker, nativePermissionError: worker.nativePermissionError ?? parent.nativePermissionError, @@ -516,15 +511,10 @@ function validateWorkerRequest( } } +/** + * A write refreshes the entry; at the cap the least recently written one makes room, so a + * long-lived gateway never refuses new work for the sake of identities long idle. + */ function remember(entries: Map, key: string, value: T): void { - // A write refreshes the entry; at the cap the least recently written one makes room, so a - // long-lived gateway never refuses new work for the sake of identities long idle. - entries.delete(key); - if (entries.size >= 4096) { - const oldest = entries.keys().next(); - if (!oldest.done) { - entries.delete(oldest.value); - } - } - entries.set(key, value); + setBounded(entries, key, value, MAX_ENTRIES, 'lru'); } diff --git a/plugins/multi-core/src/gateway/provider-request.ts b/plugins/multi-core/src/gateway/provider-request.ts new file mode 100644 index 0000000..9af1896 --- /dev/null +++ b/plugins/multi-core/src/gateway/provider-request.ts @@ -0,0 +1,61 @@ +// The boundary between the gateway's routing and a hosted-API provider (OpenAI, Zen). +// A provider module turns one Claude request into a `PreparedRequest`; the gateway owns +// keepalive, events, failure mapping and the reply, and never builds a provider request. + +import type { GatewayFetch } from './fetch.ts'; +import type { Emit, MessagesRequest, MessagesResponse } from './messages.ts'; + +/** The provider rejected or could not renew the credentials; answered as HTTP 401. */ +export class ProviderAuthError extends Error {} + +/** A non-2xx upstream reply, relayed with its status and `retry-after`. */ +export class UpstreamFailure extends Error { + status: number; + retryAfter: string | null; + constructor(status: number, retryAfter: string | null, provider: string, authHelp: string) { + super(`${provider} returned HTTP ${status}.${status === 401 ? authHelp : ''}`); + this.status = status; + this.retryAfter = retryAfter; + } +} + +/** Routing facts a provider reports for one request; never credentials or bodies. */ +interface ProviderEvent { + route: 'openai' | 'openai-request' | 'zen' | 'zen-request'; + agentId?: string | null; + model?: string; + effort?: string; + status?: number; +} + +/** What the gateway knows about the request before any provider sees it. */ +export interface ProviderRequestInput { + method?: string; + pathname: string; + body: MessagesRequest; + /** The Claude session the request identifies; empty when the client named none. */ + session: string; + /** The gateway's own session, for a client that identified none. */ + fallbackSession: string; + /** The session header exactly as the client sent it. */ + clientSession?: string; + agentId?: string; +} + +export interface PreparedRequest { + /** How failures name the provider. */ + label: string; + /** What a 401 adds to the failure text. */ + authHelp: string; + /** The API the request is billed through, reported on completion. */ + endpoint: string; + route: 'openai' | 'zen'; + /** The local input estimate; computed on first use, since a streamed reply needs it late. */ + inputTokens(): number; + /** Model and effort the completion reports in place of the client's request, if known. */ + completion?: { model: string; effort?: string }; + requestEvent(agentId?: string): ProviderEvent; + failureEvent(status: number, agentId?: string): ProviderEvent; + send(signal: AbortSignal, fetchImpl: GatewayFetch): Promise; + translate(stream: ReadableStream, emit: Emit | undefined): Promise; +} diff --git a/plugins/multi-core/src/gateway/provider.ts b/plugins/multi-core/src/gateway/provider.ts new file mode 100644 index 0000000..617b4f7 --- /dev/null +++ b/plugins/multi-core/src/gateway/provider.ts @@ -0,0 +1,55 @@ +// The one place that reads a model ID's provider prefix. Every routing, review, +// signal and execution decision derives from `providerOf`. + +const HARNESS_PROVIDERS = ['cursor', 'antigravity', 'grok'] as const; +export type HarnessProvider = (typeof HARNESS_PROVIDERS)[number]; +type ProviderName = HarnessProvider | 'openai' | 'zen'; +export type ProviderRoute = ProviderName | 'anthropic'; + +const PROVIDERS: readonly ProviderName[] = [...HARNESS_PROVIDERS, 'openai', 'zen']; + +/** The provider whose `multi//` prefix the model carries, if any. */ +function providerOf(model: unknown): ProviderName | undefined { + if (typeof model !== 'string') { + return undefined; + } + return PROVIDERS.find((provider) => model.startsWith(`multi/${provider}/`)); +} + +export function isHarnessProvider(provider: string | undefined): provider is HarnessProvider { + return HARNESS_PROVIDERS.some((name) => name === provider); +} + +/** The native harness that runs the model's actions itself. */ +export function harnessProvider(model: unknown): HarnessProvider | undefined { + const provider = providerOf(model); + return isHarnessProvider(provider) ? provider : undefined; +} + +/** An external model with an unrecognized prefix still routes to OpenAI; no model is Claude's. */ +export function providerRoute(model: string | null | undefined): ProviderRoute { + return providerOf(model) ?? (model ? 'openai' : 'anthropic'); +} + +/** Whose reviewer judges the model's actions: a provider's own, never Zen or Claude. */ +export function providerOwnedReview(model: string): boolean { + const provider = providerOf(model); + return provider !== undefined && provider !== 'zen'; +} + +/** Claude's context tag is display metadata, never part of a native model ID. */ +export function nativeSpelling(model: string | undefined): string | undefined { + return model?.replace(/\[1m\]$/i, ''); +} + +/** The upstream a harness bills through, reported on its completion events. */ +export function harnessEndpoint(provider: HarnessProvider): string { + return { cursor: '@cursor/sdk', antigravity: 'agy', grok: 'grok' }[provider]; +} + +/** What a request for a harness the gateway does not run is told. */ +export const harnessUnavailable: Record = { + cursor: 'Cursor SDK is not signed in. Run the launcher with --cursor-login first.', + antigravity: 'Antigravity is unavailable. Install and connect the multi-antigravity plugin.', + grok: 'Grok is unavailable. Install Grok Build, run grok login, and relaunch.', +}; diff --git a/plugins/multi-core/src/gateway/receipts.ts b/plugins/multi-core/src/gateway/receipts.ts index ce454fb..46819ff 100644 --- a/plugins/multi-core/src/gateway/receipts.ts +++ b/plugins/multi-core/src/gateway/receipts.ts @@ -1,8 +1,10 @@ import { randomUUID } from 'node:crypto'; import { appendFile } from 'node:fs/promises'; +import { addBounded } from './bounded.ts'; import type { GatewayEvent } from './server.ts'; const RECEIPT_SCHEMA_VERSION = 1; +const MAX_REMEMBERED = 8192; type Usage = NonNullable & { reasoning_tokens?: number; @@ -297,7 +299,7 @@ export class ReceiptLedger { return; } if (event.requestId) { - retain(this.seenRequests, event.requestId); + addBounded(this.seenRequests, event.requestId, MAX_REMEMBERED); } this.start(event); const pending = this.pending.get(keyFor(event)); @@ -338,7 +340,7 @@ export class ReceiptLedger { } this.pending.delete(keyFor(ref)); if (ref.invocationId) { - retain(this.completedKeys, dedup); + addBounded(this.completedKeys, dedup, MAX_REMEMBERED); } const snapshot = copySnapshot(pending.snapshot); const receipt: WorkerUsageReceipt = { @@ -394,13 +396,3 @@ export class ReceiptLedger { return this.queue; } } - -function retain(set: Set, key: string) { - set.add(key); - if (set.size > 8192) { - const oldest = set.values().next().value; - if (oldest !== undefined) { - set.delete(oldest); - } - } -} diff --git a/plugins/multi-core/src/gateway/record.ts b/plugins/multi-core/src/gateway/record.ts new file mode 100644 index 0000000..54808fb --- /dev/null +++ b/plugins/multi-core/src/gateway/record.ts @@ -0,0 +1,4 @@ +/** A plain JSON object: not null and not an array. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/plugins/multi-core/src/gateway/safeguards.ts b/plugins/multi-core/src/gateway/safeguards.ts index 3382458..3ac6bd5 100644 --- a/plugins/multi-core/src/gateway/safeguards.ts +++ b/plugins/multi-core/src/gateway/safeguards.ts @@ -1,4 +1,5 @@ import type { ResponseContentBlock } from './messages.ts'; +import { isRecord } from './record.ts'; type ToolVerdict = | { type: 'unavailable'; reason: 'error' } @@ -22,13 +23,9 @@ const zenUnreviewedModes = new Set([ 'bypassPermissions', ]); -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** A malformed request cannot authorize a synthetic verdict. */ export function dangerousToolMode(safeguards: unknown): string | undefined { - if (!Array.isArray(safeguards) || !safeguards.every(record)) { + if (!Array.isArray(safeguards) || !safeguards.every(isRecord)) { return undefined; } const entry = safeguards.find((item) => item.type === 'dangerous_tool_use'); @@ -36,7 +33,7 @@ export function dangerousToolMode(safeguards: unknown): string | undefined { return undefined; } const context = entry.classifier_context; - return record(context) && typeof context.permission_mode === 'string' + return isRecord(context) && typeof context.permission_mode === 'string' ? context.permission_mode : ''; } diff --git a/plugins/multi-core/src/gateway/server.ts b/plugins/multi-core/src/gateway/server.ts index 646cbe2..7764fa6 100644 --- a/plugins/multi-core/src/gateway/server.ts +++ b/plugins/multi-core/src/gateway/server.ts @@ -1,19 +1,16 @@ -import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import type { Server } from 'node:http'; import http from 'node:http'; import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { CodexAuthError, codexRequest } from '../../../multi-openai/src/auth.ts'; -import { openaiInstructions } from '../../../multi-openai/src/instructions.ts'; -import { MODELS } from '../../../multi-openai/src/models.ts'; -import type { ResponsesRequest } from '../../../multi-openai/src/responses.ts'; -import { forAnthropic, fromResponses, toResponses } from '../../../multi-openai/src/responses.ts'; +import { prepareOpenAIRequest } from '../../../multi-openai/src/gateway-request.ts'; +import { forAnthropic } from '../../../multi-openai/src/responses.ts'; import { validateZenKey } from '../../../multi-zen/src/auth.ts'; -import { fromChat } from '../../../multi-zen/src/chat.ts'; -import { zenRequest } from '../../../multi-zen/src/request.ts'; +import { prepareZenRequest, zenUnavailable } from '../../../multi-zen/src/gateway-request.ts'; import type { ApprovalContext, NativeApprovalBridge } from './approval.ts'; import { approvalCwdForComparison, isApprovalRequest, parseApprovalRequest } from './approval.ts'; +import { setBounded } from './bounded.ts'; import { DisplayRows, displayFollowUp, @@ -35,14 +32,23 @@ import { handleModRoute } from './mod-routes.ts'; import type { PermissionContext, PermissionModes } from './mode-hook.ts'; import { type NativeHarness, nativeHarnessErrorStatus } from './native-harness.ts'; import type { PendingApprovalTool } from './permission-hook.ts'; +import { + type HarnessProvider, + harnessEndpoint, + harnessProvider, + harnessUnavailable, + isHarnessProvider, + providerOwnedReview, + providerRoute, +} from './provider.ts'; +import type { PreparedRequest } from './provider-request.ts'; +import { ProviderAuthError, UpstreamFailure } from './provider-request.ts'; import { ProviderUsageDashboard, type ProviderUsageReader } from './provider-usage.ts'; import { ReceiptLedger } from './receipts.ts'; +import { isRecord } from './record.ts'; import { dangerousToolMode, safeguardResults } from './safeguards.ts'; -import { estimateInputTokens } from './tokens.ts'; import { forwardObservedTools, ToolObserver } from './tool-observer.ts'; -import { originalToolNames } from './tools.ts'; -const OPENAI_URL = 'https://chatgpt.com/backend-api/codex/responses'; const DEFAULT_ANTHROPIC_URL = 'https://api.anthropic.com'; // Anthropic's own Messages API limit is 32 MB; the gateway buffers up to it and leaves // the verdict to the provider, so a request Claude Code could send natively still goes. @@ -137,32 +143,8 @@ export interface GatewayOptions { /** Rejected before any provider call; answered as HTTP 400 rather than 502. */ class BadRequest extends Error {} -class UpstreamFailure extends Error { - status: number; - retryAfter: string | null; - constructor(status: number, retryAfter: string | null, provider = 'OpenAI') { - const authHelp = provider === 'OpenAI' ? ' Renew the Codex login.' : ' Check the Zen API key.'; - super(`${provider} returned HTTP ${status}.${status === 401 ? authHelp : ''}`); - this.status = status; - this.retryAfter = retryAfter; - } -} - const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function providerOwnedReview(model: string): boolean { - return ( - model.startsWith('multi/openai/') || - model.startsWith('multi/cursor/') || - model.startsWith('multi/antigravity/') || - model.startsWith('multi/grok/') - ); -} - function authenticated(actual: string | string[] | undefined, expected: string): boolean { const a = Buffer.from(String(actual ?? '')); const b = Buffer.from(expected); @@ -253,9 +235,16 @@ export function createNativeGateway({ const anthropicUrl = (url: URL) => anthropicOrigin + url.pathname + url.search; const modKeys = new ModSessionKeys(); const fallbackSession = randomUUID(); + const harnesses: Record = { + cursor, + antigravity, + grok, + }; + const harnessFor = (provider: string | undefined) => + isHarnessProvider(provider) ? harnesses[provider] : undefined; // Offer the mod rows only for the harnesses this gateway runs. for (const [provider, names] of Object.entries(displayTools)) { - if ({ cursor, antigravity, grok }[provider as keyof typeof displayTools] && names) { + if (harnessFor(provider) && names) { displayRows.announce(names); } } @@ -302,11 +291,13 @@ export function createNativeGateway({ ) { const context = approvalContexts.get(pending.scope); if (context?.model === pending.model) { - evictOldest(reviewCandidates, 512); - reviewCandidates.set(id, { - tool: pending, - context: { ...context, cwd: parsed.cwd, ...reportedMode(parsed) }, - }); + setBounded( + reviewCandidates, + id, + { tool: pending, context: { ...context, cwd: parsed.cwd, ...reportedMode(parsed) } }, + 512, + 'insertion', + ); } } // Permission hooks only enrich attribution. Provider review is enforced @@ -328,17 +319,22 @@ export function createNativeGateway({ } // Each worker retains its own current request; provider switches replace it. approvalContexts.delete(approvalScope); - evictOldest(approvalContexts, 128); - approvalContexts.set(approvalScope, { - model: external, - request: body, - scope: approvalScope, - worker: Boolean(agentId), - rootRequest: agentId - ? approvalContexts.get(JSON.stringify([identity, 'main']))?.request - : undefined, - requestPermissionMode: dangerousToolMode(body.safeguards), - }); + setBounded( + approvalContexts, + approvalScope, + { + model: external, + request: body, + scope: approvalScope, + worker: Boolean(agentId), + rootRequest: agentId + ? approvalContexts.get(JSON.stringify([identity, 'main']))?.request + : undefined, + requestPermissionMode: dangerousToolMode(body.safeguards), + }, + 128, + 'insertion', + ); } function reviewCandidatesFor(parsed: Record, sourceSession: string) { const { action } = parseApprovalRequest(parsed); @@ -359,8 +355,7 @@ export function createNativeGateway({ } const compactions = new ModCompactions(async (request) => { const model = request.context.model; - const provider = model?.split('/')[1]; - const harness = provider === 'cursor' ? cursor : grokOrAntigravity(provider); + const harness = harnessFor(harnessProvider(model)); if (!harness || !model) { throw new Error('Precomputed summaries require a native harness model'); } @@ -389,13 +384,6 @@ export function createNativeGateway({ .join('\n'); }); - function grokOrAntigravity(provider: string | undefined) { - if (provider === 'antigravity') { - return antigravity; - } - return provider === 'grok' ? grok : undefined; - } - function harnessScope({ identity, agentId }: ProviderRequest) { return identity.scope ?? JSON.stringify([fallbackSession, agentId ?? 'main']); } @@ -409,20 +397,11 @@ export function createNativeGateway({ } return error; } - async function handleHarness( - exchange: ProviderRequest, - provider: 'cursor' | 'antigravity' | 'grok', - ) { + async function handleHarness(exchange: ProviderRequest, provider: HarnessProvider) { const { res, body, url, signal, agentId, emit, identity } = exchange; - const bridge = { cursor, antigravity, grok }[provider]; + const bridge = harnesses[provider]; if (!bridge) { - const unavailable = { - cursor: 'Cursor SDK is not signed in. Run the launcher with --cursor-login first.', - antigravity: - 'Antigravity is unavailable. Install and connect the multi-antigravity plugin.', - grok: 'Grok is unavailable. Install Grok Build, run grok login, and relaunch.', - }; - throw refuseHarness(exchange, provider, new BadRequest(unavailable[provider])); + throw refuseHarness(exchange, provider, new BadRequest(harnessUnavailable[provider])); } const scope = harnessScope(exchange); let inputTokens: number; @@ -558,7 +537,7 @@ export function createNativeGateway({ if (held) { return held; } - const harness = provider === 'cursor' ? cursor : grokOrAntigravity(provider); + const harness = harnessFor(provider); const recorded = await harness ?.recordedResponse?.(scope, followUpContext(exchange)) .catch(() => undefined); @@ -576,139 +555,70 @@ export function createNativeGateway({ return undefined; } } - async function handleOpenAI(exchange: ProviderRequest, externalModel: string) { - const { req, res, body, url, signal, abort, agentId, emit } = exchange; - const request = openaiRequest(exchange, externalModel); - request.prompt_cache_key = createHash('sha256') - .update( - JSON.stringify([ - 'openai', - exchange.identity.session || fallbackSession, - agentId ?? 'main', - request.model, - ]), - ) - .digest('hex'); + /** + * A hosted-API provider (OpenAI, Zen): the provider module prepares the request, + * this owns keepalive, events, failure mapping and the reply. + */ + async function handleHosted(exchange: ProviderRequest, route: 'openai' | 'zen') { + const { res, body, url, signal, agentId, emit } = exchange; + const prepared = prepareHosted(exchange, route); if (url.pathname === '/v1/messages/count_tokens') { res.writeHead(200, { 'content-type': 'application/json', 'x-multi-token-count': 'estimate', }); - return res.end(JSON.stringify({ input_tokens: estimateInputTokens(request) })); + return res.end(JSON.stringify({ input_tokens: prepared.inputTokens() })); } - const toolNames = originalToolNames(body); - onEvent({ - route: 'openai-request', - agentId, - model: request.model, - effort: request.reasoning.effort, - }); - const headers = { - 'content-type': 'application/json', - accept: 'text/event-stream', - originator: 'cc_multi_native', - session_id: String( - agentId ?? header(req.headers['x-claude-code-session-id']) ?? fallbackSession, - ), - }; + onEvent(prepared.requestEvent(agentId)); exchange.keepAlive(); - const upstream = await codexRequest(authFile, signal, (auth) => - fetchImpl(OPENAI_URL, { - method: 'POST', - headers: { ...headers, ...auth }, - body: JSON.stringify(request), - signal, - redirect: 'error', - }), - ); + const upstream = await prepared.send(signal, fetchImpl); if (!upstream.ok) { // Do not print upstream bodies or credentials in gateway diagnostics. - onEvent({ route: 'openai', status: upstream.status }); + onEvent(prepared.failureEvent(upstream.status, agentId)); await upstream.body?.cancel(); - throw new UpstreamFailure(upstream.status, upstream.headers.get('retry-after')); + throw new UpstreamFailure( + upstream.status, + upstream.headers.get('retry-after'), + prepared.label, + prepared.authHelp, + ); } if (!upstream.body) { - throw new Error('OpenAI returned no response stream.'); + throw new Error(`${prepared.label} returned no response stream.`); } exchange.startStream(); - const result = await fromResponses( - upstream.body, - externalModel, - body.stream ? emit : undefined, - { - toolNames, - stopSequences: body.stop_sequences, - inputTokens: estimateInputTokens(request), - safeguards: body.safeguards, - }, - ); + const result = await prepared.translate(upstream.body, body.stream ? emit : undefined); rememberResult(exchange, result); if (result.stop_reason === 'stop_sequence') { - abort.abort(); + exchange.abort.abort(); } onEvent({ - ...completionEvent(exchange, result, 'openai', 'responses'), - model: request.model, - effort: request.reasoning.effort, + ...completionEvent(exchange, result, prepared.route, prepared.endpoint), + ...prepared.completion, }); sendResult(exchange, result); } - async function handleZen(exchange: ProviderRequest) { - const { res, body, url, signal, agentId, emit } = exchange; - if (!zen?.apiKey) { - throw new BadRequest('Zen is not configured. Set OPENCODE_API_KEY or connect OpenCode Zen.'); - } - const prepared = prepareZenRequest(exchange, fallbackSession); - if (url.pathname === '/v1/messages/count_tokens') { - res.writeHead(200, { 'content-type': 'application/json', 'x-multi-token-count': 'estimate' }); - return res.end(JSON.stringify({ input_tokens: prepared.inputTokens })); - } - onEvent({ route: 'zen-request', agentId, model: body.model }); - exchange.keepAlive(); - const upstream = await fetchImpl(`https://opencode.ai/zen/v1/${prepared.endpoint}`, { - method: 'POST', - headers: { - authorization: `Bearer ${zen.apiKey}`, - 'content-type': 'application/json', - accept: 'text/event-stream', - 'x-opencode-session': prepared.cacheKey, - 'x-opencode-client': 'cc-multi-cli-plugin', - }, - body: JSON.stringify(prepared.body), - signal, - redirect: 'error', - }); - if (!upstream.ok) { - onEvent({ route: 'zen', agentId, status: upstream.status }); - await upstream.body?.cancel(); - throw new UpstreamFailure(upstream.status, upstream.headers.get('retry-after'), 'Zen'); - } - if (!upstream.body) { - throw new Error('Zen returned no response stream.'); - } - exchange.startStream(); - const options = { - toolNames: originalToolNames(body), - stopSequences: body.stop_sequences, - signaturePrefix: prepared.signaturePrefix, - requireUsage: true, - inputTokens: prepared.inputTokens, - safeguards: body.safeguards, - safeguardProvider: 'zen' as const, + function prepareHosted(exchange: ProviderRequest, route: 'openai' | 'zen'): PreparedRequest { + const { req, url, body, identity, agentId } = exchange; + if (route === 'zen' && !zen?.apiKey) { + throw new BadRequest(zenUnavailable); + } + const input = { + method: req.method, + pathname: url.pathname, + body, + session: identity.session, + fallbackSession, + clientSession: header(req.headers['x-claude-code-session-id']), + agentId, }; - const translate = prepared.endpoint === 'responses' ? fromResponses : fromChat; - const result = await translate( - upstream.body, - String(body.model), - body.stream ? emit : undefined, - options, - ); - rememberResult(exchange, result); - if (result.stop_reason === 'stop_sequence') { - exchange.abort.abort(); + try { + return zen?.apiKey && route === 'zen' + ? prepareZenRequest(input, zen.apiKey) + : prepareOpenAIRequest(input, String(body.model), authFile); + } catch (error) { + throw new BadRequest(reason(error)); } - onEvent(completionEvent(exchange, result, 'zen', prepared.endpoint)); - sendResult(exchange, result); } async function handleAnthropic(exchange: ProviderRequest) { const { req, res, body, url, raw, signal } = exchange; @@ -987,11 +897,7 @@ export function createNativeGateway({ const route = providerRoute(external); beginUsage(exchange, external); let permissionContext: PermissionContext | undefined; - if ( - permissionModes && - ['cursor', 'antigravity', 'grok'].includes(route) && - url.pathname === '/v1/messages' - ) { + if (permissionModes && isHarnessProvider(route) && url.pathname === '/v1/messages') { try { permissionContext = permissionModes.resolveHarness( exchange.identity.session, @@ -1013,13 +919,10 @@ export function createNativeGateway({ if (!external) { return handleAnthropic(exchange); } - if (route === 'cursor' || route === 'antigravity' || route === 'grok') { + if (isHarnessProvider(route)) { return handleHarness(exchange, route); } - if (external.startsWith('multi/zen/')) { - return handleZen(exchange); - } - return handleOpenAI(exchange, external); + return handleHosted(exchange, route === 'zen' ? 'zen' : 'openai'); } function handleMod( req: http.IncomingMessage, @@ -1030,19 +933,15 @@ export function createNativeGateway({ if (!admitModRequest(req, res, parsed, modKeys)) { return undefined; } - return handleModRoute( - req, - res, - url, - parsed, - modBridge, + return handleModRoute(req, res, url, parsed, { + bridge: modBridge, permissionModes, compactions, receipts, billedUsage, dashboard, - displayRows, - ); + rows: displayRows, + }); } return http.createServer(async (req, res) => { const abort = new AbortController(); @@ -1059,28 +958,18 @@ export function createNativeGateway({ if (!guardAuto || !sourceSession) { return; } - evictOldest(pendingTools, 512); - pendingTools.set(tool.id, { + const pending: PendingApprovalTool = { model: sourceModel, session: sourceSession, name: tool.name, input: tool.input, scope: sourceScope, - }); + }; + setBounded(pendingTools, tool.id, pending, 512, 'insertion'); if (sourceScope) { const context = approvalContexts.get(sourceScope); if (context?.model === sourceModel) { - evictOldest(reviewCandidates, 512); - reviewCandidates.set(tool.id, { - tool: { - model: sourceModel, - session: sourceSession, - name: tool.name, - input: tool.input, - scope: sourceScope, - }, - context, - }); + setBounded(reviewCandidates, tool.id, { tool: pending, context }, 512, 'insertion'); } } }; @@ -1279,11 +1168,7 @@ function displayRowsRemoved( function providerSignal(disconnected: AbortSignal, model: string | null, timeoutMs?: number) { // Native harness runs follow the client connection rather than an HTTP deadline. - if ( - model?.startsWith('multi/cursor/') || - model?.startsWith('multi/antigravity/') || - model?.startsWith('multi/grok/') - ) { + if (harnessProvider(model)) { return disconnected; } // Streamed OpenAI, Zen and Claude responses (including server-side advisor calls) @@ -1317,29 +1202,8 @@ async function readRequest(req: http.IncomingMessage) { return { raw, parsed, body: parsed as MessagesRequest }; } -function providerRoute( - model: string | null, -): 'cursor' | 'antigravity' | 'grok' | 'openai' | 'anthropic' | 'zen' { - if (model?.startsWith('multi/cursor/')) { - return 'cursor'; - } - if (model?.startsWith('multi/antigravity/')) { - return 'antigravity'; - } - if (model?.startsWith('multi/grok/')) { - return 'grok'; - } - if (model?.startsWith('multi/zen/')) { - return 'zen'; - } - return model ? 'openai' : 'anthropic'; -} -function harnessEndpoint(provider: 'cursor' | 'antigravity' | 'grok'): string { - return { cursor: '@cursor/sdk', antigravity: 'agy', grok: 'grok' }[provider]; -} - function errorStatus(error: unknown): number { - if (error instanceof CodexAuthError) { + if (error instanceof ProviderAuthError) { return 401; } if (error instanceof BadRequest) { @@ -1378,13 +1242,6 @@ function sendResult(exchange: ProviderRequest, result: MessagesResponse) { exchange.res.end(exchange.body.stream ? undefined : JSON.stringify(result)); } -function evictOldest(cache: Map, capacity: number) { - const oldest = cache.keys().next(); - if (cache.size >= capacity && !oldest.done) { - cache.delete(oldest.value); - } -} - /** Claude's PreToolUse input carries its mode at the moment the action was proposed. */ function reportedMode(parsed: Record): { permissionMode?: string } { return typeof parsed.permission_mode === 'string' @@ -1430,53 +1287,6 @@ function requestIdentity( }; } -function prepareZenRequest(exchange: ProviderRequest, fallbackSession: string) { - const { req, body, url, identity, agentId } = exchange; - try { - if ( - req.method !== 'POST' || - !['/v1/messages', '/v1/messages/count_tokens'].includes(url.pathname) - ) { - throw new Error('Zen requires POST /v1/messages or /v1/messages/count_tokens'); - } - // Zen uses this for sticky upstream routing. Claude identity survives restarts; - // no per-request nonce is inserted into the prompt or cache key. - const cacheKey = createHash('sha256') - .update( - JSON.stringify([ - identity.session || fallbackSession, - agentId ?? 'main', - body.model, - process.cwd(), - ]), - ) - .digest('hex'); - return { ...zenRequest(body, cacheKey), cacheKey }; - } catch (error) { - throw new BadRequest(reason(error)); - } -} - -function openaiRequest(exchange: ProviderRequest, externalModel: string): ResponsesRequest { - const { req, body, url } = exchange; - try { - const model = Object.values(MODELS).find((model) => externalModel === `multi/openai/${model}`); - if (!model) { - throw new Error('Unknown native OpenAI model'); - } - if ( - !['/v1/messages', '/v1/messages/count_tokens'].includes(url.pathname) || - req.method !== 'POST' - ) { - throw new Error('External models require POST /v1/messages or /v1/messages/count_tokens'); - } - const request = toResponses(body, model); - return { ...request, instructions: openaiInstructions(request.instructions) }; - } catch (error) { - throw new BadRequest(reason(error)); - } -} - function anthropicHeaders(req: http.IncomingMessage) { const headers: Record = {}; for (const [name, value] of Object.entries(req.headers)) { diff --git a/plugins/multi-core/src/gateway/tool-observer.ts b/plugins/multi-core/src/gateway/tool-observer.ts index 88d10a2..0577f00 100644 --- a/plugins/multi-core/src/gateway/tool-observer.ts +++ b/plugins/multi-core/src/gateway/tool-observer.ts @@ -1,5 +1,6 @@ import { once } from 'node:events'; import type { ServerResponse } from 'node:http'; +import { isRecord } from './record.ts'; interface Tool { id: string; @@ -8,8 +9,6 @@ interface Tool { } const MAX_BYTES = 8 * 1024 * 1024; -const record = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); /** Observe provider output for attribution, never for permission grants. */ export class ToolObserver { @@ -21,7 +20,7 @@ export class ToolObserver { } response(value: unknown) { - if (record(value) && Array.isArray(value.content)) { + if (isRecord(value) && Array.isArray(value.content)) { for (const block of value.content) { const tool = this.tool(block); if (tool) { @@ -32,7 +31,7 @@ export class ToolObserver { } event(value: unknown) { - if (!record(value) || typeof value.index !== 'number') { + if (!isRecord(value) || typeof value.index !== 'number') { return; } if (value.type === 'content_block_start') { @@ -46,7 +45,7 @@ export class ToolObserver { if (!tool) { return; } - if (value.type === 'content_block_delta' && record(value.delta)) { + if (value.type === 'content_block_delta' && isRecord(value.delta)) { this.append(tool, value.delta.partial_json); } if (value.type === 'content_block_stop') { @@ -66,7 +65,7 @@ export class ToolObserver { private tool(value: unknown): Tool | undefined { if ( - record(value) && + isRecord(value) && value.type === 'tool_use' && typeof value.id === 'string' && typeof value.name === 'string' diff --git a/plugins/multi-core/src/launcher.ts b/plugins/multi-core/src/launcher.ts index 4f8df42..3cb5b30 100644 --- a/plugins/multi-core/src/launcher.ts +++ b/plugins/multi-core/src/launcher.ts @@ -17,7 +17,6 @@ import { antigravityDefaultWorkerModel, antigravityPickerOptions, discoverAntigravityModels, - nativeSpelling, } from '../../multi-antigravity/src/models.ts'; import { antigravityPermissionPolicy } from '../../multi-antigravity/src/permissions.ts'; import { ANTIGRAVITY_TOOLS } from '../../multi-antigravity/src/progress.ts'; @@ -29,10 +28,7 @@ import { cursorModelOptions, cursorPickerOptions, } from '../../multi-cursor/src/models.ts'; -import { - cursorPermissionPolicy, - mergeCursorPermissions, -} from '../../multi-cursor/src/permissions.ts'; +import { cursorPermissionPolicy } from '../../multi-cursor/src/permissions.ts'; import { CURSOR_TOOLS } from '../../multi-cursor/src/progress.ts'; import { cursorUsageReader } from '../../multi-cursor/src/usage-adapter.ts'; import { CursorWorkspaces } from '../../multi-cursor/src/workspaces.ts'; @@ -68,12 +64,17 @@ import { type PluginPermissionInventory, pluginPermissions, } from './gateway/agent-definitions.ts'; -import { type CursorSettingsOptions, checkCursorSettings } from './gateway/cursor-settings.ts'; import { DISPLAY_TOOL_SERVER } from './gateway/display-rows.ts'; import { executableInvocation, resolveExecutable } from './gateway/executable.ts'; +import { + checkHarnessSettings, + type HarnessSettingsOptions, + mergePermissions, +} from './gateway/harness-settings.ts'; import { ModBridge } from './gateway/mod-bridge.ts'; import { PermissionModes } from './gateway/mode-hook.ts'; import { nestedClaudeShim, withPathPrefix } from './gateway/nested-env.ts'; +import { nativeSpelling } from './gateway/provider.ts'; import type { ProviderUsageReader } from './gateway/provider-usage.ts'; import { ReceiptLedger } from './gateway/receipts.ts'; import type { GatewayEvent } from './gateway/server.ts'; @@ -409,7 +410,7 @@ function nativeSettingsCheck( return {}; } try { - return await checkCursorSettings(cwd, args, callerSettings, sharedAdmission(harnesses)); + return await checkHarnessSettings(cwd, args, callerSettings, sharedAdmission(harnesses)); } catch (error) { return { nativePermissionError: String(error) }; } @@ -428,7 +429,7 @@ function nativeHarnesses( (cwd) => new CursorHarness(cursorModels, { cwd, - checkPermissions: () => checkCursorSettings(cwd, args, callerSettings), + checkPermissions: () => checkHarnessSettings(cwd, args, callerSettings), }), ) : undefined; @@ -436,20 +437,20 @@ function nativeHarnesses( ? new AntigravityHarness(antigravityModels, { checkPermissions: async (cwd, context) => { await checkAntigravityHooks(); - const restrictions = await checkCursorSettings(cwd, args, callerSettings, { + const restrictions = await checkHarnessSettings(cwd, args, callerSettings, { validate: antigravityPermissionPolicy, }); - return antigravityPermissionPolicy(mergeCursorPermissions(context, restrictions)); + return antigravityPermissionPolicy(mergePermissions(context, restrictions)); }, }) : undefined; const grok = grokModels.length ? new GrokHarness(grokModels, { checkPermissions: async (cwd, context) => { - const restrictions = await checkCursorSettings(cwd, args, callerSettings, { + const restrictions = await checkHarnessSettings(cwd, args, callerSettings, { validate: grokPermissionPolicy, }); - return grokPermissionPolicy(mergeCursorPermissions(context, restrictions)); + return grokPermissionPolicy(mergePermissions(context, restrictions)); }, }) : undefined; @@ -701,7 +702,7 @@ export function sharedAdmission(harnesses: { cursor?: unknown; antigravity?: unknown; grok?: unknown; -}): CursorSettingsOptions { +}): HarnessSettingsOptions { if (harnesses.antigravity) { return { validate: antigravityPermissionPolicy, cursorToolRules: false }; } diff --git a/plugins/multi-cursor/src/harness.ts b/plugins/multi-cursor/src/harness.ts index ba97097..609c94f 100644 --- a/plugins/multi-cursor/src/harness.ts +++ b/plugins/multi-cursor/src/harness.ts @@ -49,6 +49,7 @@ import { readJson, textContentBlock, } from '../../multi-core/src/gateway/harness-session.ts'; +import { mergePermissions } from '../../multi-core/src/gateway/harness-settings.ts'; import type { Emit, MessagesRequest, @@ -59,11 +60,7 @@ import { dangerousToolMode } from '../../multi-core/src/gateway/safeguards.ts'; import { settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import { CursorProviderError, cursorRunError } from './errors.ts'; import { type CursorModelOption, cursorSelection } from './models.ts'; -import { - cursorNativePermissions, - cursorPermissionPolicy, - mergeCursorPermissions, -} from './permissions.ts'; +import { cursorNativePermissions, cursorPermissionPolicy } from './permissions.ts'; import { cursorContextNotice, observeCursorUpdate } from './progress.ts'; import { prepareCursorRequest } from './request.ts'; import { legacyCursorSessionFile, restoreCursorSession } from './session-record.ts'; @@ -396,7 +393,7 @@ export class CursorHarness { if (!context) { throw new Error('Cursor requires an explicit Claude permission context'); } - const permissions = mergeCursorPermissions(context, await this.checkPermissions()); + const permissions = mergePermissions(context, await this.checkPermissions()); this.validate(body, permissions); this.cwd = await realpath(this.cwd); const key = hash([ diff --git a/plugins/multi-cursor/src/permissions.ts b/plugins/multi-cursor/src/permissions.ts index 5b09ed4..0d5dece 100644 --- a/plugins/multi-cursor/src/permissions.ts +++ b/plugins/multi-cursor/src/permissions.ts @@ -144,22 +144,6 @@ export function assertCursorClaudeSettings( return { disallowedTools: denied }; } -/** Restriction layers intersect grants and accumulate denials; none can widen another. */ -export function mergeCursorPermissions( - context: PermissionContext, - rules: WorkerPermissions = {}, -): PermissionContext { - let tools = context.tools; - if (rules.tools !== undefined) { - tools = tools === undefined ? rules.tools : tools.filter((tool) => rules.tools?.includes(tool)); - } - return { - ...context, - tools, - disallowedTools: [...(context.disallowedTools ?? []), ...(rules.disallowedTools ?? [])], - }; -} - function settingsRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Invalid Claude settings for native Cursor execution'); diff --git a/plugins/multi-openai/src/gateway-request.ts b/plugins/multi-openai/src/gateway-request.ts new file mode 100644 index 0000000..941209a --- /dev/null +++ b/plugins/multi-openai/src/gateway-request.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; +import type { GatewayFetch } from '../../multi-core/src/gateway/fetch.ts'; +import type { + PreparedRequest, + ProviderRequestInput, +} from '../../multi-core/src/gateway/provider-request.ts'; +import { ProviderAuthError } from '../../multi-core/src/gateway/provider-request.ts'; +import { estimateInputTokens } from '../../multi-core/src/gateway/tokens.ts'; +import { originalToolNames } from '../../multi-core/src/gateway/tools.ts'; +import { CodexAuthError, codexRequest } from './auth.ts'; +import { openaiInstructions } from './instructions.ts'; +import { MODELS } from './models.ts'; +import { fromResponses, toResponses } from './responses.ts'; + +const OPENAI_URL = 'https://chatgpt.com/backend-api/codex/responses'; +const MESSAGES_PATHS = ['/v1/messages', '/v1/messages/count_tokens']; + +const openaiAuthHelp = ' Renew the Codex login.'; + +/** The Responses request for one Claude request to a native `multi/openai/` model. */ +export function prepareOpenAIRequest( + { + method, + pathname, + body, + session, + fallbackSession, + clientSession, + agentId, + }: ProviderRequestInput, + externalModel: string, + authFile: string, +): PreparedRequest { + const model = Object.values(MODELS).find((model) => externalModel === `multi/openai/${model}`); + if (!model) { + throw new Error('Unknown native OpenAI model'); + } + if (!MESSAGES_PATHS.includes(pathname) || method !== 'POST') { + throw new Error('External models require POST /v1/messages or /v1/messages/count_tokens'); + } + const translated = toResponses(body, model); + const request = { + ...translated, + instructions: openaiInstructions(translated.instructions), + prompt_cache_key: createHash('sha256') + .update( + JSON.stringify(['openai', session || fallbackSession, agentId ?? 'main', translated.model]), + ) + .digest('hex'), + }; + let estimate: number | undefined; + const inputTokens = () => { + estimate ??= estimateInputTokens(request); + return estimate; + }; + let toolNames: Map | undefined; + return { + label: 'OpenAI', + authHelp: openaiAuthHelp, + endpoint: 'responses', + route: 'openai', + inputTokens, + completion: { model: request.model, effort: request.reasoning.effort }, + requestEvent: (agent) => ({ + route: 'openai-request', + agentId: agent, + model: request.model, + effort: request.reasoning.effort, + }), + failureEvent: (status) => ({ route: 'openai', status }), + async send(signal: AbortSignal, fetchImpl: GatewayFetch) { + toolNames = originalToolNames(body); + const headers = { + 'content-type': 'application/json', + accept: 'text/event-stream', + originator: 'cc_multi_native', + session_id: String(agentId ?? clientSession ?? fallbackSession), + }; + try { + return await codexRequest(authFile, signal, (auth) => + fetchImpl(OPENAI_URL, { + method: 'POST', + headers: { ...headers, ...auth }, + body: JSON.stringify(request), + signal, + redirect: 'error', + }), + ); + } catch (error) { + throw error instanceof CodexAuthError ? new ProviderAuthError(error.message) : error; + } + }, + translate: (stream, emit) => + fromResponses(stream, externalModel, emit, { + toolNames: toolNames ?? originalToolNames(body), + stopSequences: body.stop_sequences, + inputTokens: inputTokens(), + safeguards: body.safeguards, + }), + }; +} diff --git a/plugins/multi-zen/src/gateway-request.ts b/plugins/multi-zen/src/gateway-request.ts new file mode 100644 index 0000000..7c1a2f7 --- /dev/null +++ b/plugins/multi-zen/src/gateway-request.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto'; +import type { GatewayFetch } from '../../multi-core/src/gateway/fetch.ts'; +import type { + PreparedRequest, + ProviderRequestInput, +} from '../../multi-core/src/gateway/provider-request.ts'; +import { originalToolNames } from '../../multi-core/src/gateway/tools.ts'; +import { fromResponses } from '../../multi-openai/src/responses.ts'; +import { fromChat } from './chat.ts'; +import { zenRequest } from './request.ts'; + +const ZEN_URL = 'https://opencode.ai/zen/v1'; +const MESSAGES_PATHS = ['/v1/messages', '/v1/messages/count_tokens']; + +export const zenUnavailable = + 'Zen is not configured. Set OPENCODE_API_KEY or connect OpenCode Zen.'; + +const zenAuthHelp = ' Check the Zen API key.'; + +/** The Zen request for one Claude request to a `multi/zen/` model. */ +export function prepareZenRequest( + { method, pathname, body, session, fallbackSession, agentId }: ProviderRequestInput, + apiKey: string, +): PreparedRequest { + if (method !== 'POST' || !MESSAGES_PATHS.includes(pathname)) { + throw new Error('Zen requires POST /v1/messages or /v1/messages/count_tokens'); + } + // Zen uses this for sticky upstream routing. Claude identity survives restarts; + // no per-request nonce is inserted into the prompt or cache key. + const cacheKey = createHash('sha256') + .update( + JSON.stringify([session || fallbackSession, agentId ?? 'main', body.model, process.cwd()]), + ) + .digest('hex'); + const prepared = zenRequest(body, cacheKey); + return { + label: 'Zen', + authHelp: zenAuthHelp, + endpoint: prepared.endpoint, + route: 'zen', + inputTokens: () => prepared.inputTokens, + requestEvent: (agent) => ({ route: 'zen-request', agentId: agent, model: body.model }), + failureEvent: (status, agent) => ({ route: 'zen', agentId: agent, status }), + send: (signal: AbortSignal, fetchImpl: GatewayFetch) => + fetchImpl(`${ZEN_URL}/${prepared.endpoint}`, { + method: 'POST', + headers: { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + accept: 'text/event-stream', + 'x-opencode-session': cacheKey, + 'x-opencode-client': 'cc-multi-cli-plugin', + }, + body: JSON.stringify(prepared.body), + signal, + redirect: 'error', + }), + translate: (stream, emit) => { + const options = { + toolNames: originalToolNames(body), + stopSequences: body.stop_sequences, + signaturePrefix: prepared.signaturePrefix, + requireUsage: true, + inputTokens: prepared.inputTokens, + safeguards: body.safeguards, + safeguardProvider: 'zen' as const, + }; + const translate = prepared.endpoint === 'responses' ? fromResponses : fromChat; + return translate(stream, String(body.model), emit, options); + }, + }; +} diff --git a/test/unit/gateway-provider-bounded.test.ts b/test/unit/gateway-provider-bounded.test.ts new file mode 100644 index 0000000..8c6f248 --- /dev/null +++ b/test/unit/gateway-provider-bounded.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { addBounded, setBounded } from '../../plugins/multi-core/src/gateway/bounded.ts'; +import { + harnessProvider, + providerOwnedReview, + providerRoute, +} from '../../plugins/multi-core/src/gateway/provider.ts'; + +test('provider prefixes decide routing, review ownership and harness execution in one place', () => { + assert.equal(providerRoute(null), 'anthropic'); + assert.equal(providerRoute('multi/cursor/auto'), 'cursor'); + assert.equal(providerRoute('multi/zen/glm'), 'zen'); + assert.equal(providerRoute('multi/openai/gpt-6-luna'), 'openai'); + assert.equal(providerRoute('multi/unknown/x'), 'openai'); + assert.equal(harnessProvider('multi/grok/x'), 'grok'); + assert.equal(harnessProvider('multi/openai/x'), undefined); + assert.equal(harnessProvider(undefined), undefined); + assert.equal(providerOwnedReview('multi/antigravity/x'), true); + assert.equal(providerOwnedReview('multi/openai/x'), true); + assert.equal(providerOwnedReview('multi/zen/x'), false); + assert.equal(providerOwnedReview('claude-sonnet'), false); +}); + +test('bounded maps choose between recency and insertion eviction', () => { + const lru = new Map(); + for (const key of ['a', 'b', 'c']) { + setBounded(lru, key, 1, 3, 'lru'); + } + setBounded(lru, 'a', 2, 3, 'lru'); + setBounded(lru, 'd', 1, 3, 'lru'); + assert.deepEqual([...lru.keys()], ['c', 'a', 'd']); + + const insertion = new Map(); + for (const key of ['a', 'b', 'c']) { + setBounded(insertion, key, 1, 3, 'insertion'); + } + setBounded(insertion, 'a', 2, 3, 'insertion'); + assert.deepEqual([...insertion.keys()], ['a', 'b', 'c']); + assert.equal(insertion.get('a'), 2); + setBounded(insertion, 'd', 1, 3, 'insertion'); + assert.deepEqual([...insertion.keys()], ['b', 'c', 'd']); + + const set = new Set(); + for (const key of ['a', 'b', 'c', 'd']) { + addBounded(set, key, 3); + } + assert.deepEqual([...set], ['b', 'c', 'd']); +}); diff --git a/test/unit/native-cursor-permissions.test.ts b/test/unit/native-cursor-permissions.test.ts index bffb8ca..510ae83 100644 --- a/test/unit/native-cursor-permissions.test.ts +++ b/test/unit/native-cursor-permissions.test.ts @@ -3,12 +3,12 @@ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { mergePermissions } from '../../plugins/multi-core/src/gateway/harness-settings.ts'; import { assertCursorClaudeSettings, cursorNativePermissions, cursorPermissionMode, cursorPermissionPolicy, - mergeCursorPermissions, } from '../../plugins/multi-cursor/src/permissions.ts'; import { removeTemporary } from '../temporary.ts'; @@ -160,12 +160,12 @@ test('native bypass disables review without widening explicit capabilities', asy }); test('restriction layers intersect tool lists and accumulate denials', () => { - const context = mergeCursorPermissions( + const context = mergePermissions( { permissionMode: 'auto', tools: ['Read', 'Bash'], disallowedTools: ['Edit'] }, { tools: ['Read', 'Write'], disallowedTools: ['Write'] }, ); assert.deepEqual(context.tools, ['Read']); assert.deepEqual(context.disallowedTools, ['Edit', 'Write']); assert.deepEqual(cursorPermissionPolicy(context).tools, ['read', 'ls']); - assert.deepEqual(mergeCursorPermissions(context, { tools: [] }).tools, []); + assert.deepEqual(mergePermissions(context, { tools: [] }).tools, []); }); diff --git a/test/unit/native-cursor-settings.test.ts b/test/unit/native-cursor-settings.test.ts index b26fb27..ccec16a 100644 --- a/test/unit/native-cursor-settings.test.ts +++ b/test/unit/native-cursor-settings.test.ts @@ -6,11 +6,11 @@ import path from 'node:path'; import test, { mock } from 'node:test'; import { antigravityPermissionPolicy } from '../../plugins/multi-antigravity/src/permissions.ts'; import { - type CursorSettingsOptions, - checkCursorSettings, + checkHarnessSettings, + type HarnessSettingsOptions, policyCommandTimeoutMs, systemCommandPath, -} from '../../plugins/multi-core/src/gateway/cursor-settings.ts'; +} from '../../plugins/multi-core/src/gateway/harness-settings.ts'; import type { PermissionContext } from '../../plugins/multi-core/src/gateway/mode-hook.ts'; import { sharedAdmission } from '../../plugins/multi-core/src/launcher.ts'; import { @@ -26,9 +26,9 @@ async function checkSettings( cwd: string, args: readonly string[], inlineSettings: Record, - options: CursorSettingsOptions = {}, + options: HarnessSettingsOptions = {}, ) { - return checkCursorSettings(cwd, args, inlineSettings, { + return checkHarnessSettings(cwd, args, inlineSettings, { platform: 'linux', osRelease: 'test-linux', runCommand: absentManagedPolicy, @@ -296,7 +296,7 @@ function commandError( return error; } -function absentManagedFiles(): Pick { +function absentManagedFiles(): Pick { return { readDir: async () => { throw commandError('ENOENT', 'missing'); diff --git a/test/unit/native-mod-routes.test.ts b/test/unit/native-mod-routes.test.ts index 849f889..b2e6920 100644 --- a/test/unit/native-mod-routes.test.ts +++ b/test/unit/native-mod-routes.test.ts @@ -265,6 +265,22 @@ test('compaction core fallback authenticates generation and removes all native c assert.equal(typeof modes.resolve('s').compaction, 'string'); }); +test('cancelling a main compaction clears its permission boundary', async (t) => { + const modes = new PermissionModes(async () => ({})); + const base = await start(t, modes); + const generation = await admit(base); + const accepted = await request(base, '/multi/mod/compact/authorize', { + sessionId: 's', + generation, + }); + assert.equal(accepted.body.allow, true); + assert.equal(typeof modes.resolve('s').compaction, 'string'); + const cancelled = await request(base, '/multi/mod/compact/cancel', { sessionId: 's' }); + assert.equal(cancelled.body.accepted, true); + assert.equal(modes.resolve('s').compaction, undefined); + assert.notDeepEqual(modes.resolve('s').tools, []); +}); + test('worker route authenticates catalog and generation before child-start acknowledgement', async (t) => { const modes = new PermissionModes(async () => ({ worker: { model: 'multi/cursor/auto', tools: ['Read'] }, From cd4f008475b0e37314b218e0373a6b157cabe127 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:25:34 -0400 Subject: [PATCH 28/36] Route signature stripping through provider modules, drop the responses shim, harden mod keys The gateway imports the shared Responses translation directly and gets each provider's signature prefixes from its own request module. Detaching a session now drops its mod key, and cap eviction removes unconfirmed entries before confirmed ones so a token holder cannot evict a real session's key. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-core/src/gateway/mod-keys.ts | 30 ++++++++++++-- plugins/multi-core/src/gateway/mod-routes.ts | 6 ++- plugins/multi-core/src/gateway/server.ts | 18 +++++++-- plugins/multi-openai/src/gateway-request.ts | 9 ++++- plugins/multi-openai/src/responses.ts | 20 ---------- plugins/multi-zen/src/gateway-request.ts | 7 +++- test/unit/native-session-isolation.test.ts | 41 +++++++++++++++++++- 7 files changed, 97 insertions(+), 34 deletions(-) delete mode 100644 plugins/multi-openai/src/responses.ts diff --git a/plugins/multi-core/src/gateway/mod-keys.ts b/plugins/multi-core/src/gateway/mod-keys.ts index beb0507..7f73298 100644 --- a/plugins/multi-core/src/gateway/mod-keys.ts +++ b/plugins/multi-core/src/gateway/mod-keys.ts @@ -41,12 +41,34 @@ export class ModSessionKeys { return { refused: false }; } + /** The mod forgot this session (detach), so a later start under the same ID mints afresh. */ + forget(session: string): void { + this.entries.delete(session); + } + + /** + * Makes room for a new session. An unconfirmed entry goes first: anyone holding the + * gateway token can mint entries for sessions that do not exist, and they must not be + * able to push a confirmed session's key out. A confirmed entry goes only when every + * entry is confirmed. + */ + private evict(): void { + let victim: string | undefined; + for (const [session, entry] of this.entries) { + victim ??= session; + if (!entry.confirmed) { + victim = session; + break; + } + } + if (victim !== undefined) { + this.entries.delete(victim); + } + } + private mint(session: string): Entry { if (this.entries.size >= MAX_SESSIONS) { - const oldest = this.entries.keys().next(); - if (!oldest.done) { - this.entries.delete(oldest.value); - } + this.evict(); } const entry = { key: randomBytes(24).toString('hex'), confirmed: false }; this.entries.set(session, entry); diff --git a/plugins/multi-core/src/gateway/mod-routes.ts b/plugins/multi-core/src/gateway/mod-routes.ts index efa890b..90d67f4 100644 --- a/plugins/multi-core/src/gateway/mod-routes.ts +++ b/plugins/multi-core/src/gateway/mod-routes.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { DisplayRows } from './display-rows.ts'; import type { ModBridge } from './mod-bridge.ts'; import type { ModCompactions } from './mod-compaction.ts'; +import type { ModSessionKeys } from './mod-keys.ts'; import { usageRoute } from './mod-usage.ts'; import type { PermissionContext, PermissionModes } from './mode-hook.ts'; import type { ProviderUsageDashboard } from './provider-usage.ts'; @@ -39,6 +40,8 @@ export interface ModRouteContext { billedUsage?: (session: string) => Promise; dashboard?: ProviderUsageDashboard; rows?: DisplayRows; + /** The per-session keys; a detached session's key is dropped with the session. */ + keys?: ModSessionKeys; } export async function handleModRoute( @@ -120,7 +123,7 @@ async function handlePostRoute( parsed: unknown, context: ModRouteContext, ) { - const { bridge, permissionModes, compactions, rows } = context; + const { bridge, permissionModes, compactions, rows, keys } = context; method(req, 'POST'); if (!isRecord(parsed)) { throw new Error('Expected an object'); @@ -138,6 +141,7 @@ async function handlePostRoute( bridge.forgetSession(sessionId); rows?.forgetSession(sessionId); permissionModes?.forgetSession(sessionId); + keys?.forget(sessionId); return reply(res, { accepted: true }); case '/multi/mod/telemetry': bridge.observeStep(key, { diff --git a/plugins/multi-core/src/gateway/server.ts b/plugins/multi-core/src/gateway/server.ts index 7764fa6..c8a6dbc 100644 --- a/plugins/multi-core/src/gateway/server.ts +++ b/plugins/multi-core/src/gateway/server.ts @@ -4,10 +4,16 @@ import http from 'node:http'; import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { prepareOpenAIRequest } from '../../../multi-openai/src/gateway-request.ts'; -import { forAnthropic } from '../../../multi-openai/src/responses.ts'; +import { + openaiSignaturePrefixes, + prepareOpenAIRequest, +} from '../../../multi-openai/src/gateway-request.ts'; import { validateZenKey } from '../../../multi-zen/src/auth.ts'; -import { prepareZenRequest, zenUnavailable } from '../../../multi-zen/src/gateway-request.ts'; +import { + prepareZenRequest, + zenSignaturePrefixes, + zenUnavailable, +} from '../../../multi-zen/src/gateway-request.ts'; import type { ApprovalContext, NativeApprovalBridge } from './approval.ts'; import { approvalCwdForComparison, isApprovalRequest, parseApprovalRequest } from './approval.ts'; import { setBounded } from './bounded.ts'; @@ -46,9 +52,12 @@ import { ProviderAuthError, UpstreamFailure } from './provider-request.ts'; import { ProviderUsageDashboard, type ProviderUsageReader } from './provider-usage.ts'; import { ReceiptLedger } from './receipts.ts'; import { isRecord } from './record.ts'; +import { forAnthropic } from './responses.ts'; import { dangerousToolMode, safeguardResults } from './safeguards.ts'; import { forwardObservedTools, ToolObserver } from './tool-observer.ts'; +/** Other providers' reasoning signatures mean nothing to Anthropic and are stripped. */ +const FOREIGN_SIGNATURE_PREFIXES = [...openaiSignaturePrefixes, ...zenSignaturePrefixes]; const DEFAULT_ANTHROPIC_URL = 'https://api.anthropic.com'; // Anthropic's own Messages API limit is 32 MB; the gateway buffers up to it and leaves // the verdict to the provider, so a request Claude Code could send natively still goes. @@ -623,7 +632,7 @@ export function createNativeGateway({ async function handleAnthropic(exchange: ProviderRequest) { const { req, res, body, url, raw, signal } = exchange; const headers = anthropicHeaders(req); - const cleaned = forAnthropic(body); + const cleaned = forAnthropic(body, FOREIGN_SIGNATURE_PREFIXES); let forwarded: Buffer | undefined; if (req.method === 'POST') { forwarded = cleaned === body ? raw : Buffer.from(JSON.stringify(cleaned)); @@ -941,6 +950,7 @@ export function createNativeGateway({ billedUsage, dashboard, rows: displayRows, + keys: modKeys, }); } return http.createServer(async (req, res) => { diff --git a/plugins/multi-openai/src/gateway-request.ts b/plugins/multi-openai/src/gateway-request.ts index 941209a..8c79285 100644 --- a/plugins/multi-openai/src/gateway-request.ts +++ b/plugins/multi-openai/src/gateway-request.ts @@ -5,16 +5,23 @@ import type { ProviderRequestInput, } from '../../multi-core/src/gateway/provider-request.ts'; import { ProviderAuthError } from '../../multi-core/src/gateway/provider-request.ts'; +import { + fromResponses, + OPENAI_SIGNATURE_PREFIX, + toResponses, +} from '../../multi-core/src/gateway/responses.ts'; import { estimateInputTokens } from '../../multi-core/src/gateway/tokens.ts'; import { originalToolNames } from '../../multi-core/src/gateway/tools.ts'; import { CodexAuthError, codexRequest } from './auth.ts'; import { openaiInstructions } from './instructions.ts'; import { MODELS } from './models.ts'; -import { fromResponses, toResponses } from './responses.ts'; const OPENAI_URL = 'https://chatgpt.com/backend-api/codex/responses'; const MESSAGES_PATHS = ['/v1/messages', '/v1/messages/count_tokens']; +/** The prefixes of the reasoning signatures this provider writes into Claude's history. */ +export const openaiSignaturePrefixes: readonly string[] = [OPENAI_SIGNATURE_PREFIX]; + const openaiAuthHelp = ' Renew the Codex login.'; /** The Responses request for one Claude request to a native `multi/openai/` model. */ diff --git a/plugins/multi-openai/src/responses.ts b/plugins/multi-openai/src/responses.ts deleted file mode 100644 index 5e8c19f..0000000 --- a/plugins/multi-openai/src/responses.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { MessagesRequest } from '../../multi-core/src/gateway/messages.ts'; -import { - OPENAI_SIGNATURE_PREFIX, - forAnthropic as stripProviderSignatures, -} from '../../multi-core/src/gateway/responses.ts'; -import { ZEN_SIGNATURE_PREFIXES } from '../../multi-zen/src/chat.ts'; - -export { - fromResponses, - type ResponsesRequest, - toResponses, -} from '../../multi-core/src/gateway/responses.ts'; - -/** - * Compatibility entry for the gateway router, which still imports this module. - * The gateway owns the provider list; the shared translation takes it as a parameter. - */ -export function forAnthropic(body: MessagesRequest): MessagesRequest { - return stripProviderSignatures(body, [OPENAI_SIGNATURE_PREFIX, ...ZEN_SIGNATURE_PREFIXES]); -} diff --git a/plugins/multi-zen/src/gateway-request.ts b/plugins/multi-zen/src/gateway-request.ts index 7c1a2f7..115ea6c 100644 --- a/plugins/multi-zen/src/gateway-request.ts +++ b/plugins/multi-zen/src/gateway-request.ts @@ -4,9 +4,9 @@ import type { PreparedRequest, ProviderRequestInput, } from '../../multi-core/src/gateway/provider-request.ts'; +import { fromResponses } from '../../multi-core/src/gateway/responses.ts'; import { originalToolNames } from '../../multi-core/src/gateway/tools.ts'; -import { fromResponses } from '../../multi-openai/src/responses.ts'; -import { fromChat } from './chat.ts'; +import { fromChat, ZEN_SIGNATURE_PREFIXES } from './chat.ts'; import { zenRequest } from './request.ts'; const ZEN_URL = 'https://opencode.ai/zen/v1'; @@ -15,6 +15,9 @@ const MESSAGES_PATHS = ['/v1/messages', '/v1/messages/count_tokens']; export const zenUnavailable = 'Zen is not configured. Set OPENCODE_API_KEY or connect OpenCode Zen.'; +/** The prefixes of the reasoning signatures this provider writes into Claude's history. */ +export const zenSignaturePrefixes: readonly string[] = ZEN_SIGNATURE_PREFIXES; + const zenAuthHelp = ' Check the Zen API key.'; /** The Zen request for one Claude request to a `multi/zen/` model. */ diff --git a/test/unit/native-session-isolation.test.ts b/test/unit/native-session-isolation.test.ts index a682047..332b089 100644 --- a/test/unit/native-session-isolation.test.ts +++ b/test/unit/native-session-isolation.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import type { TestContext } from 'node:test'; import test from 'node:test'; +import { ModSessionKeys } from '../../plugins/multi-core/src/gateway/mod-keys.ts'; import { nestedClaudeShim, withPathPrefix, @@ -81,8 +82,8 @@ async function listen(t: TestContext) { }); const address = server.address(); assert(address && typeof address !== 'string'); - return (session: string, key?: string) => - fetch(`http://127.0.0.1:${address.port}/multi/mod/session`, { + return (session: string, key?: string, route = 'session') => + fetch(`http://127.0.0.1:${address.port}/multi/mod/${route}`, { method: 'POST', headers: { 'x-multi-gateway-token': 'tok', ...(key ? { 'x-multi-mod-key': key } : {}) }, body: JSON.stringify({ sessionId: session, permissionMode: 'default' }), @@ -106,3 +107,39 @@ test('once the mod presents its session key, the token alone cannot change that // Another session has its own key. assert.notEqual((await post('s2')).status, 403); }); + +test('detaching a session drops its key so a reused session ID is issued a fresh one', async (t) => { + const post = await listen(t); + const key = (await post('s1')).headers.get('x-multi-mod-key'); + assert(key); + assert.notEqual((await post('s1', key)).status, 403); + assert.equal((await post('s1')).status, 403); + assert.equal((await post('s1', key, 'detach')).status, 200); + const reused = await post('s1'); + assert.notEqual(reused.status, 403); + assert.notEqual(reused.headers.get('x-multi-mod-key'), null); +}); + +test('minting fake sessions cannot evict a confirmed session key', () => { + const keys = new ModSessionKeys(); + const real = keys.check('real', undefined); + assert(!real.refused && real.issue); + assert.deepEqual(keys.check('real', real.issue), { refused: false }); + for (let index = 0; index < 2048; index++) { + keys.check(`fake-${index}`, undefined); + } + assert.deepEqual(keys.check('real', undefined), { refused: true }); + assert.deepEqual(keys.check('real', real.issue), { refused: false }); +}); + +test('a full table of confirmed sessions still admits a new one by evicting the oldest', () => { + const keys = new ModSessionKeys(); + for (let index = 0; index < 1024; index++) { + const verdict = keys.check(`s-${index}`, undefined); + assert(!verdict.refused && verdict.issue); + keys.check(`s-${index}`, verdict.issue); + } + assert(!keys.check('new', undefined).refused); + assert.equal(keys.check('s-0', undefined).refused, false); + assert.equal(keys.check('s-2', undefined).refused, true); +}); From 79ffc5c5614b5c86e258bc07654dcd8450bf8750 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:33:33 -0400 Subject: [PATCH 29/36] Consolidate isRecord and nativeSpelling into the core helpers Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- plugins/multi-antigravity/src/cli.ts | 2 +- plugins/multi-antigravity/src/harness.ts | 11 ++--- plugins/multi-antigravity/src/models.ts | 6 +-- plugins/multi-antigravity/src/progress.ts | 5 +-- plugins/multi-antigravity/src/quota.ts | 5 +-- .../multi-core/src/gateway/harness-session.ts | 5 +-- plugins/multi-core/src/gateway/responses.ts | 5 +-- .../multi-core/src/install/installation.ts | 2 +- plugins/multi-cursor/src/harness.ts | 2 +- plugins/multi-cursor/src/quota.ts | 5 +-- plugins/multi-cursor/src/request.ts | 5 +-- plugins/multi-cursor/src/session-record.ts | 2 +- plugins/multi-grok/src/cli.ts | 5 +-- plugins/multi-grok/src/progress.ts | 5 +-- plugins/multi-grok/src/usage.ts | 5 +-- plugins/multi-openai/src/approval.ts | 26 +++++------ plugins/multi-openai/src/auth.ts | 5 +-- plugins/multi-openai/src/usage.ts | 20 ++++----- plugins/multi-zen/src/auth.ts | 5 +-- plugins/multi-zen/src/chat.ts | 43 +++++++++---------- plugins/multi-zen/src/usage.ts | 5 +-- test/unit/native-harness-session.test.ts | 2 +- 22 files changed, 68 insertions(+), 108 deletions(-) diff --git a/plugins/multi-antigravity/src/cli.ts b/plugins/multi-antigravity/src/cli.ts index 72d72f3..05ce50a 100644 --- a/plugins/multi-antigravity/src/cli.ts +++ b/plugins/multi-antigravity/src/cli.ts @@ -6,7 +6,7 @@ import { promptArgvSafe, runNativeCli, } from '../../multi-core/src/gateway/harness-process.ts'; -import { isRecord } from '../../multi-core/src/gateway/harness-session.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; type AntigravityStatus = | 'SUCCESS' diff --git a/plugins/multi-antigravity/src/harness.ts b/plugins/multi-antigravity/src/harness.ts index 509390f..093f8a4 100644 --- a/plugins/multi-antigravity/src/harness.ts +++ b/plugins/multi-antigravity/src/harness.ts @@ -23,10 +23,9 @@ import { HarnessResponse, type HarnessUsageFields, } from '../../multi-core/src/gateway/harness-response.ts'; -import { - type HarnessSession, - type HarnessSessionBase, - isRecord, +import type { + HarnessSession, + HarnessSessionBase, } from '../../multi-core/src/gateway/harness-session.ts'; import type { Emit, @@ -34,6 +33,8 @@ import type { MessagesResponse, } from '../../multi-core/src/gateway/messages.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; +import { nativeSpelling } from '../../multi-core/src/gateway/provider.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import type { AntigravityResult, @@ -45,7 +46,7 @@ import type { import { runAntigravity } from './cli.ts'; import { antigravitySettingsFile } from './hooks.ts'; import type { AntigravityModel } from './models.ts'; -import { nativeSpelling, selectAntigravityModel } from './models.ts'; +import { selectAntigravityModel } from './models.ts'; import { type AntigravityPolicy, antigravityCompactionDenyList } from './permissions.ts'; import { observeAntigravityCall, diff --git a/plugins/multi-antigravity/src/models.ts b/plugins/multi-antigravity/src/models.ts index 2c74bc2..ef49e97 100644 --- a/plugins/multi-antigravity/src/models.ts +++ b/plugins/multi-antigravity/src/models.ts @@ -4,6 +4,7 @@ import { executableInvocation, resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; +import { nativeSpelling } from '../../multi-core/src/gateway/provider.ts'; import { antigravityEnvironment } from './cli.ts'; export interface AntigravityModel { @@ -139,11 +140,6 @@ function defaultVariant(models: readonly AntigravityModel[], base: string) { throw new Error('Antigravity base route has no advertised native variant.'); } -/** Claude's 1M-context picker tag; it is display metadata and never a native model ID. */ -export function nativeSpelling(model: string | undefined): string | undefined { - return model?.replace(/\[1m\]$/i, ''); -} - /** Resolve advertised variants; let agy validate effort for models without suffixes. */ export function selectAntigravityModel( models: readonly AntigravityModel[], diff --git a/plugins/multi-antigravity/src/progress.ts b/plugins/multi-antigravity/src/progress.ts index 1710854..157b952 100644 --- a/plugins/multi-antigravity/src/progress.ts +++ b/plugins/multi-antigravity/src/progress.ts @@ -3,6 +3,7 @@ import type { NativeActionTracker, } from '../../multi-core/src/gateway/harness-progress.ts'; import type { HarnessModelCalls } from '../../multi-core/src/gateway/harness-response.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { type AntigravityStreamEvent, parseAntigravityUsage } from './cli.ts'; type StepUpdate = Extract['step_update']; @@ -82,10 +83,6 @@ const targetKey = /path|file|command|query|pattern|url/i; const doneState = /done|complete|success|finish/i; const failedState = /error|fail|cancel|denied|reject|block/i; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function kindOf(tool: string): NativeActionKind { return kinds.find(([pattern]) => pattern.test(tool))?.[1] ?? 'other'; } diff --git a/plugins/multi-antigravity/src/quota.ts b/plugins/multi-antigravity/src/quota.ts index 0de4195..aec9de9 100644 --- a/plugins/multi-antigravity/src/quota.ts +++ b/plugins/multi-antigravity/src/quota.ts @@ -4,6 +4,7 @@ import { resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; import { terminateProcessTree } from '../../multi-core/src/gateway/process-tree.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; interface AntigravityQuotaBucket { id: string; @@ -222,7 +223,3 @@ function runCommand( }); }); } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/plugins/multi-core/src/gateway/harness-session.ts b/plugins/multi-core/src/gateway/harness-session.ts index 19e548f..aaf3135 100644 --- a/plugins/multi-core/src/gateway/harness-session.ts +++ b/plugins/multi-core/src/gateway/harness-session.ts @@ -6,6 +6,7 @@ import { atomicWriteFile } from './atomic-write.ts'; import { isDisplayTool } from './display-rows.ts'; import type { HarnessEvent } from './harness-exchange.ts'; import type { MessagesResponse } from './messages.ts'; +import { isRecord } from './record.ts'; import { lockStateFile } from './state-lock.ts'; /** @@ -386,10 +387,6 @@ export function isHash(value: unknown): value is string { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); } -export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - export function optionalCount(value: unknown): boolean { return value === undefined || (Number.isSafeInteger(value) && Number(value) >= 0); } diff --git a/plugins/multi-core/src/gateway/responses.ts b/plugins/multi-core/src/gateway/responses.ts index d84ae3f..6ad909f 100644 --- a/plugins/multi-core/src/gateway/responses.ts +++ b/plugins/multi-core/src/gateway/responses.ts @@ -8,6 +8,7 @@ import type { ResponseContentBlock, StopReason, } from './messages.ts'; +import { isRecord } from './record.ts'; import { type SafeguardProvider, safeguardResults } from './safeguards.ts'; import { callId, toolName } from './tools.ts'; @@ -109,10 +110,6 @@ interface ReasoningState { } // Boundary guards: JSON.parse and the provider stream hand us `unknown`. -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function isOutputItem(value: unknown, done: boolean): value is ResponsesOutputItem { if ( !isRecord(value) || diff --git a/plugins/multi-core/src/install/installation.ts b/plugins/multi-core/src/install/installation.ts index d75c1b6..764e434 100644 --- a/plugins/multi-core/src/install/installation.ts +++ b/plugins/multi-core/src/install/installation.ts @@ -145,7 +145,7 @@ function normalizeModels(value: string | undefined, previous?: string): string | // /model and pastes here. Accept it and store the untagged ID, which is the stable one: // it still names the row whether or not the tag is switched on later. The strip is // spelled out rather than imported because this file is installed on its own, with no - // sibling plugin sources beside it; `nativeSpelling()` in multi-antigravity is the + // sibling plugin sources beside it; `nativeSpelling()` in gateway/provider.ts is the // canonical definition. const persisted = models.map((model) => model.replace(/\[1m\]$/i, '')); for (const model of persisted) { diff --git a/plugins/multi-cursor/src/harness.ts b/plugins/multi-cursor/src/harness.ts index b003619..7cca7b6 100644 --- a/plugins/multi-cursor/src/harness.ts +++ b/plugins/multi-cursor/src/harness.ts @@ -46,7 +46,6 @@ import { HarnessSessionStore, type HarnessTurnLease, isHash, - isRecord, readJson, textContentBlock, } from '../../multi-core/src/gateway/harness-session.ts'; @@ -57,6 +56,7 @@ import type { MessagesResponse, } from '../../multi-core/src/gateway/messages.ts'; import type { PermissionContext } from '../../multi-core/src/gateway/mode-hook.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { dangerousToolMode } from '../../multi-core/src/gateway/safeguards.ts'; import { settleOrAbort } from '../../multi-core/src/gateway/settle.ts'; import { CursorProviderError, cursorRunError } from './errors.ts'; diff --git a/plugins/multi-cursor/src/quota.ts b/plugins/multi-cursor/src/quota.ts index 0b15d97..6ade07e 100644 --- a/plugins/multi-cursor/src/quota.ts +++ b/plugins/multi-cursor/src/quota.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import { getDefaultSdkAuthPath } from '@cursor/sdk'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; export interface CursorQuotaUsage { billingCycleStart?: string; @@ -204,10 +205,6 @@ function formatDate(value: string | undefined): string { return value; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function unavailable(): Error { return new Error('Cursor quota is unavailable'); } diff --git a/plugins/multi-cursor/src/request.ts b/plugins/multi-cursor/src/request.ts index 50f467a..0c7425b 100644 --- a/plugins/multi-cursor/src/request.ts +++ b/plugins/multi-cursor/src/request.ts @@ -2,6 +2,7 @@ import type { SDKUserMessage } from '@cursor/sdk'; import { normalizeConversation, textContent } from '../../multi-core/src/gateway/conversation.ts'; import { isDirectToolAvailable } from '../../multi-core/src/gateway/direct-tools.ts'; import type { MessagesRequest } from '../../multi-core/src/gateway/messages.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { estimateTextTokens } from '../../multi-core/src/gateway/tokens.ts'; function validateControls(body: MessagesRequest) { @@ -82,10 +83,6 @@ function validateThinking(thinking: MessagesRequest['thinking']) { } } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - export function prepareCursorRequest(body: MessagesRequest) { validateControls(body); // AgentOptions has no Messages max-token or sampling controls. Keep Claude's diff --git a/plugins/multi-cursor/src/session-record.ts b/plugins/multi-cursor/src/session-record.ts index 759c9bc..1ca9ac3 100644 --- a/plugins/multi-cursor/src/session-record.ts +++ b/plugins/multi-cursor/src/session-record.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; -import { isRecord } from '../../multi-core/src/gateway/harness-session.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; /** The v2 gateway hashed the serialized [workspace, scope] directly. */ export function legacyCursorSessionFile(directory: string, identity: string): string { diff --git a/plugins/multi-grok/src/cli.ts b/plugins/multi-grok/src/cli.ts index c54ffe5..da4ce61 100644 --- a/plugins/multi-grok/src/cli.ts +++ b/plugins/multi-grok/src/cli.ts @@ -9,6 +9,7 @@ import { promptArgvSafe, runNativeCli, } from '../../multi-core/src/gateway/harness-process.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; /** * Grok Build headless contract, captured from `grok -p --output-format streaming-json` @@ -472,10 +473,6 @@ function parseTerminal( emit({ event: 'result', result }); } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function optionalString(value: unknown): string | undefined { return typeof value === 'string' && value ? value : undefined; } diff --git a/plugins/multi-grok/src/progress.ts b/plugins/multi-grok/src/progress.ts index 083fb90..e31ad65 100644 --- a/plugins/multi-grok/src/progress.ts +++ b/plugins/multi-grok/src/progress.ts @@ -2,6 +2,7 @@ import type { NativeActionKind, NativeActionTracker, } from '../../multi-core/src/gateway/harness-progress.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import type { GrokStreamEvent, GrokToolCall } from './cli.ts'; // ACP tool kinds as the Grok Build CLI reports them. @@ -14,10 +15,6 @@ const kinds: Record = { execute: 'shell', }; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** The text a tool update carries in its content blocks. */ function grokContentText(content: unknown): string { if (!Array.isArray(content)) { diff --git a/plugins/multi-grok/src/usage.ts b/plugins/multi-grok/src/usage.ts index fd002c5..42e5f91 100644 --- a/plugins/multi-grok/src/usage.ts +++ b/plugins/multi-grok/src/usage.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; /** * Grok Build exposes no account quota endpoint, so the useful account fact is the @@ -26,10 +27,6 @@ export function grokAuthFile(options: GrokAuthOptions = {}): string { return join(options.homedir ?? os.homedir(), '.grok', 'auth.json'); } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - /** Loads native auth to report key/refresh-token presence and access-token expiry only. */ export async function readGrokAuth(options: GrokAuthOptions = {}): Promise { let source: string; diff --git a/plugins/multi-openai/src/approval.ts b/plugins/multi-openai/src/approval.ts index 168b825..54896c5 100644 --- a/plugins/multi-openai/src/approval.ts +++ b/plugins/multi-openai/src/approval.ts @@ -9,6 +9,7 @@ import type { } from '../../multi-core/src/gateway/approval.ts'; import { NativeApprovalBridge } from '../../multi-core/src/gateway/approval.ts'; import type { GatewayFetch } from '../../multi-core/src/gateway/fetch.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { readSse } from '../../multi-core/src/gateway/responses.ts'; import { codexRequest } from './auth.ts'; @@ -19,9 +20,6 @@ const REVIEW_FAILURE_EVENTS: readonly unknown[] = [ 'error', ]; const endpoint = 'https://chatgpt.com/backend-api/codex'; -const record = (value: unknown): value is Record => - !!value && typeof value === 'object' && !Array.isArray(value); - /** Discover the subscription reviewer; never substitute the working model. */ export async function discoverOpenAIReviewer( authFile: string, @@ -42,9 +40,9 @@ export async function discoverOpenAIReviewer( } const catalog: unknown = await response.json(); return ( - record(catalog) && + isRecord(catalog) && Array.isArray(catalog.models) && - catalog.models.some((model: unknown) => record(model) && model.slug === 'codex-auto-review') + catalog.models.some((model: unknown) => isRecord(model) && model.slug === 'codex-auto-review') ); } @@ -55,7 +53,7 @@ export async function inspectApprovalPath( { platform = process.platform }: { platform?: NodeJS.Platform } = {}, ): Promise { if ( - !record(input) || + !isRecord(input) || typeof input.path !== 'string' || Object.keys(input).some((key) => key !== 'path') ) { @@ -238,7 +236,7 @@ async function readReviewerOutput(response: Response): Promise[] = []; let bytes = 0; for await (const event of readSse(response.body)) { - if (!record(event)) { + if (!isRecord(event)) { throw new Error('Invalid reviewer stream'); } bytes += JSON.stringify(event).length; @@ -248,7 +246,7 @@ async function readReviewerOutput(response: Response): Promise[], completed: unknown, ): Record[] { - if (!record(completed) || completed.status !== 'completed' || !Array.isArray(completed.output)) { + if ( + !isRecord(completed) || + completed.status !== 'completed' || + !Array.isArray(completed.output) + ) { throw new Error('Incomplete reviewer response'); } // Codex's subscription stream may leave final output empty; done events carry the items. const output: unknown[] = items.length ? items : completed.output; - if (!output.every(record)) { + if (!output.every(isRecord)) { throw new Error('Invalid reviewer output item'); } if (JSON.stringify(output).length > 131072) { @@ -331,7 +333,7 @@ function reviewerVerdict(output: Record[]): ApprovalVerdict { throw new Error('Invalid reviewer verdict'); } return item.content.map((part: unknown) => { - if (!record(part) || part.type !== 'output_text' || typeof part.text !== 'string') { + if (!isRecord(part) || part.type !== 'output_text' || typeof part.text !== 'string') { throw new Error('Invalid reviewer verdict'); } return part.text; @@ -341,7 +343,7 @@ function reviewerVerdict(output: Record[]): ApprovalVerdict { throw new Error('Invalid reviewer verdict'); } const verdict: unknown = JSON.parse(text.join('')); - if (!record(verdict) || (verdict.outcome !== 'allow' && verdict.outcome !== 'deny')) { + if (!isRecord(verdict) || (verdict.outcome !== 'allow' && verdict.outcome !== 'deny')) { throw new Error('Invalid reviewer verdict'); } if ( diff --git a/plugins/multi-openai/src/auth.ts b/plugins/multi-openai/src/auth.ts index 3c490f2..1aee2a7 100644 --- a/plugins/multi-openai/src/auth.ts +++ b/plugins/multi-openai/src/auth.ts @@ -7,6 +7,7 @@ import { resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; import { terminateProcessTree } from '../../multi-core/src/gateway/process-tree.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; /** Codex's saved ChatGPT login, forwarded as OpenAI request headers. */ export interface CodexAuthHeaders { @@ -20,10 +21,6 @@ const refreshing = new Map>(); const renewalFailure = () => new CodexAuthError('Codex could not renew the ChatGPT login. Run codex login and retry.'); -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - async function savedAuth(authFile: string) { let auth: unknown; try { diff --git a/plugins/multi-openai/src/usage.ts b/plugins/multi-openai/src/usage.ts index e9db52e..05332b3 100644 --- a/plugins/multi-openai/src/usage.ts +++ b/plugins/multi-openai/src/usage.ts @@ -6,6 +6,7 @@ import { resolveExecutable, } from '../../multi-core/src/gateway/executable.ts'; import { terminateProcessTree } from '../../multi-core/src/gateway/process-tree.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; interface CodexQuotaWindow { label: string; @@ -103,10 +104,10 @@ async function quotaResponse( for await (const line of lines) { check(); const message: unknown = JSON.parse(line); - if (!record(message)) { + if (!isRecord(message)) { throw unavailable(); } - if (message.id === 1 && !initialized && record(message.result)) { + if (message.id === 1 && !initialized && isRecord(message.result)) { initialized = true; send({ method: 'initialized' }); send({ id: 2, method: 'account/rateLimits/read' }); @@ -121,12 +122,12 @@ async function quotaResponse( /** The multi-bucket response is authoritative; never count its legacy alias twice. */ export function normalizeCodexUsage(value: unknown): CodexQuota { - if (!record(value)) { + if (!isRecord(value)) { throw unavailable(); } const quota: CodexQuota = { windows: [] }; for (const [id, bucket] of quotaBuckets(value).slice(0, 32)) { - if (!record(bucket)) { + if (!isRecord(bucket)) { continue; } if (typeof bucket.planType === 'string') { @@ -145,7 +146,7 @@ export function normalizeCodexUsage(value: unknown): CodexQuota { } function quotaCredits(value: unknown): CodexQuota['credits'] { - if (!record(value)) { + if (!isRecord(value)) { return undefined; } return { @@ -155,17 +156,17 @@ function quotaCredits(value: unknown): CodexQuota['credits'] { } function quotaBuckets(value: Record): [string, unknown][] { - if (record(value.rateLimitsByLimitId) && Object.keys(value.rateLimitsByLimitId).length) { + if (isRecord(value.rateLimitsByLimitId) && Object.keys(value.rateLimitsByLimitId).length) { return Object.entries(value.rateLimitsByLimitId); } - if (record(value.rateLimits)) { + if (isRecord(value.rateLimits)) { return [['codex', value.rateLimits]]; } throw unavailable(); } function quotaWindow(value: unknown, name: string, slot: string): CodexQuotaWindow | undefined { - if (!record(value)) { + if (!isRecord(value)) { return undefined; } if ( @@ -202,6 +203,3 @@ function windowLabel(minutes: number) { } return `${minutes} minutes`; } -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/plugins/multi-zen/src/auth.ts b/plugins/multi-zen/src/auth.ts index d5fc742..e387b55 100644 --- a/plugins/multi-zen/src/auth.ts +++ b/plugins/multi-zen/src/auth.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; export class ZenAuthError extends Error {} @@ -11,10 +12,6 @@ export function validateZenKey(value: string): string { return value; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function pathForPlatform(platform: NodeJS.Platform) { return platform === 'win32' ? path.win32 : path.posix; } diff --git a/plugins/multi-zen/src/chat.ts b/plugins/multi-zen/src/chat.ts index fb0a105..0d1bd1f 100644 --- a/plugins/multi-zen/src/chat.ts +++ b/plugins/multi-zen/src/chat.ts @@ -7,6 +7,7 @@ import type { ResponseContentBlock, StopReason, } from '../../multi-core/src/gateway/messages.ts'; +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { prefixSafeLength, readSse } from '../../multi-core/src/gateway/responses.ts'; import { safeguardResults } from '../../multi-core/src/gateway/safeguards.ts'; import { callId, toolName } from '../../multi-core/src/gateway/tools.ts'; @@ -93,10 +94,6 @@ interface ChatEvent { error?: unknown; } -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function string(value: unknown, name: string): string { if (typeof value !== 'string') { throw new Error(`Invalid ${name}`); @@ -112,7 +109,7 @@ function blocks(value: unknown): ContentBlock[] { throw new Error('Expected text or content blocks'); } for (const block of value) { - if (!record(block) || typeof block.type !== 'string') { + if (!isRecord(block) || typeof block.type !== 'string') { throw new Error('Invalid content block'); } } @@ -198,7 +195,7 @@ function ownReasoning(model: string, block: ContentBlock): string | undefined { const decoded: unknown = JSON.parse( Buffer.from(block.signature.slice(prefix.length), 'base64url').toString(), ); - if (record(decoded) && typeof decoded.reasoning === 'string') { + if (isRecord(decoded) && typeof decoded.reasoning === 'string') { return decoded.reasoning; } } catch { @@ -253,7 +250,7 @@ function assistantText( return { kind: 'text', value: string(block.text, 'assistant text') }; } if (block.type === 'tool_use') { - if (!block.id || !block.name || !record(block.input)) { + if (!block.id || !block.name || !isRecord(block.input)) { throw new Error('Invalid tool_use'); } return { @@ -421,20 +418,20 @@ function chatTools(body: MessagesRequest): ChatTool[] { .filter( (tool) => !isDeferredTool(tool) || - (record(tool) && typeof tool.name === 'string' && isDirectToolAvailable(body, tool.name)), + (isRecord(tool) && typeof tool.name === 'string' && isDirectToolAvailable(body, tool.name)), ) .map(chatTool); } function isDeferredTool(tool: unknown): boolean { - return record(tool) && tool.defer_loading === true; + return isRecord(tool) && tool.defer_loading === true; } function chatTool(tool: unknown): ChatTool { - if (!record(tool) || (tool.type !== undefined && tool.type !== 'custom')) { + if (!isRecord(tool) || (tool.type !== undefined && tool.type !== 'custom')) { throw new Error('Invalid tool'); } - if (typeof tool.name !== 'string' || !tool.name.trim() || !record(tool.input_schema)) { + if (typeof tool.name !== 'string' || !tool.name.trim() || !isRecord(tool.input_schema)) { throw new Error('Invalid function tool'); } if (tool.description !== undefined && typeof tool.description !== 'string') { @@ -469,7 +466,7 @@ function requestOptions(body: MessagesRequest, result: ChatRequest): ChatRequest } const format = body.output_config?.format ?? body.output_format; if (format !== undefined) { - if (format.type !== 'json_schema' || !record(format.schema)) { + if (format.type !== 'json_schema' || !isRecord(format.schema)) { throw new Error('Unsupported output format'); } result.response_format = { @@ -481,7 +478,7 @@ function requestOptions(body: MessagesRequest, result: ChatRequest): ChatRequest } function validUsage(value: unknown): value is ChatUsage { - if (!record(value)) { + if (!isRecord(value)) { return false; } if (!validUsageNumbers(value) || !validUsageDetails(value)) { @@ -520,14 +517,14 @@ function validUsageDetails(value: Record): boolean { return true; } return ( - record(item) && + isRecord(item) && Object.values(item).every((nested) => Number.isSafeInteger(nested) && Number(nested) >= 0) ); }); } function usageNumber(value: unknown, ...keys: string[]): number | undefined { - if (!record(value)) { + if (!isRecord(value)) { return undefined; } for (const key of keys) { @@ -570,7 +567,7 @@ function delta(value: unknown): ChatDelta { if (value === undefined || value === null) { return {}; } - if (!record(value)) { + if (!isRecord(value)) { throw new Error('Malformed Zen Chat choice'); } for (const key of ['content', 'reasoning_content']) { @@ -640,7 +637,7 @@ class ChatAccumulator { } accept(value: unknown) { - if (!record(value)) { + if (!isRecord(value)) { throw new Error('Malformed Zen Chat event'); } const event = value as ChatEvent; @@ -659,7 +656,9 @@ class ChatAccumulator { return; } throw new Error( - record(error) && typeof error.message === 'string' ? error.message : 'Zen Chat stream failed', + isRecord(error) && typeof error.message === 'string' + ? error.message + : 'Zen Chat stream failed', ); } @@ -682,7 +681,7 @@ class ChatAccumulator { return; } const choice = value[0]; - if (!record(choice)) { + if (!isRecord(choice)) { throw new Error('Malformed Zen Chat choice'); } if (choice.index !== undefined && choice.index !== 0) { @@ -861,7 +860,7 @@ class ChatAccumulator { } private toolSlot(raw: unknown): ToolSlot { - if (!record(raw) || !Number.isSafeInteger(raw.index) || Number(raw.index) < 0) { + if (!isRecord(raw) || !Number.isSafeInteger(raw.index) || Number(raw.index) < 0) { throw new Error('Malformed Zen tool call delta'); } const index = Number(raw.index); @@ -889,7 +888,7 @@ class ChatAccumulator { if (fn === undefined) { return; } - if (!record(fn)) { + if (!isRecord(fn)) { throw new Error('Malformed Zen function delta'); } if (fn.name !== undefined && fn.name !== null) { @@ -972,7 +971,7 @@ class ChatAccumulator { } catch { throw new Error('Zen Chat returned invalid tool arguments'); } - if (!record(input)) { + if (!isRecord(input)) { throw new Error('Zen Chat tool arguments must be an object'); } const block: Extract = { diff --git a/plugins/multi-zen/src/usage.ts b/plugins/multi-zen/src/usage.ts index aefe406..4fd1a7e 100644 --- a/plugins/multi-zen/src/usage.ts +++ b/plugins/multi-zen/src/usage.ts @@ -1,3 +1,4 @@ +import { isRecord } from '../../multi-core/src/gateway/record.ts'; import { readZenKey, validateZenKey, type ZenAuthPathOptions } from './auth.ts'; interface ZenQuotaWindow { @@ -133,10 +134,6 @@ function parseWindow(value: unknown): ZenQuotaWindow | undefined { return { status: value.status, percent: value.percent, resetsAt: value.resetsAt }; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function isEntitlementError(value: unknown) { return ( isRecord(value) && diff --git a/test/unit/native-harness-session.test.ts b/test/unit/native-harness-session.test.ts index 8cb330f..d4e4591 100644 --- a/test/unit/native-harness-session.test.ts +++ b/test/unit/native-harness-session.test.ts @@ -10,7 +10,6 @@ import { type HarnessSessionBase, HarnessSessionStore, isHash, - isRecord, optionalCount, readJson, validMessagesResponse, @@ -18,6 +17,7 @@ import { validReplay, } from '../../plugins/multi-core/src/gateway/harness-session.ts'; import type { MessagesResponse } from '../../plugins/multi-core/src/gateway/messages.ts'; +import { isRecord } from '../../plugins/multi-core/src/gateway/record.ts'; type Saved = HarnessSessionBase & { version: 1; sessionId?: string }; From 7c0083df08903958da4a4c9cb6d982e33c0b501a Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:33:33 -0400 Subject: [PATCH 30/36] Bring AGENTS.md, ARCHITECTURE.md and docs in line with the current modules Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- AGENTS.md | 20 +++++++++-------- ARCHITECTURE.md | 52 +++++++++++++++++++++++++++++++++++---------- docs/claude-mods.md | 2 +- docs/usage.md | 2 +- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 767eb1e..fba5734 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,17 +17,19 @@ Claude Code function-hook UI and extensibility rules live in | --- | --- | | `plugins/multi-core/src/launcher.ts` | Launches the gateway and registers models and workers. | | `plugins/multi-core/src/gateway/server.ts` | Routes requests and manages sessions. | -| `plugins/multi-core/src/gateway/messages.ts`, `fetch.ts`, `tools.ts` | Shared protocol, outbound fetch, and tool aliases. | +| `plugins/multi-core/src/gateway/messages.ts`, `fetch.ts`, `tools.ts`, `responses.ts`, `bounded.ts`, `record.ts` | Shared protocol, outbound fetch, tool aliases, Responses translation and SSE reader, bounded maps, and the `isRecord` guard. | +| `plugins/multi-core/src/gateway/provider.ts`, `provider-request.ts` | Model-prefix provider table, `nativeSpelling`, and the contract through which OpenAI and Zen modules build their own requests. | | `plugins/multi-core/src/gateway/executable.ts`, `process-tree.ts` | Resolves executables and manages child processes. | | `plugins/multi-core/src/gateway/atomic-write.ts`, `state-lock.ts` | Protects files and serializes native state. | -| `plugins/multi-core/src/gateway/cursor-settings.ts`, `mode-hook.ts`, `agent-definitions.ts`, `worker-catalog.ts` | Admits settings, maps prompt and worker permissions, and resolves the Agent tool's `model` against each provider's worker catalog. | +| `plugins/multi-core/src/gateway/harness-settings.ts`, `mode-hook.ts`, `agent-definitions.ts`, `worker-catalog.ts` | Admits harness settings (`checkHarnessSettings`, `mergePermissions`), maps prompt and worker permissions, and resolves the Agent tool's `model` against each provider's worker catalog. | | `plugins/multi-core/src/gateway/approval.ts`, `permission-hook.ts` | Approval protocol and capability checks. | -| `plugins/multi-core/src/gateway/mod-*.ts`, `display-rows.ts`, `tool-observer.ts` | Claude Mods control-plane routes, compaction, policy, native action display rows, and tool observation. | -| `plugins/multi-core/src/gateway/harness-*.ts` | Session store, exchange registry, response builder, completion, and notices shared by Cursor, Antigravity, and Grok; CLI process runner and text prompt preparation shared by Antigravity and Grok. | +| `plugins/multi-core/src/gateway/mod-*.ts`, `display-rows.ts`, `tool-observer.ts`, `nested-env.ts` | Claude Mods control-plane routes, per-session mod key (`mod-keys.ts`), compaction, policy, native action display rows, tool observation, and the nested-`claude` environment restore. | +| `plugins/multi-core/hooks/` | Claude Mods function hooks; `gateway.ts` is the single gateway client, state lives in `$.state`, timing in `$.clock`. | +| `plugins/multi-core/src/gateway/harness-*.ts` | Session store, exchange registry, response builder, completion, notices, and failure classifier (`harness-failure.ts`) shared by Cursor, Antigravity, and Grok; the CLI harness base (`harness-cli.ts`), process runner, and text prompt preparation shared by Antigravity and Grok. | | `plugins/multi-core/src/account.ts`, `setup.ts`, `install/` | Accounts, bootstrap, plugin discovery, and installation. | -| `plugins/multi-openai/src/` | Codex authentication, models, Responses translation, instructions, and reviewer. | +| `plugins/multi-openai/src/` | Codex authentication, models, request construction (`gateway-request.ts`), instructions, and reviewer. | | `plugins/multi-cursor/src/` | Cursor SDK harness, permissions, progress, requests, models, and workspaces. | -| `plugins/multi-zen/src/` | Zen API-key authentication, catalogs, requests, and translations. | +| `plugins/multi-zen/src/` | Zen API-key authentication, catalogs, request construction (`gateway-request.ts`), and translations. | | `plugins/multi-antigravity/src/` | `agy` CLI harness, models, hooks, requests, and permissions. | | `plugins/multi-grok/src/` | Grok Build CLI harness, models, requests, permissions, and login state. | | `test/unit/` | Offline unit tests for gateway and provider behavior. | @@ -71,9 +73,9 @@ type checking, and offline tests. `npm test` runs `tsc --noEmit` and the unit test suite. Use Node 24.12 or newer and avoid `DEP0190` warnings. For Claude Mods hook or surface changes, also run `npm run test:mod`. It type-checks -the hooks against `.claude/types` (write them with `/plugin-types` first; they are -gitignored) and runs `claude plugin test` with the installed Claude executable, -without provider inference. It is separate from `npm run check` and the current CI matrix. +the hooks against `.claude-plugin/types/` (Claude Code writes them when it loads the +plugin; they are gitignored) and runs `claude plugin test` with the installed Claude +executable and the Mods rollout flag forced on, without provider inference. It is separate from `npm run check` and the current CI matrix. | Command | Check | Login needed | | --- | --- | --- | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 22d8e25..017f66b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,7 +13,13 @@ session. The launcher registers provider models and one Agent-tool worker type per provider. The Node gateway routes requests, preserves Claude passthrough, and coordinates sessions. Claude Mods provide the in-engine control plane for model rows, worker rows, -permission state, progress, and compaction. [The local Mods reference](docs/claude-mods.md) +permission state, progress, and compaction. The hooks (`plugins/multi-core/hooks/`) +reach the gateway through one client (`hooks/gateway.ts`) and keep state in +`$.state` so it survives a hot reload. The gateway mints a per-session mod key +(`mod-keys.ts`) on the mod's first request and, once the mod echoes it, refuses +`/multi/mod/*` requests carrying only the environment token, so a shell command +cannot loosen a session's permissions. Session state is dropped on `session.end`, +not when a client detaches. [The local Mods reference](docs/claude-mods.md) is required reading for changes to Claude Code UI or extensibility. Provider adapters own their model catalogs, authentication, execution, and review boundaries. @@ -32,8 +38,19 @@ Claude Code session (/model, workers, prompts) ## Request flow Claude Code sends Anthropic Messages traffic and provider requests to the gateway. -The gateway passes Anthropic traffic through and translates direct-provider -requests. `/model` exposes provider model and effort rows. Each connected +The gateway passes Anthropic traffic through untouched (every path other than +Messages and `/multi/*` is forwarded as raw bytes, and a failed upstream +connection is dropped so Claude Code's own retry runs) and translates +direct-provider requests. It honors `HTTPS_PROXY` and forwards to a caller-set +`ANTHROPIC_BASE_URL`. A `claude` started by a command inside the session gets +the session-start environment back (`nested-env.ts`, a PATH shim and the +bootstrap), so it never routes through the session gateway. Hosted-API +providers build their own requests: `gateway/provider-request.ts` defines the +contract, `multi-openai/src/gateway-request.ts` and +`multi-zen/src/gateway-request.ts` implement it, and the gateway owns keepalive, +events, and failure mapping. `gateway/provider.ts` is the one reader of a model +ID's provider prefix. The Responses translation and SSE reader live in +`gateway/responses.ts`. `/model` exposes provider model and effort rows. Each connected provider's Agent-tool worker type runs that provider's picker rows; the Agent tool's `model` parameter picks which. Each run reports a visible lifecycle: row, elapsed time, streamed progress, completion, failure, and cancellation. @@ -78,19 +95,26 @@ action becomes a display row in the harness's own reply: a tool_use block named the native tool (`mcp__multi-core__run_command`) with the native parameters and a gateway-issued token, answered by the Multi mod with the native output, so the row sits in the transcript of the `/model` session or worker that ran it. Display tools -are never model tools: the gateway strips them from every provider's tools and -history, the mod defers them and refuses any call without an issued token, and the +are never model tools: they register in the engine lazily, only when a harness +prompt, worker, or step needs them, and the gateway strips them (and their +`tool_reference` blocks) from every provider's tools and history. The mod defers +them and refuses any call without an issued token, and the gateway answers the engine's follow-up request with the reply's remaining text without a native run. Nothing is replayed or re-executed. Cursor supports Auto, Plan, and Bypass. Antigravity uses its native CLI with Claude policy enforcement at the prompt boundary. Grok carries the same policy in its own run arguments, and each announced toolset is checked against -it because an unknown removal is accepted and ignored by that CLI. +it because an unknown removal is accepted and ignored by that CLI. Antigravity +runs an allowlist built from the Claude grant: unmapped tools are denied in +every mode, scheduling, messaging, task, and knowledge-deletion tools are always +denied, and write tools cannot touch agy's own hook or config files. All three harnesses share their session store, in-flight exchange registry, -response builder, durable completion, notices, and the transcript action summary from -`plugins/multi-core/src/gateway/harness-*.ts`. Grok and Antigravity additionally -share native process execution, text prompt preparation, and the native action -tracker (Cursor uses it too) that turns native actions into display rows; Cursor retains its +response builder, durable completion, notices, failure classifier, and the +transcript action summary from `plugins/multi-core/src/gateway/harness-*.ts`. +Grok and Antigravity additionally share a CLI harness base (`harness-cli.ts`: +the request flow around one native run), native process execution, text prompt +preparation, and the native action tracker (Cursor uses it too) that turns +native actions into display rows; Cursor retains its SDK and image-aware prompt format. Each provider still owns its own event grammar, CLI argument construction, usage accounting, and (for Cursor) SDK agent lifecycle. The shared layer owns turn leases, lock lifetime, @@ -102,7 +126,9 @@ than queued against a native conversation that has moved on. ## Permissions Claude's permission mode controls each provider at prompt boundaries through the -UserPromptSubmit and SubagentStart hooks. The gateway intersects worker rules, +UserPromptSubmit and SubagentStart hooks. Worker tool calls are attributed +through the Mods `tool.call` hook, with the session's permission mode. +Harness settings admission is `gateway/harness-settings.ts`. The gateway intersects worker rules, provider capabilities, project settings, and platform policy. Unsupported modes, unknown workers, untranslatable policies, and unavailable required reviewers fail explicitly. Plan denies shell and edit capabilities. Bypass disables Cursor native @@ -118,6 +144,10 @@ rule, and its announced toolset is verified because that CLI accepts an unknown removal silently; its MCP tools stay visible to the model and are documented as such. Explicit native workspace selection is required. +Auto-mode classifier requests for Claude's own actions go to Anthropic unless a +provider-owned pending action positively matches; only a request routed to +provider review keeps the session mismatch check. + Claude's launcher enables on-demand tool discovery for the local gateway. Direct provider adapters omit deferred tool schemas until Claude discovers or uses them; tool references and loaded declarations survive later turns and provider diff --git a/docs/claude-mods.md b/docs/claude-mods.md index 52eb794..e7a7b6a 100644 --- a/docs/claude-mods.md +++ b/docs/claude-mods.md @@ -280,7 +280,7 @@ whose last turn holds only the rows' results, with it, without a native run. The harness's record of its reply includes that text, so the next turn's history still matches. The held text is bound to the session, worker and provider whose reply wrote the rows, and only that scope's request naming that reply's rows receives it; -any other is refused, and a session's held texts are dropped when it detaches. +any other is refused, and a session's held texts are dropped when it ends. After a restart or an eviction the gateway reads it back from the harness's session record (`recordedResponse`); when no record holds it, the request fails explicitly. An action whose completion never arrives is settled when the run ends as diff --git a/docs/usage.md b/docs/usage.md index 424990d..7cee39e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -42,7 +42,7 @@ The advisory uses the same cached account lookups as the menu. Unavailable quota is labeled unknown, and lookup failures do not stop the agent. Worker receipts are not included in the advisory. Disabling the toggle stops future advisories; earlier context already received by the agent is not removed. The preference is -session-only and is cleared when the session detaches. +session-only and is cleared when the session ends. Runtime totals cover requests observed by this gateway process; restarting the launcher starts a new in-memory view. Claude subscription passthrough and reviewer From 5661c46183a22ec7d0b37d51f38bd2c4d26b84cd Mon Sep 17 00:00:00 2001 From: greenpolo Date: Thu, 1 Oct 2026 17:38:44 -0400 Subject: [PATCH 31/36] Fix regression-review findings: Host check, review ownership, nested launch, Windows shim, proxy, keepalive failure Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- CHANGELOG.md | 10 +++++ plugins/multi-core/src/gateway/executable.ts | 6 ++- plugins/multi-core/src/gateway/nested-env.ts | 28 ++++++++---- plugins/multi-core/src/gateway/provider.ts | 9 ++-- plugins/multi-core/src/gateway/server.ts | 42 ++++++++++++++++-- plugins/multi-core/src/install/bootstrap.ts | 14 ++++-- .../multi-core/src/install/nested-claude.ts | 4 +- plugins/multi-core/src/install/process.ts | 35 ++++++++++++++- plugins/multi-core/src/launcher.ts | 22 +++++----- test/unit/gateway-provider-bounded.test.ts | 1 + .../unit/native-anthropic-passthrough.test.ts | 28 ++++++++++++ test/unit/native-gateway-transport.test.ts | 32 ++++++++++++++ test/unit/native-launcher.test.ts | 43 +++++++++++++++++++ test/unit/native-session-isolation.test.ts | 29 +++++++++++++ 14 files changed, 269 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc26661..64f3372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and ## Unreleased +- **Close review-round gaps.** The gateway refuses any request whose `Host` is + not its own loopback name and port, so a DNS-rebinding page cannot use the + raw Anthropic passthrough as a relay. A `multi/` model with an unknown + provider prefix is reviewed like other external models, except Zen. A `multi` + launch inside a Multi session forwards to the user's own upstream and headers. + On Windows the nested `claude` shim also exists as an extensionless sh script + for Git Bash. A `claude "a & b"` through a non-npm `.cmd` launcher names the + refused argument. With a proxy set, `NO_PROXY` gains the loopback names. A + provider failure after the 30 s keepalive is now an assistant message stating + the failure rather than an error body under a 200. - **Leave Claude sessions alone until a harness runs.** Multi registered about 70 hidden display tools (named after Cursor, Antigravity, and Grok tools, such as `web_search`) at every session start, so ToolSearch could surface them to diff --git a/plugins/multi-core/src/gateway/executable.ts b/plugins/multi-core/src/gateway/executable.ts index 2b556cf..576708d 100644 --- a/plugins/multi-core/src/gateway/executable.ts +++ b/plugins/multi-core/src/gateway/executable.ts @@ -80,11 +80,13 @@ export function isCmdSafeArgument(value: string): boolean { export class UnsafeCommandArgumentError extends Error { readonly code = 'EUNSAFEARG'; - constructor() { + readonly argument: string; + constructor(argument = '') { super( 'Refusing to pass an argument containing cmd.exe metacharacters through a .cmd/.bat launcher; deliver it on stdin instead', ); this.name = 'UnsafeCommandArgumentError'; + this.argument = argument; } } @@ -128,7 +130,7 @@ export function executableInvocation( // safe for such text, so refuse it and let callers use stdin or a file. for (const value of [executable, ...args]) { if (!isCmdSafeArgument(value)) { - throw new UnsafeCommandArgumentError(); + throw new UnsafeCommandArgumentError(value); } } const command = environmentValue(env, 'ComSpec') ?? process.env.ComSpec ?? 'cmd.exe'; diff --git a/plugins/multi-core/src/gateway/nested-env.ts b/plugins/multi-core/src/gateway/nested-env.ts index 0a80ab7..b23da3a 100644 --- a/plugins/multi-core/src/gateway/nested-env.ts +++ b/plugins/multi-core/src/gateway/nested-env.ts @@ -9,25 +9,37 @@ // has to carry it without the rest of the gateway). // --------------------------------------------------------------------------- -/** The `claude` launcher a session puts first on PATH for the processes it starts. */ +export interface NestedShimFile { + file: string; + content: string; +} + +/** + * The `claude` launchers a session puts first on PATH for the processes it starts. On + * Windows that is `claude.cmd` for cmd.exe and PowerShell, plus an extensionless sh + * script: Claude Code's Bash tool there is Git Bash, which does not apply PATHEXT and + * would otherwise find npm's own extensionless `claude`. + */ export function nestedClaudeShim(options: { platform?: NodeJS.Platform; node: string; script: string; claude: string; -}): { file: string; content: string } { +}): NestedShimFile & { files: NestedShimFile[] } { const { node, script, claude } = options; + const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + const sh: NestedShimFile = { + file: 'claude', + content: `#!/bin/sh\nexec ${quote(node)} ${quote(script)} ${quote(claude)} "$@"\n`, + }; if ((options.platform ?? process.platform) === 'win32') { - return { + const cmd: NestedShimFile = { file: 'claude.cmd', content: `@echo off\r\n"${node}" "${script}" "${claude}" %*\r\n`, }; + return { ...cmd, files: [cmd, sh] }; } - const quote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; - return { - file: 'claude', - content: `#!/bin/sh\nexec ${quote(node)} ${quote(script)} ${quote(claude)} "$@"\n`, - }; + return { ...sh, files: [sh] }; } /** `directory` first on PATH, whatever case the platform spells the variable in. */ diff --git a/plugins/multi-core/src/gateway/provider.ts b/plugins/multi-core/src/gateway/provider.ts index 617b4f7..6623078 100644 --- a/plugins/multi-core/src/gateway/provider.ts +++ b/plugins/multi-core/src/gateway/provider.ts @@ -31,10 +31,13 @@ export function providerRoute(model: string | null | undefined): ProviderRoute { return providerOf(model) ?? (model ? 'openai' : 'anthropic'); } -/** Whose reviewer judges the model's actions: a provider's own, never Zen or Claude. */ +/** + * Whose reviewer judges the model's actions: a provider's own, never Zen (whose auto mode + * is unreviewed) or Claude. An unrecognized `multi/` prefix routes to OpenAI, so it is + * reviewed like any other external model. + */ export function providerOwnedReview(model: string): boolean { - const provider = providerOf(model); - return provider !== undefined && provider !== 'zen'; + return model.startsWith('multi/') && providerOf(model) !== 'zen'; } /** Claude's context tag is display metadata, never part of a native model ID. */ diff --git a/plugins/multi-core/src/gateway/server.ts b/plugins/multi-core/src/gateway/server.ts index c8a6dbc..b66f7e8 100644 --- a/plugins/multi-core/src/gateway/server.ts +++ b/plugins/multi-core/src/gateway/server.ts @@ -152,6 +152,7 @@ export interface GatewayOptions { /** Rejected before any provider call; answered as HTTP 400 rather than 502. */ class BadRequest extends Error {} +class ForbiddenHost extends Error {} const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error)); function authenticated(actual: string | string[] | undefined, expected: string): boolean { @@ -991,6 +992,7 @@ export function createNativeGateway({ return res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...value })}\n\n`); }; try { + assertLoopbackHost(req); const url = new URL(req.url ?? '', 'http://localhost'); if (isRawAnthropicPath(url.pathname)) { return await handleRawAnthropic(req, res, url, abort); @@ -1042,7 +1044,7 @@ export function createNativeGateway({ await dispatch(exchange, metadata, external); } catch (error) { abort.abort(); - failResponse(res, emit, error, replies.jsonStarted); + failResponse(res, emit, error, replies.jsonStarted, sourceModel); } finally { replies.stop(); } @@ -1140,6 +1142,20 @@ function isRawAnthropicPath(pathName: string) { ); } +/** + * Only the gateway's own loopback name and port are accepted as the `Host`. A web page + * that rebinds its DNS name to 127.0.0.1 reaches the port with its own name in `Host`, + * which is what keeps it from using the unauthenticated raw passthrough as a relay. + */ +function assertLoopbackHost(req: http.IncomingMessage) { + const host = req.headers.host?.toLowerCase(); + const port = req.socket.localPort; + const allowed = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]; + if (!host || !allowed.includes(host)) { + throw new ForbiddenHost('Forbidden host'); + } +} + /** A request body is present when the client declared one, whatever the method. */ function requestHasBody(req: http.IncomingMessage) { if (req.method === 'GET' || req.method === 'HEAD') { @@ -1219,7 +1235,7 @@ function errorStatus(error: unknown): number { if (error instanceof BadRequest) { return 400; } - if (error instanceof FollowUpRefused) { + if (error instanceof FollowUpRefused || error instanceof ForbiddenHost) { return 403; } if (error instanceof FollowUpUnavailable) { @@ -1374,6 +1390,7 @@ function failResponse( emit: Emit, error: unknown, jsonKeepAlive: boolean, + model = '', ) { if (res.destroyed) { return; @@ -1412,8 +1429,9 @@ function failResponse( res.setHeader('retry-after', error.retryAfter); } if (res.headersSent && jsonKeepAlive) { - // The 200 and its headers are already out; a status can no longer carry the failure. - res.end(JSON.stringify(failure)); + // The 200 and its headers are already out, so the failure has to be a valid message + // for Claude Code to show; an error body under a 200 is read as a malformed reply. + res.end(JSON.stringify(failureMessage(error, model))); } else if (res.headersSent) { emit('error', { error: failure.error }); res.end(); @@ -1423,6 +1441,22 @@ function failResponse( } } +/** The failure as an ordinary assistant message: it states the failure and claims no work. */ +export function failureMessage(error: unknown, model: string): MessagesResponse { + const provider = providerRoute(model); + const detail = reason(error).replace(/\s+/g, ' ').trim().slice(0, 500); + return { + id: `msg_${randomUUID()}`, + type: 'message', + role: 'assistant', + model, + content: [{ type: 'text', text: `Multi: ${provider} failed: ${detail}` }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }; +} + function assertProviderEnabled(model: string | null, enabled: readonly string[] | undefined) { if (model && enabled && !enabled.includes(model.split('/')[1])) { throw new BadRequest('This provider plugin is not enabled for this session.'); diff --git a/plugins/multi-core/src/install/bootstrap.ts b/plugins/multi-core/src/install/bootstrap.ts index 1216d00..5bab34f 100644 --- a/plugins/multi-core/src/install/bootstrap.ts +++ b/plugins/multi-core/src/install/bootstrap.ts @@ -3,7 +3,13 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { type Installation, readInstallation, uninstall } from './installation.ts'; import { installedPlugins, settingsArguments } from './plugins.ts'; -import { needsEnvProxy, run, withoutGateway } from './process.ts'; +import { + describeRunError, + needsEnvProxy, + run, + withLoopbackNoProxy, + withoutGateway, +} from './process.ts'; async function dispatch(state: Installation, args: string[], management: boolean) { const { root, providers } = await installedPlugins(state.claude, settingsArguments(args)); @@ -30,7 +36,7 @@ async function dispatch(state: Installation, args: string[], management: boolean if (manifest.name !== 'multi-core') { throw new Error('Installed core manifest does not identify multi-core'); } - const env = { + const env = withLoopbackNoProxy({ ...process.env, ...(state.models !== undefined && process.env.MULTI_MODELS === undefined ? { MULTI_MODELS: state.models } @@ -40,7 +46,7 @@ async function dispatch(state: Installation, args: string[], management: boolean MULTI_REAL_CLAUDE: state.claude, MULTI_ENABLED_PROVIDERS: providers.join(','), MULTI_ANTIGRAVITY: providers.includes('antigravity') ? '1' : '0', - }; + }); const entry = management ? 'account.ts' : 'launcher.ts'; return run(state.node, [path.join(root, 'plugins', 'multi-core', 'src', entry), ...args], { env, @@ -67,7 +73,7 @@ void main().then( process.exitCode = code; }, (error: unknown) => { - console.error(`Multi: ${error instanceof Error ? error.message : String(error)}`); + console.error(`Multi: ${describeRunError(error)}`); process.exitCode = 1; }, ); diff --git a/plugins/multi-core/src/install/nested-claude.ts b/plugins/multi-core/src/install/nested-claude.ts index ddac466..362f7c2 100644 --- a/plugins/multi-core/src/install/nested-claude.ts +++ b/plugins/multi-core/src/install/nested-claude.ts @@ -1,7 +1,7 @@ // Runs the real Claude Code without the enclosing Multi session's gateway // environment. The launcher puts a `claude` shim that calls this first on the // session's PATH, so a `claude` started by Claude's own tools is an ordinary run. -import { run, withoutGateway } from './process.ts'; +import { describeRunError, run, withoutGateway } from './process.ts'; const [claude, ...args] = process.argv.slice(2); if (!claude) { @@ -13,7 +13,7 @@ if (!claude) { process.exitCode = code; }, (error: unknown) => { - console.error(`Multi: ${error instanceof Error ? error.message : String(error)}`); + console.error(`Multi: ${describeRunError(error)}`); process.exitCode = 1; }, ); diff --git a/plugins/multi-core/src/install/process.ts b/plugins/multi-core/src/install/process.ts index 6908c3d..f2f2bb2 100644 --- a/plugins/multi-core/src/install/process.ts +++ b/plugins/multi-core/src/install/process.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { executableInvocation } from '../gateway/executable.ts'; +import { executableInvocation, UnsafeCommandArgumentError } from '../gateway/executable.ts'; import { terminateProcessTree } from '../gateway/process-tree.ts'; export interface RunOptions { @@ -97,3 +97,36 @@ export function needsEnvProxy(env: NodeJS.ProcessEnv): boolean { ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((name) => Boolean(env[name])) ); } + +/** The message a failed `run` is reported with; a refused argument is named and explained. */ +export function describeRunError(error: unknown): string { + if (error instanceof UnsafeCommandArgumentError) { + const shown = error.argument.length > 80 ? `${error.argument.slice(0, 80)}...` : error.argument; + return `Claude is launched through a .cmd/.bat file, and cmd.exe cannot pass the argument ${JSON.stringify(shown)} safely (it contains one of " % ! ^ & | < > or a line break). Run Claude through the npm-installed claude (npm install -g @anthropic-ai/claude-code) or the native installer, or avoid those characters in the argument.`; + } + return error instanceof Error ? error.message : String(error); +} + +const PROXY_VARIABLES = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']; +const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '::1']; + +/** + * With a proxy configured and NODE_USE_ENV_PROXY=1, the gateway's and hooks' own + * requests to 127.0.0.1 would go through the proxy too. Append the loopback names to + * NO_PROXY and no_proxy, keeping the user's entries and adding none twice. + */ +export function withLoopbackNoProxy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (!PROXY_VARIABLES.some((name) => Boolean(env[name]))) { + return env; + } + const result: NodeJS.ProcessEnv = { ...env }; + for (const name of ['NO_PROXY', 'no_proxy']) { + const entries = (env[name] ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + const missing = LOOPBACK_HOSTS.filter((host) => !entries.includes(host)); + result[name] = [...entries, ...missing].join(','); + } + return result; +} diff --git a/plugins/multi-core/src/launcher.ts b/plugins/multi-core/src/launcher.ts index b9b6464..21f5a3e 100644 --- a/plugins/multi-core/src/launcher.ts +++ b/plugins/multi-core/src/launcher.ts @@ -87,7 +87,7 @@ import { } from './gateway/worker-catalog.ts'; import { providerSelection } from './install/plugins.ts'; -import { needsEnvProxy, rememberOriginalEnvironment } from './install/process.ts'; +import { needsEnvProxy, rememberOriginalEnvironment, withoutGateway } from './install/process.ts'; const enabledProviders = providerSelection(process.env.MULTI_ENABLED_PROVIDERS); const providerEnabled = (provider: string) => @@ -254,7 +254,8 @@ async function main() { usageReaders, permissionModes, approvalBridge, - anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL || undefined, + // A launch inside another Multi session forwards to the user's own upstream, not the outer gateway. + anthropicBaseUrl: withoutGateway(process.env).ANTHROPIC_BASE_URL || undefined, blockAnthropic: !anthropic, guardAuto: true, onEvent: traceEvent, @@ -1210,28 +1211,27 @@ function translateTrafficPolicy(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { } function gatewayEnvironment(port: number, token: string, anthropic: boolean) { - const env = translateTrafficPolicy({ ...process.env }); + // A launch inside another Multi session starts from the user's own environment. + const origin = withoutGateway(process.env); + const env = translateTrafficPolicy({ ...origin }); delete env.OPENCODE_API_KEY; return { ...env, // What nested Claude runs get back in place of the gateway (see nested-env.ts). - ...rememberOriginalEnvironment(process.env), + ...rememberOriginalEnvironment(origin), ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, // A custom base URL makes Claude Code skip on-demand tool loading unless it is told // otherwise, and a gateway base URL is always custom. Claude's own default against // Anthropic is to defer tools always, which is what 'true' says; the user's own // setting is kept. 'auto' would instead load every tool once a 1M window makes them // under 10%. - ENABLE_TOOL_SEARCH: process.env.ENABLE_TOOL_SEARCH ?? 'true', + ENABLE_TOOL_SEARCH: origin.ENABLE_TOOL_SEARCH ?? 'true', MULTI_GATEWAY_TOKEN: token, // Claude Code 2.1.287 and newer has Mods on by default; 2.1.272 to 2.1.286 need this. CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: '1', MULTI_MOD_GATEWAY_URL: `http://127.0.0.1:${port}`, ...(!anthropic ? { ANTHROPIC_AUTH_TOKEN: token } : {}), - ANTHROPIC_CUSTOM_HEADERS: [ - process.env.ANTHROPIC_CUSTOM_HEADERS, - `x-multi-gateway-token: ${token}`, - ] + ANTHROPIC_CUSTOM_HEADERS: [origin.ANTHROPIC_CUSTOM_HEADERS, `x-multi-gateway-token: ${token}`] .filter(Boolean) .join('\n'), }; @@ -1254,7 +1254,9 @@ async function withNestedClaudeShim( claude, }); await mkdir(directory, { recursive: true }); - await writeFile(path.join(directory, shim.file), shim.content, { mode: 0o755 }); + for (const file of shim.files) { + await writeFile(path.join(directory, file.file), file.content, { mode: 0o755 }); + } return withPathPrefix(env, directory); } diff --git a/test/unit/gateway-provider-bounded.test.ts b/test/unit/gateway-provider-bounded.test.ts index 8c6f248..aeb9c26 100644 --- a/test/unit/gateway-provider-bounded.test.ts +++ b/test/unit/gateway-provider-bounded.test.ts @@ -18,6 +18,7 @@ test('provider prefixes decide routing, review ownership and harness execution i assert.equal(harnessProvider(undefined), undefined); assert.equal(providerOwnedReview('multi/antigravity/x'), true); assert.equal(providerOwnedReview('multi/openai/x'), true); + assert.equal(providerOwnedReview('multi/unknown/x'), true); assert.equal(providerOwnedReview('multi/zen/x'), false); assert.equal(providerOwnedReview('claude-sonnet'), false); }); diff --git a/test/unit/native-anthropic-passthrough.test.ts b/test/unit/native-anthropic-passthrough.test.ts index 6037738..b5bd324 100644 --- a/test/unit/native-anthropic-passthrough.test.ts +++ b/test/unit/native-anthropic-passthrough.test.ts @@ -140,3 +140,31 @@ test('/v1 routes the gateway does not own are forwarded as raw bytes without its const gated = await fetch(`http://127.0.0.1:${port}/multi/mod/session`, { method: 'POST' }); assert.equal(gated.status, 401); }); + +test('a request whose Host is not the gateway loopback name is refused before any relay', async (t) => { + let relayed = 0; + const port = await listen(t, async () => { + relayed += 1; + return new Response('ok'); + }); + const send = (host: string) => + new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: '/v1/files', method: 'GET', headers: { host } }, + (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }, + ); + req.on('error', reject); + req.end(); + }); + assert.equal(await send('evil.example'), 403); + assert.equal(await send(`evil.example:${port}`), 403); + assert.equal(await send('127.0.0.1'), 403); + assert.equal(relayed, 0); + assert.equal(await send(`localhost:${port}`), 200); + assert.equal(await send(`[::1]:${port}`), 200); + assert.equal(await send(`127.0.0.1:${port}`), 200); + assert.equal(relayed, 3); +}); diff --git a/test/unit/native-gateway-transport.test.ts b/test/unit/native-gateway-transport.test.ts index c65e65a..96cb259 100644 --- a/test/unit/native-gateway-transport.test.ts +++ b/test/unit/native-gateway-transport.test.ts @@ -209,3 +209,35 @@ test('a non-streamed provider reply that outlasts the delay sends headers and ke assert(timing.text.startsWith(' '), 'leading JSON whitespace'); assert.equal(JSON.parse(timing.text).content[0].text, 'Done'); }); + +test('a provider failure after the keepalive started is a valid assistant message', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'keepalive-fail-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const authFile = path.join(dir, 'auth.json'); + await writeFile( + authFile, + JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'fake', account_id: 'fake' } }), + ); + const fetchImpl: GatewayFetch = async () => { + await new Promise((resolve) => setTimeout(resolve, 300)); + throw new Error('provider exploded'); + }; + const port = await listen(t, { fetchImpl, authFile, jsonKeepAliveMs: 50 }); + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': TOKEN }, + body: JSON.stringify({ + model: 'multi/openai/gpt-6-luna', + max_tokens: 10, + messages: [{ role: 'user', content: 'hi' }], + }), + }); + assert.equal(response.status, 200); + const message = JSON.parse(await response.text()); + assert.equal(message.type, 'message'); + assert.equal(message.role, 'assistant'); + assert.equal(message.stop_reason, 'end_turn'); + assert.deepEqual(message.usage, { input_tokens: 0, output_tokens: 0 }); + assert.equal(message.content.length, 1); + assert.match(message.content[0].text, /^Multi: openai failed: .*provider exploded/); +}); diff --git a/test/unit/native-launcher.test.ts b/test/unit/native-launcher.test.ts index 2a9a09e..bc57138 100644 --- a/test/unit/native-launcher.test.ts +++ b/test/unit/native-launcher.test.ts @@ -667,3 +667,46 @@ test('the Zen model listing is available without authentication', async () => { const models = JSON.parse(stdout); assert(models.some((model: { id: string }) => model.id === 'minimax-m2.7')); }); + +test('a launch inside another Multi session starts from the user original environment', { + skip: process.platform === 'win32', +}, async (t) => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'launcher-nested-test-')); + t.after(() => removeTemporary(cwd)); + await mkdir(path.join(cwd, 'bin')); + await writeClaudeFixture( + path.join(cwd, 'bin'), + `#!/usr/bin/env node +const args=process.argv.slice(2); +if(args.includes('plugin')&&args.includes('list')){console.log('[]');process.exit(0)} +if(args[0]==='--version'){console.log('2.1.272');process.exit(0)} +if(args[0]==='auth'){process.stdout.write(JSON.stringify({loggedIn:true}));process.exit(0)} +console.log(JSON.stringify({headers:process.env.ANTHROPIC_CUSTOM_HEADERS,orig:process.env.MULTI_ORIG_ANTHROPIC_BASE_URL,origHeaders:process.env.MULTI_ORIG_ANTHROPIC_CUSTOM_HEADERS,base:process.env.ANTHROPIC_BASE_URL})); +`, + ); + const launcher = fileURLToPath( + new URL('../../plugins/multi-core/src/launcher.ts', import.meta.url), + ); + const { stdout } = await promisify(execFile)(process.execPath, [launcher], { + cwd, + timeout: 20000, + env: { + PATH: path.join(cwd, 'bin') + path.delimiter + process.env.PATH, + HOME: cwd, + CLAUDE_CONFIG_DIR: path.join(cwd, 'claude'), + CODEX_HOME: cwd, + // What the outer Multi session left in this process. + MULTI_GATEWAY_TOKEN: 'outer-token', + ANTHROPIC_BASE_URL: 'http://127.0.0.1:1', + ANTHROPIC_CUSTOM_HEADERS: 'x-team: a\nx-multi-gateway-token: outer-token', + MULTI_ORIG_ANTHROPIC_BASE_URL: 'https://corp.example', + MULTI_ORIG_ANTHROPIC_CUSTOM_HEADERS: 'x-team: a', + }, + }); + const seen = JSON.parse(stdout); + assert.match(seen.base, /^http:\/\/127\.0\.0\.1:(?!1$)\d+$/); + assert.equal(seen.orig, 'https://corp.example'); + assert.equal(seen.origHeaders, 'x-team: a'); + assert(seen.headers.startsWith('x-team: a\nx-multi-gateway-token: ')); + assert(!seen.headers.includes('outer-token')); +}); diff --git a/test/unit/native-session-isolation.test.ts b/test/unit/native-session-isolation.test.ts index 332b089..e2015f5 100644 --- a/test/unit/native-session-isolation.test.ts +++ b/test/unit/native-session-isolation.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import type { TestContext } from 'node:test'; import test from 'node:test'; +import { UnsafeCommandArgumentError } from '../../plugins/multi-core/src/gateway/executable.ts'; import { ModSessionKeys } from '../../plugins/multi-core/src/gateway/mod-keys.ts'; import { nestedClaudeShim, @@ -8,8 +9,10 @@ import { } from '../../plugins/multi-core/src/gateway/nested-env.ts'; import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; import { + describeRunError, needsEnvProxy, rememberOriginalEnvironment, + withLoopbackNoProxy, withoutGateway, } from '../../plugins/multi-core/src/install/process.ts'; @@ -59,10 +62,36 @@ test('the nested claude shim quotes its paths and goes first on PATH', () => { nestedClaudeShim({ platform: 'win32', node: 'n', script: 's', claude: 'c' }).file, 'claude.cmd', ); + const windows = nestedClaudeShim({ platform: 'win32', node: 'n', script: 's', claude: 'c' }); + assert.deepEqual( + windows.files.map((entry) => entry.file), + ['claude.cmd', 'claude'], + ); + assert.equal(windows.files[1].content, `#!/bin/sh\nexec 'n' 's' 'c' "$@"\n`); + assert(!windows.files[1].content.includes('\r')); + assert.deepEqual(posix.files, [{ file: posix.file, content: posix.content }]); assert.equal(withPathPrefix({ PATH: '/a' }, '/s', 'linux').PATH, '/s:/a'); assert.equal(withPathPrefix({ Path: 'C:\\a' }, 'C:\\s', 'win32').Path, 'C:\\s;C:\\a'); }); +test('loopback joins NO_PROXY and no_proxy once when a proxy is configured', () => { + assert.deepEqual(withLoopbackNoProxy({ A: '1' }), { A: '1' }); + const added = withLoopbackNoProxy({ + HTTPS_PROXY: 'http://p', + NO_PROXY: 'corp.example, localhost', + }); + assert.equal(added.NO_PROXY, 'corp.example,localhost,127.0.0.1,::1'); + assert.equal(added.no_proxy, '127.0.0.1,localhost,::1'); + assert.equal(withLoopbackNoProxy(added).NO_PROXY, added.NO_PROXY); +}); + +test('a refused cmd argument is named in an actionable message', () => { + const message = describeRunError(new UnsafeCommandArgumentError('fix a & b')); + assert(message.includes('"fix a & b"')); + assert(message.includes('native installer')); + assert.equal(describeRunError(new Error('plain')), 'plain'); +}); + test('a proxy in the environment needs Node started with NODE_USE_ENV_PROXY', () => { assert.equal(needsEnvProxy({ HTTPS_PROXY: 'http://p' }), true); assert.equal(needsEnvProxy({ https_proxy: 'http://p', NODE_USE_ENV_PROXY: '1' }), false); From 7f41ec01851365150b04730a5325386402918612 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Sat, 3 Oct 2026 21:32:28 -0400 Subject: [PATCH 32/36] Wait for a concurrent launch's Antigravity hook lock instead of failing Two claude-multi launches started together both reinstall the Antigravity hook under lockStateFile, which refused a live owner at once. The hook install now waits up to 5 s (new waitMs option; session files keep failing fast), and a marker released between lstat and read is retried. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdKRYTowK4F5p9JdLb6Ac6 --- CHANGELOG.md | 5 ++ plugins/multi-antigravity/src/hooks.ts | 5 +- plugins/multi-core/src/gateway/state-lock.ts | 66 ++++++++++++++++---- test/unit/native-state-lock.test.ts | 14 +++++ 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f3372..9243f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and ## Unreleased +- **Start several `claude-multi` sessions at once.** Every launch rewrites the + Antigravity permission hook under a file lock, and a second launch that met a + live holder failed with "State file is locked by another gateway". The hook + install now waits up to 5 s for the other launch, and a lock released between + the check and the read is retried instead of reported as missing metadata. - **Close review-round gaps.** The gateway refuses any request whose `Host` is not its own loopback name and port, so a DNS-rebinding page cannot use the raw Anthropic passthrough as a relay. A `multi/` model with an unknown diff --git a/plugins/multi-antigravity/src/hooks.ts b/plugins/multi-antigravity/src/hooks.ts index 26606af..3b0632e 100644 --- a/plugins/multi-antigravity/src/hooks.ts +++ b/plugins/multi-antigravity/src/hooks.ts @@ -6,6 +6,8 @@ import { hookCommand } from '../../multi-core/src/gateway/permission-hook.ts'; import { lockStateFile } from '../../multi-core/src/gateway/state-lock.ts'; const namespace = 'multi-cli-antigravity'; +/** Long enough for a concurrent launch's read and atomic rewrite of the hook file. */ +const hookLockWaitMs = 5000; export interface AntigravityPathOptions { platform?: NodeJS.Platform; @@ -85,7 +87,8 @@ export async function installAntigravityHook( options: AntigravityPathOptions = {}, ): Promise { await mkdir(path.dirname(file), { recursive: true }); - const unlock = await lockStateFile(`${file}.multi-lock`); + // Every launch reinstalls the hook, so a concurrent launch holds this briefly. + const unlock = await lockStateFile(`${file}.multi-lock`, { waitMs: hookLockWaitMs }); try { const hooks = await readHooks(file); const platform = options.platform ?? process.platform; diff --git a/plugins/multi-core/src/gateway/state-lock.ts b/plugins/multi-core/src/gateway/state-lock.ts index 50a200f..f6da467 100644 --- a/plugins/multi-core/src/gateway/state-lock.ts +++ b/plugins/multi-core/src/gateway/state-lock.ts @@ -12,6 +12,11 @@ export interface LockStateFileOptions { hostname?: string; platform?: NodeJS.Platform; maxAttempts?: number; + /** + * How long to wait for a live owner to release before failing. The default 0 + * fails at once: a session file held by another gateway is owned, not busy. + */ + waitMs?: number; rename?: typeof rename; unlink?: typeof unlink; open?: typeof open; @@ -101,26 +106,23 @@ export async function lockStateFile( const platform = options.platform ?? process.platform; const owner = newOwner(options, platform); const maxAttempts = options.maxAttempts ?? 100; - const unlinkFile = options.unlink ?? unlink; const openFile = options.open ?? open; - const readFileContents = options.readFile ?? readFile; - const operations: LockOperations = { - renameFile: options.rename ?? rename, - unlinkFile, - lstatFile: options.lstat ?? lstat, - readFileContents, - linkFile: options.link ?? link, - }; + const operations = lockOperations(options); + const { unlinkFile, readFileContents } = operations; if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) { throw new RangeError('State file lock maxAttempts must be a positive integer'); } + const deadline = Date.now() + (options.waitMs ?? 0); - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + for (let attempt = 0; attempt < maxAttempts; ) { const release = await tryAcquire(file, owner, platform, unlinkFile, openFile, readFileContents); if (release) { return release; } - await takeOverStaleLock(file, owner, platform, operations); + if (await ownerStillHolds(file, owner, platform, operations, deadline)) { + continue; + } + attempt += 1; if (platform === 'win32') { await delay(20); } @@ -128,6 +130,43 @@ export async function lockStateFile( throw new Error(`State file lock acquisition exceeded ${maxAttempts} attempts`); } +function lockOperations(options: LockStateFileOptions): LockOperations { + return { + renameFile: options.rename ?? rename, + unlinkFile: options.unlink ?? unlink, + lstatFile: options.lstat ?? lstat, + readFileContents: options.readFile ?? readFile, + linkFile: options.link ?? link, + }; +} + +class LockHeld extends Error {} + +const ownerPollMs = 50; + +/** + * Takes over a stale marker; a live owner's marker is waited on until `deadline`, + * then refused. True when the caller should try again without spending an attempt. + */ +async function ownerStillHolds( + file: string, + owner: LockOwner, + platform: NodeJS.Platform, + operations: LockOperations, + deadline: number, +): Promise { + try { + await takeOverStaleLock(file, owner, platform, operations); + return false; + } catch (error) { + if (!(error instanceof LockHeld) || Date.now() >= deadline) { + throw error; + } + await delay(Math.min(ownerPollMs, Math.max(1, deadline - Date.now()))); + return true; + } +} + async function tryAcquire( file: string, owner: LockOwner, @@ -191,13 +230,14 @@ async function takeOverStaleLock( } const current = await readOwner(file, platform, readFileContents); if (!current) { - throw new Error('State file is locked by another gateway (owner metadata is unavailable)'); + // The owner released between the lstat and the read: the caller acquires again. + return; } if ( current !== legacyEmptyMarker && (!sameMachine(current, self) || isProcessAlive(current.pid)) ) { - throw new Error('State file is locked by another gateway'); + throw new LockHeld('State file is locked by another gateway'); } const stale = `${file}.stale-${randomUUID()}`; try { diff --git a/test/unit/native-state-lock.test.ts b/test/unit/native-state-lock.test.ts index 9ad2f12..ba942ca 100644 --- a/test/unit/native-state-lock.test.ts +++ b/test/unit/native-state-lock.test.ts @@ -26,6 +26,20 @@ test('acquires, excludes concurrent owners, and releases a state lock', async (t await nextRelease(); }); +test('waits a bounded time for a live owner when asked to', async (t) => { + const directory = await temporaryDirectory(t, 'multi-wait-lock-'); + const file = path.join(directory, 'hooks.lock'); + const release = await lockStateFile(file); + setTimeout(() => void release(), 100); + const waited = await lockStateFile(file, { waitMs: 2000 }); + await waited(); + const held = await lockStateFile(file); + const started = Date.now(); + await assert.rejects(lockStateFile(file, { waitMs: 150 }), /locked by another gateway/); + assert.ok(Date.now() - started >= 140); + await held(); +}); + test('legacy directory locks remain explicit recovery evidence', async (t) => { const directory = await temporaryDirectory(t, 'multi-legacy-lock-'); const file = path.join(directory, 'session.lock'); From 861b975722d4c42abcf9c67dc4b902a3feb19124 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Sat, 3 Oct 2026 21:49:57 -0400 Subject: [PATCH 33/36] Deliver harness worker reports through SubagentHandback --- CHANGELOG.md | 5 + .../multi-core/src/gateway/display-rows.ts | 2 +- .../src/gateway/harness-handback.ts | 208 +++++++++++++ .../multi-core/src/gateway/harness-notices.ts | 2 + plugins/multi-core/src/gateway/server.ts | 172 +++++++++-- test/unit/native-harness-handback.test.ts | 285 ++++++++++++++++++ 6 files changed, 653 insertions(+), 21 deletions(-) create mode 100644 plugins/multi-core/src/gateway/harness-handback.ts create mode 100644 test/unit/native-harness-handback.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f3372..932014b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and ## Unreleased +- **Deliver native worker reports in Auto mode.** Cursor, Antigravity, and Grok + workers now send their final report through Claude Code's `SubagentHandback` + tool when it is offered. Display rows keep their follow-up answer, and resumed + workers continue without forwarding handback results or enforcement prompts to + the native harness. - **Close review-round gaps.** The gateway refuses any request whose `Host` is not its own loopback name and port, so a DNS-rebinding page cannot use the raw Anthropic passthrough as a relay. A `multi/` model with an unknown diff --git a/plugins/multi-core/src/gateway/display-rows.ts b/plugins/multi-core/src/gateway/display-rows.ts index 8e2ff51..e947d6c 100644 --- a/plugins/multi-core/src/gateway/display-rows.ts +++ b/plugins/multi-core/src/gateway/display-rows.ts @@ -544,7 +544,7 @@ export function recordedFollowUp( * Only a well-formed request is rewritten or answered here; anything else passes * unchanged to the validation that refuses it with the provider's own error. */ -function wellFormed(body: MessagesRequest): boolean { +export function wellFormed(body: MessagesRequest): boolean { const tools: unknown = body.tools; const messages: unknown = body.messages; return ( diff --git a/plugins/multi-core/src/gateway/harness-handback.ts b/plugins/multi-core/src/gateway/harness-handback.ts new file mode 100644 index 0000000..59df6ab --- /dev/null +++ b/plugins/multi-core/src/gateway/harness-handback.ts @@ -0,0 +1,208 @@ +import { randomUUID } from 'node:crypto'; +import { wellFormed } from './display-rows.ts'; +import type { + ContentBlock, + Emit, + MessagesRequest, + MessagesResponse, + RequestMessage, +} from './messages.ts'; +import { safeguardResults } from './safeguards.ts'; + +const HANDBACK_TOOL = 'SubagentHandback'; +const enforcement = /^\[handback-send-enforce\]/; +const fallback = 'Native run finished.'; + +export function handbackOffered(body: MessagesRequest): boolean { + if (!wellFormed(body)) { + return false; + } + return body.tools?.some((tool) => tool.name === HANDBACK_TOOL) ?? false; +} + +function blocks(message: RequestMessage | undefined): ContentBlock[] { + if (!message) { + return []; + } + return typeof message.content === 'string' + ? [{ type: 'text', text: message.content }] + : message.content; +} + +/** These are Claude's delivery protocol, never native-harness input. */ +export function handbackRequestKind(body: MessagesRequest): 'result' | 'enforce' | undefined { + if (!wellFormed(body)) { + return undefined; + } + const messages = body.messages ?? []; + const last = messages.at(-1); + if (last?.role !== 'user') { + return undefined; + } + const content = blocks(last); + if ( + content.some( + (block) => + block.type === 'text' && + typeof block.text === 'string' && + enforcement.test(block.text.trimStart()), + ) + ) { + return 'enforce'; + } + const ids = new Set( + messages.flatMap((message) => + message.role === 'assistant' + ? blocks(message).flatMap((block) => + block.type === 'tool_use' && block.name === HANDBACK_TOOL && block.id ? [block.id] : [], + ) + : [], + ), + ); + if ( + content.length > 0 && + content.every( + (block) => + block.type === 'tool_result' && + block.tool_use_id !== undefined && + ids.has(block.tool_use_id), + ) + ) { + return 'result'; + } + return undefined; +} + +function joined(messages: RequestMessage[]): RequestMessage[] { + const result: RequestMessage[] = []; + for (const message of messages) { + const content = blocks(message); + if (!content.length) { + continue; + } + const previous = result.at(-1); + if (previous?.role === message.role) { + result[result.length - 1] = { + role: message.role, + content: [...blocks(previous), ...content], + }; + } else { + result.push({ role: message.role, content }); + } + } + return result; +} + +/** Strip gateway handbacks and their results before continuation or rewind checks. */ +export function withoutHarnessHandback(body: MessagesRequest): MessagesRequest { + if (!wellFormed(body)) { + return body; + } + const messages = body.messages ?? []; + const tools = body.tools?.filter((tool) => tool.name !== HANDBACK_TOOL); + const ids = new Set( + messages.flatMap((message) => + blocks(message).flatMap((block) => + block.type === 'tool_use' && block.name === HANDBACK_TOOL && block.id ? [block.id] : [], + ), + ), + ); + if (!ids.size) { + return tools?.length === body.tools?.length ? body : { ...body, tools }; + } + const cleaned = messages.map((message) => ({ + ...message, + content: cleanHandbackMessage(message, ids), + })); + return { ...body, ...(body.tools ? { tools } : {}), messages: joined(cleaned) }; +} + +function cleanHandbackMessage(message: RequestMessage, ids: ReadonlySet): ContentBlock[] { + const original = blocks(message); + const content = original.filter( + (block) => + !(block.type === 'tool_use' && block.name === HANDBACK_TOOL) && + !( + block.type === 'tool_result' && + block.tool_use_id !== undefined && + ids.has(block.tool_use_id) + ), + ); + // An empty native answer still has a delivered report. Keep an assistant + // anchor so the next user message is a continuation, not a fresh prompt. + if ( + message.role === 'assistant' && + !content.length && + original.some((block) => block.type === 'tool_use' && block.name === HANDBACK_TOOL) + ) { + return [{ type: 'text', text: fallback }]; + } + return content; +} + +export function recordedReport(response: MessagesResponse | undefined): string | undefined { + if (!response) { + return undefined; + } + // With rows, only the deferred follow-up is the worker's final answer. + const text = + response.multi_followup ?? + response.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join(''); + return text || fallback; +} + +export function withHandback( + response: MessagesResponse, + report: string, + safeguards: unknown, +): MessagesResponse { + // This call delivers the gateway's own reply; it is not a replay of a native tool event. + const block = { + type: 'tool_use' as const, + id: `toolu_multi_handback_${randomUUID().replaceAll('-', '')}`, + name: HANDBACK_TOOL, + input: { message: report || fallback }, + }; + const content = [...response.content, block]; + const existing = response.safeguard_results ?? safeguardResults(safeguards, content, 'native'); + const safeguard_results = existing + ? ([ + { + type: 'dangerous_tool_use' as const, + status: { + type: 'available' as const, + tool_uses: { + ...existing[0].status.tool_uses, + [block.id]: { type: 'evaluated' as const, outcome: 'not_flagged' as const }, + }, + }, + }, + ] as const) + : undefined; + return { + ...response, + content, + stop_reason: 'tool_use', + ...(safeguard_results + ? { safeguard_results: [...safeguard_results] as MessagesResponse['safeguard_results'] } + : {}), + }; +} + +/** Append the delivery block after streamed text, before the terminal event. */ +export function emitHandback(emit: Emit, response: MessagesResponse): void { + const block = response.content.at(-1); + if (block?.type !== 'tool_use' || block.name !== HANDBACK_TOOL) { + return; + } + const index = response.content.length - 1; + emit('content_block_start', { index, content_block: { ...block, input: {} } }); + emit('content_block_delta', { + index, + delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) }, + }); + emit('content_block_stop', { index }); +} diff --git a/plugins/multi-core/src/gateway/harness-notices.ts b/plugins/multi-core/src/gateway/harness-notices.ts index 852791f..ca545cd 100644 --- a/plugins/multi-core/src/gateway/harness-notices.ts +++ b/plugins/multi-core/src/gateway/harness-notices.ts @@ -98,6 +98,8 @@ function historyContent(response: MessagesResponse): ContentBlock[] { ); if (response.multi_followup !== undefined) { content.push({ type: 'text', text: response.multi_followup || followUpFallback }); + } else if (!content.length) { + content.push({ type: 'text', text: followUpFallback }); } return content; } diff --git a/plugins/multi-core/src/gateway/server.ts b/plugins/multi-core/src/gateway/server.ts index b66f7e8..f608914 100644 --- a/plugins/multi-core/src/gateway/server.ts +++ b/plugins/multi-core/src/gateway/server.ts @@ -29,6 +29,14 @@ import { withoutDisplayTools, } from './display-rows.ts'; import type { GatewayFetch } from './fetch.ts'; +import { + emitHandback, + handbackOffered, + handbackRequestKind, + recordedReport, + withHandback, + withoutHarnessHandback, +} from './harness-handback.ts'; import type { NativeObservation } from './harness-progress.ts'; import type { Emit, MessagesRequest, MessagesResponse, StopReason } from './messages.ts'; import { ModBridge } from './mod-bridge.ts'; @@ -441,12 +449,14 @@ export function createNativeGateway({ ? displayRows.issue(scope, observation.row) : undefined; }; - const result = await bridge + const deliverHandback = handbackOffered(exchange.parsed as MessagesRequest); + const streamed = harnessStreamEmitter(emit, deliverHandback); + const nativeResult = await bridge .handle( body, scope, signal, - body.stream ? emit : undefined, + body.stream ? streamed : undefined, exchange.permissionContext, observe, ) @@ -460,6 +470,7 @@ export function createNativeGateway({ ); throw error; }); + const result = finishHarnessDelivery(nativeResult, deliverHandback, body, emit); permissionModes?.finishModCompaction( identity.session, agentId, @@ -492,6 +503,7 @@ export function createNativeGateway({ exchange: ProviderRequest, ids: readonly string[], external: string | null, + deliverHandback: boolean, ) { const { body, emit } = exchange; const followUp = await pendingFollowUp(exchange, ids, providerRoute(external)); @@ -500,7 +512,7 @@ export function createNativeGateway({ // The reply's context carries over, so the window's fill does not read as empty // after a turn with rows; the reply already reported its output and its spend. const context = followUp.usage; - const result: MessagesResponse = { + const plain: MessagesResponse = { id: `msg_${randomUUID()}`, type: 'message', role: 'assistant', @@ -510,7 +522,9 @@ export function createNativeGateway({ stop_sequence: null, usage: context ? { ...context, output_tokens: 0 } : { input_tokens: 0, output_tokens: 0 }, }; - const safeguard_results = safeguardResults(body.safeguards, result.content, 'native'); + const result = deliverHandback ? withHandback(plain, text, body.safeguards) : plain; + const safeguard_results = + result.safeguard_results ?? safeguardResults(body.safeguards, result.content, 'native'); if (safeguard_results) { result.safeguard_results = safeguard_results; } @@ -520,18 +534,79 @@ export function createNativeGateway({ emit('content_block_start', { index: 0, content_block: { type: 'text', text: '' } }); emit('content_block_delta', { index: 0, delta: { type: 'text_delta', text } }); emit('content_block_stop', { index: 0 }); - emit('message_delta', { - delta: { - stop_reason: 'end_turn', - stop_sequence: null, - ...(safeguard_results === undefined ? {} : { safeguard_results }), - }, - usage: result.usage, + if (deliverHandback) { + emitHandback(emit, result); + } + emitTerminal(emit, result); + } + sendResult(exchange, result); + } + async function answerHandbackOnly(exchange: ProviderRequest, external: string | null) { + const harness = harnessFor(providerRoute(external)); + const recorded = await harness?.recordedResponse?.( + harnessScope(exchange), + followUpContext(exchange), + ); + const report = recordedReport(recorded); + if (report === undefined) { + throw new BadRequest('No recorded harness report is available for SubagentHandback'); + } + const plain: MessagesResponse = { + id: `msg_${randomUUID()}`, + type: 'message', + role: 'assistant', + model: exchange.body.model ?? '', + content: [{ type: 'text', text: report }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: recorded?.usage ?? { input_tokens: 0, output_tokens: 0 }, + }; + const result = withHandback(plain, report, exchange.body.safeguards); + if (exchange.body.stream) { + exchange.startStream(); + exchange.emit('message_start', { message: { ...result, content: [], stop_reason: null } }); + exchange.emit('content_block_start', { index: 0, content_block: { type: 'text', text: '' } }); + exchange.emit('content_block_delta', { + index: 0, + delta: { type: 'text_delta', text: report }, }); - emit('message_stop', {}); + exchange.emit('content_block_stop', { index: 0 }); + emitHandback(exchange.emit, result); + emitTerminal(exchange.emit, result); } sendResult(exchange, result); } + async function answerHandbackRequest( + exchange: ProviderRequest, + kind: ReturnType, + deliverHandback: boolean, + external: string | null, + ): Promise { + if (!kind || !harnessProvider(external)) { + return false; + } + if (!deliverHandback) { + throw new BadRequest('SubagentHandback is required for this harness continuation'); + } + await answerHandbackOnly(exchange, external); + return true; + } + async function dispatchGatewayMessage( + exchange: ProviderRequest, + followUp: string[] | undefined, + external: string | null, + deliverHandback: boolean, + handbackKind: ReturnType, + ): Promise { + if (await answerHandbackRequest(exchange, handbackKind, deliverHandback, external)) { + return; + } + if (followUp) { + await answerFollowUp(exchange, followUp, external, deliverHandback); + return; + } + await dispatch(exchange, exchange.identity, external); + } /** * The deferred answer the rows' own reply holds, for the scope and provider that * wrote them only: from this gateway, or after a restart or an eviction from the @@ -1005,7 +1080,10 @@ export function createNativeGateway({ if (url.pathname.startsWith('/multi/mod/')) { return handleMod(req, res, url, parsed); } - const { body, raw, followUp } = displayRowsRemoved(url, request); + const { body, raw, followUp, deliverHandback, handbackKind } = displayRowsRemoved( + url, + request, + ); const external = externalModel(body.model); assertProviderEnabled(external, enabledProviders); const signal = providerSignal(abort.signal, external, timeoutMs); @@ -1038,10 +1116,7 @@ export function createNativeGateway({ if (url.pathname === '/multi/permission') { return sendPermissionDecision(exchange); } - if (followUp) { - return await answerFollowUp(exchange, followUp, external); - } - await dispatch(exchange, metadata, external); + await dispatchGatewayMessage(exchange, followUp, external, deliverHandback, handbackKind); } catch (error) { abort.abort(); failResponse(res, emit, error, replies.jsonStarted, sourceModel); @@ -1184,12 +1259,69 @@ function displayRowsRemoved( request: { raw: Buffer; parsed: Record; body: MessagesRequest }, ) { if (isApprovalRequest(request.parsed)) { - return { body: request.body, raw: request.raw, followUp: undefined }; + return { + body: request.body, + raw: request.raw, + followUp: undefined, + deliverHandback: false, + handbackKind: undefined, + }; } + const harness = harnessProvider(externalModel(request.body.model)); + const deliverHandback = Boolean(harness && handbackOffered(request.body)); + const handbackKind = harness ? handbackRequestKind(request.body) : undefined; const followUp = url.pathname === '/v1/messages' ? displayFollowUp(request.body) : undefined; - const body = withoutDisplayTools(request.body); + const withoutRows = withoutDisplayTools(request.body); + const body = harness ? withoutHarnessHandback(withoutRows) : withoutRows; const raw = body === request.body ? request.raw : Buffer.from(JSON.stringify(body)); - return { body, raw, followUp }; + return { body, raw, followUp, deliverHandback, handbackKind }; +} + +function emitTerminal(emit: Emit, response: MessagesResponse): void { + emit('message_delta', { + delta: { + stop_reason: response.stop_reason, + stop_sequence: response.stop_sequence, + ...(response.safeguard_results === undefined + ? {} + : { safeguard_results: response.safeguard_results }), + }, + usage: response.usage, + }); + emit('message_stop', {}); +} + +function harnessStreamEmitter(emit: Emit, deliverHandback: boolean): Emit { + return (type, value) => { + // The native reply is durable before its terminal event. Hold that event + // until the gateway has appended Claude's delivery call to the final message. + if (deliverHandback && (type === 'message_delta' || type === 'message_stop')) { + return; + } + emit(type, value); + }; +} + +function finishHarnessDelivery( + nativeResult: MessagesResponse, + deliverHandback: boolean, + body: MessagesRequest, + emit: Emit, +): MessagesResponse { + if (!deliverHandback) { + return nativeResult; + } + const result = + nativeResult.multi_followup === undefined + ? withHandback(nativeResult, recordedReport(nativeResult) ?? '', body.safeguards) + : nativeResult; + if (body.stream) { + if (result !== nativeResult) { + emitHandback(emit, result); + } + emitTerminal(emit, result); + } + return result; } function providerSignal(disconnected: AbortSignal, model: string | null, timeoutMs?: number) { diff --git a/test/unit/native-harness-handback.test.ts b/test/unit/native-harness-handback.test.ts new file mode 100644 index 0000000..e6ac1a6 --- /dev/null +++ b/test/unit/native-harness-handback.test.ts @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + withHandback, + withoutHarnessHandback, +} from '../../plugins/multi-core/src/gateway/harness-handback.ts'; +import { + continuation, + harnessHistoryHash, + historyRewound, +} from '../../plugins/multi-core/src/gateway/harness-notices.ts'; +import { HarnessResponse } from '../../plugins/multi-core/src/gateway/harness-response.ts'; +import type { + MessagesRequest, + MessagesResponse, +} from '../../plugins/multi-core/src/gateway/messages.ts'; +import type { NativeHarness } from '../../plugins/multi-core/src/gateway/native-harness.ts'; +import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; + +const model = 'multi/cursor/test'; +const handback = { name: 'SubagentHandback', input_schema: { type: 'object' } }; +const safeguards = [ + { type: 'dangerous_tool_use', classifier_context: { permission_mode: 'auto' } }, +]; + +async function gateway(t: test.TestContext, rows: boolean) { + let runs = 0; + let saved: MessagesResponse | undefined; + const inputs: MessagesRequest[] = []; + const harness: NativeHarness = { + validate: () => 1, + async handle(body, _scope, _signal, emit) { + inputs.push(body); + runs++; + const response = new HarnessResponse(model, 1, emit ?? (() => {}), body.safeguards); + response.text(`[Cursor] notice\nreport ${runs}`); + if (rows) { + response.displayRow({ + type: 'tool_use', + id: 'toolu_multi_12345678901234567890123456789012', + name: 'mcp__multi-core__run_command', + input: {}, + }); + response.text(`\nfinal ${runs}`); + } + saved = response.finish(undefined); + for (const [name, value] of response.takeTerminalEvents()) { + emit?.(name, value); + } + return saved; + }, + async recordedResponse() { + return saved; + }, + }; + const server = createNativeGateway({ + token: 'handback-test', + authFile: 'unused', + cursor: harness, + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.closeAllConnections(); + return new Promise((resolve) => server.close(() => resolve())); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const send = (messages: MessagesRequest['messages'], offered = true, stream = false) => + fetch(`http://127.0.0.1:${address.port}/v1/messages`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-multi-gateway-token': 'handback-test', + 'x-claude-code-session-id': 'handback-session', + 'x-claude-code-agent-id': 'worker', + }, + body: JSON.stringify({ + model, + messages, + tools: offered ? [handback] : [], + stream, + safeguards, + }), + }); + return { send, inputs, runs: () => runs, saved: () => saved }; +} + +function delivery(response: MessagesResponse) { + const block = response.content.at(-1); + assert.equal(block?.type, 'tool_use'); + assert.equal(block.name, 'SubagentHandback'); + assert.equal(response.stop_reason, 'tool_use'); + assert.deepEqual(response.safeguard_results?.[0].status.tool_uses[block.id], { + type: 'evaluated', + outcome: 'not_flagged', + }); + return block; +} + +function streamEvents(raw: string) { + return raw + .split('\n\n') + .filter((part) => part.startsWith('event: ')) + .map((part) => JSON.parse(part.slice(part.indexOf('data: ') + 6)) as Record); +} + +test('a harness answer without rows sends its full report through handback in JSON and SSE', async (t) => { + const source = await gateway(t, false); + const first = (await ( + await source.send([{ role: 'user', content: 'work' }]) + ).json()) as MessagesResponse; + const block = delivery(first); + assert.deepEqual(block.input, { message: '[Cursor] notice\nreport 1' }); + assert.deepEqual(source.saved()?.content, [{ type: 'text', text: '[Cursor] notice\nreport 1' }]); + assert.equal( + source.inputs[0]?.tools?.length, + 0, + 'the native harness never sees the delivery tool', + ); + + const streamed = await source.send([{ role: 'user', content: 'stream' }], true, true); + const events = streamEvents(await streamed.text()); + assert.equal(events.filter((event) => event.type === 'message_delta').length, 1); + const terminal = events.find((event) => event.type === 'message_delta'); + assert.ok(terminal); + assert.equal((terminal.delta as { stop_reason: string }).stop_reason, 'tool_use'); + assert.equal(events.filter((event) => event.type === 'content_block_start').length, 2); + const inputDelta = events.find( + (event) => + event.type === 'content_block_delta' && + (event.delta as { type?: string }).type === 'input_json_delta', + ); + assert.ok(inputDelta); + assert.deepEqual(JSON.parse((inputDelta.delta as { partial_json: string }).partial_json), { + message: '[Cursor] notice\nreport 2', + }); +}); + +test('rows defer the delivery to the follow-up, including streamed follow-ups', async (t) => { + const source = await gateway(t, true); + const first = (await ( + await source.send([{ role: 'user', content: 'work' }]) + ).json()) as MessagesResponse; + assert.equal(first.stop_reason, 'end_turn'); + assert.equal( + first.content.some((block) => block.type === 'tool_use' && block.name === 'SubagentHandback'), + false, + ); + const row = first.content.find((block) => block.type === 'tool_use'); + assert.ok(row?.type === 'tool_use'); + const messages = [ + { role: 'user', content: 'work' }, + { role: 'assistant', content: first.content }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: row.id, content: 'done' }] }, + ]; + const followed = (await (await source.send(messages)).json()) as MessagesResponse; + assert.equal((delivery(followed).input as { message: string }).message, '\nfinal 1'); + const events = streamEvents(await (await source.send(messages, true, true)).text()); + const terminal = events.find((event) => event.type === 'message_delta'); + assert.ok(terminal); + assert.equal((terminal.delta as { stop_reason: string }).stop_reason, 'tool_use'); + assert.equal(events.filter((event) => event.type === 'content_block_start').length, 2); + assert.equal(source.runs(), 1); + + const recorded = source.saved(); + const handbackBlock = delivery(followed); + await source.send([ + ...messages, + { role: 'assistant', content: followed.content }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: handbackBlock.id, content: 'delivered' }], + }, + { role: 'user', content: 'next task' }, + ]); + const resumed = source.inputs.at(-1); + assert.ok(resumed); + assert.equal( + historyRewound({ response: recorded }, resumed.messages ?? [], harnessHistoryHash), + false, + ); + assert.deepEqual(continuation(resumed, 'Cursor'), [ + { role: 'user', content: [{ type: 'text', text: 'next task' }] }, + ]); + assert.equal(source.runs(), 2); +}); + +test('handback result and enforcement bounce are answered locally; resume strips delivery history', async (t) => { + const source = await gateway(t, false); + const first = (await ( + await source.send([{ role: 'user', content: 'work' }]) + ).json()) as MessagesResponse; + const block = delivery(first); + const history = [ + { role: 'user', content: 'work' }, + { role: 'assistant', content: first.content }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: block.id, content: 'delivered' }], + }, + ]; + const resultOnly = (await (await source.send(history)).json()) as MessagesResponse; + assert.equal( + (delivery(resultOnly).input as { message: string }).message, + '[Cursor] notice\nreport 1', + ); + const bounced = (await ( + await source.send([ + { role: 'user', content: 'work' }, + { role: 'user', content: '[handback-send-enforce] Your report has not been delivered.' }, + ]) + ).json()) as MessagesResponse; + assert.equal( + (delivery(bounced).input as { message: string }).message, + '[Cursor] notice\nreport 1', + ); + assert.equal(source.runs(), 1); + + const recorded = source.saved(); + await source.send([...history, { role: 'user', content: 'next task' }]); + const resumed = source.inputs.at(-1); + assert.ok(resumed); + assert.equal( + historyRewound({ response: recorded }, resumed.messages ?? [], harnessHistoryHash), + false, + ); + assert.deepEqual(continuation(resumed, 'Cursor'), [ + { role: 'user', content: [{ type: 'text', text: 'next task' }] }, + ]); + assert.equal(JSON.stringify(resumed.messages).includes('SubagentHandback'), false); + assert.equal(JSON.stringify(resumed.messages).includes('tool_result'), false); + assert.equal(source.runs(), 2); +}); + +test('without an offered handback tool, the harness answer stays an end turn', async (t) => { + const source = await gateway(t, false); + const response = (await ( + await source.send([{ role: 'user', content: 'work' }], false) + ).json()) as MessagesResponse; + assert.equal(response.stop_reason, 'end_turn'); + assert.deepEqual(response.content, [{ type: 'text', text: '[Cursor] notice\nreport 1' }]); +}); + +test('an enforcement bounce with no recorded answer fails before native dispatch', async (t) => { + const source = await gateway(t, false); + const reply = await source.send([ + { role: 'user', content: '[handback-send-enforce] Call SubagentHandback now.' }, + ]); + assert.equal(reply.status, 400); + assert.match(await reply.text(), /No recorded harness report/); + assert.equal(source.runs(), 0); +}); + +test('an empty native answer gets a report and remains a resumable assistant turn', () => { + const recorded: MessagesResponse = { + id: 'msg_empty', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 0 }, + }; + const delivered = withHandback(recorded, '', safeguards); + const block = delivery(delivered); + assert.deepEqual(block.input, { message: 'Native run finished.' }); + const next = withoutHarnessHandback({ + model, + tools: [handback], + messages: [ + { role: 'user', content: 'work' }, + { role: 'assistant', content: delivered.content }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: block.id, content: 'done' }] }, + { role: 'user', content: 'next' }, + ], + }); + assert.equal( + historyRewound({ response: recorded }, next.messages ?? [], harnessHistoryHash), + false, + ); + assert.deepEqual(continuation(next, 'Cursor'), [ + { role: 'user', content: [{ type: 'text', text: 'next' }] }, + ]); +}); From 72f191c018290a869f1898481204fdafb81e0ddf Mon Sep 17 00:00:00 2001 From: greenpolo Date: Sat, 3 Oct 2026 21:50:54 -0400 Subject: [PATCH 34/36] Leave the handback report to auto mode's own review The gateway did not review the report, so it reports the verdict as unavailable rather than not_flagged; Claude Code's classifier decides. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdKRYTowK4F5p9JdLb6Ac6 --- plugins/multi-core/src/gateway/harness-handback.ts | 3 ++- test/unit/native-harness-handback.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/multi-core/src/gateway/harness-handback.ts b/plugins/multi-core/src/gateway/harness-handback.ts index 59df6ab..9209678 100644 --- a/plugins/multi-core/src/gateway/harness-handback.ts +++ b/plugins/multi-core/src/gateway/harness-handback.ts @@ -176,7 +176,8 @@ export function withHandback( type: 'available' as const, tool_uses: { ...existing[0].status.tool_uses, - [block.id]: { type: 'evaluated' as const, outcome: 'not_flagged' as const }, + // Nothing reviewed the report, so auto mode's own classifier does. + [block.id]: { type: 'unavailable' as const, reason: 'error' as const }, }, }, }, diff --git a/test/unit/native-harness-handback.test.ts b/test/unit/native-harness-handback.test.ts index e6ac1a6..2b04260 100644 --- a/test/unit/native-harness-handback.test.ts +++ b/test/unit/native-harness-handback.test.ts @@ -91,8 +91,8 @@ function delivery(response: MessagesResponse) { assert.equal(block.name, 'SubagentHandback'); assert.equal(response.stop_reason, 'tool_use'); assert.deepEqual(response.safeguard_results?.[0].status.tool_uses[block.id], { - type: 'evaluated', - outcome: 'not_flagged', + type: 'unavailable', + reason: 'error', }); return block; } From fdc8bd236e1faa8c7fc069ed0656010a538972a2 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Mon, 5 Oct 2026 00:06:06 -0400 Subject: [PATCH 35/36] Fix CI regressions and run GPT-6.1 Sol on OpenAI - An unreadable classifier request (Claude 2.1.283 omits the severity tag) went to Anthropic even while an OpenAI worker's action awaited review; it fails closed again in that case. - The Grok routing fixture ended with stopReason 'stop'; real Grok ends with 'end_turn', which the fail-closed check now requires. - The Grok [1m] wire drives a session of its own and follows the mod-key protocol, since the Claude session's key now guards mod writes. - The Windows install test passes a settings path, not quoted JSON, to its .cmd fake Claude, which the launcher now correctly refuses. - The e2e rejecting proxy ignores client resets of refused tunnels, which crashed local runs. - Codex now serves gpt-6.1-sol on ChatGPT plans, so the OpenAI Sol row runs it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdKRYTowK4F5p9JdLb6Ac6 --- CHANGELOG.md | 6 ++ docs/openai.md | 2 +- plugins/multi-core/src/gateway/server.ts | 31 +++++++--- plugins/multi-openai/src/models.ts | 2 +- test/e2e/fixtures/usage-receipts/wire.mjs | 70 +++++++++++++++++------ test/e2e/harness.ts | 2 + test/e2e/model-routing-native.test.ts | 2 +- test/unit/native-approval.test.ts | 6 ++ test/unit/native-gateway.test.ts | 4 +- test/unit/native-install.test.ts | 5 +- 10 files changed, 98 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c4221..4bc90f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and ## Unreleased +- **Run GPT-6.1 Sol on OpenAI.** Codex now serves `gpt-6.1-sol` on ChatGPT + plans, so the OpenAI picker row and `model: gpt-6.1-sol` on a `multi-openai` + worker run it in place of the previous-generation `gpt-6-sol`. The default + OpenAI model stays `gpt-6-astra`. - **Deliver native worker reports in Auto mode.** Cursor, Antigravity, and Grok workers now send their final report through Claude Code's `SubagentHandback` tool when it is offered. Display rows keep their follow-up answer, and resumed @@ -40,6 +44,8 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and returned "Missing or ambiguous pending review action" once the session had run a provider. It now goes to Anthropic unless a provider-owned action positively matches; a request routed to provider review keeps the session mismatch check. + A classifier request the gateway cannot read still fails closed while a + provider action in that session awaits review. - **Fix provider workers in Claude sessions.** A custom agent pinned to a `multi/openai` or `multi/zen` model, or a worker inheriting a provider model, was treated as a native Claude subagent, skipped gateway registration, and had diff --git a/docs/openai.md b/docs/openai.md index 3d16b77..50d6591 100644 --- a/docs/openai.md +++ b/docs/openai.md @@ -18,7 +18,7 @@ Use `/model multi/openai/` or choose a row in `/model`. Use `/effort < | Picker row | Model | | --- | --- | | OpenAI · `gpt-6-astra` | `gpt-6-astra` | -| OpenAI · `gpt-6-sol` | `gpt-6-sol` | +| OpenAI · `gpt-6.1-sol` | `gpt-6.1-sol` | | OpenAI · `gpt-5.6-terra` | `gpt-5.6-terra` | | OpenAI · `gpt-6-luna` | `gpt-6-luna` | diff --git a/plugins/multi-core/src/gateway/server.ts b/plugins/multi-core/src/gateway/server.ts index f608914..68f9e4d 100644 --- a/plugins/multi-core/src/gateway/server.ts +++ b/plugins/multi-core/src/gateway/server.ts @@ -364,6 +364,12 @@ export function createNativeGateway({ (name !== 'Bash' || matchesBashAction(candidate, action[name])), ); } + function providerActionPending(sourceSession: string) { + return [...reviewCandidates.values()].some( + (candidate) => + candidate.tool.session === sourceSession && providerOwnedReview(candidate.context.model), + ); + } function pendingReview(parsed: Record, sourceSession: string) { const candidates = reviewCandidatesFor(parsed, sourceSession); if (candidates.length !== 1) { @@ -872,6 +878,22 @@ export function createNativeGateway({ } return dispatchReview(exchange, metadata, null); } + /** The matching review candidates, or undefined when the request is Anthropic's own. */ + function readableCandidates(parsed: Record, sourceSession: string) { + try { + return reviewCandidatesFor(parsed, sourceSession); + } catch (error) { + // An action the gateway cannot read cannot be matched, so it may be the provider + // action waiting for review: it passes to Anthropic only when none is waiting. + if (blockAnthropic) { + throw error; + } + if (providerActionPending(sourceSession)) { + throw new BadRequest('Unreadable review request while a provider action awaits review'); + } + return undefined; + } + } async function dispatchNativeClassification( exchange: ProviderRequest, metadata: ReturnType, @@ -879,13 +901,8 @@ export function createNativeGateway({ if (!guardAuto) { return classifyUnobserved(exchange, metadata); } - let candidates: ReturnType; - try { - candidates = reviewCandidatesFor(exchange.parsed, metadata.session); - } catch (error) { - if (blockAnthropic) { - throw error; - } + const candidates = readableCandidates(exchange.parsed, metadata.session); + if (!candidates) { return handleAnthropic(exchange); } if (!candidates.some(({ context }) => providerOwnedReview(context.model))) { diff --git a/plugins/multi-openai/src/models.ts b/plugins/multi-openai/src/models.ts index c45df5e..d24658b 100644 --- a/plugins/multi-openai/src/models.ts +++ b/plugins/multi-openai/src/models.ts @@ -2,7 +2,7 @@ import type { Effort } from '../../multi-core/src/gateway/responses.ts'; export const MODELS = { 'openai-native': 'gpt-6-astra', - 'openai-sol': 'gpt-6-sol', + 'openai-sol': 'gpt-6.1-sol', 'openai-terra': 'gpt-5.6-terra', 'openai-luna': 'gpt-6-luna', }; diff --git a/test/e2e/fixtures/usage-receipts/wire.mjs b/test/e2e/fixtures/usage-receipts/wire.mjs index eee8d2a..5ec2b99 100644 --- a/test/e2e/fixtures/usage-receipts/wire.mjs +++ b/test/e2e/fixtures/usage-receipts/wire.mjs @@ -1,20 +1,28 @@ +import { randomUUID } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import { setTimeout } from 'node:timers/promises'; const [sessionId, kind, countText = '1'] = process.argv.slice(2); const base = process.env.MULTI_MOD_GATEWAY_URL; const token = process.env.MULTI_GATEWAY_TOKEN; -async function request(route, body, agentId, allowError = false) { +// The gateway issues a session's mod key on its first mod request; later changes present it. +const modKeys = new Map(); +async function request(route, body, agentId, allowError = false, session = sessionId) { const response = await fetch(new URL(route, base), { method: body ? 'POST' : 'GET', headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token, - 'x-claude-code-session-id': sessionId, + 'x-claude-code-session-id': session, ...(agentId ? { 'x-claude-code-agent-id': agentId } : {}), + ...(modKeys.has(session) ? { 'x-multi-mod-key': modKeys.get(session) } : {}), }, body: body ? JSON.stringify(body) : undefined, }); + const issued = response.headers.get('x-multi-mod-key'); + if (issued) { + modKeys.set(session, issued); + } const text = await response.text(); if (!response.ok && !allowError) { throw new Error(`${route}: ${response.status} ${text}`); @@ -29,29 +37,53 @@ if (kind === 'quota') { ); } else if (kind === 'grok') { // Admit settings without executing a setup worker, so model validation is the - // refusal being exercised rather than missing native permission context. - const mode = await request(`/multi/mod/mode${query}`); - let policy = await request('/multi/mod/policy', { - sessionId, - cwd: process.cwd(), - sourceGeneration: mode.generation, - }); + // refusal being exercised rather than missing native permission context. The mod + // holds this Claude session's key, so the wire drives a session of its own. + const session = randomUUID(); + const own = `?sessionId=${encodeURIComponent(session)}`; + await request( + '/multi/mod/session', + { sessionId: session, cwd: process.cwd(), model: 'multi/grok/e2e[1m]', event: 'start' }, + undefined, + false, + session, + ); + const mode = await request(`/multi/mod/mode${own}`, undefined, undefined, false, session); + let policy = await request( + '/multi/mod/policy', + { sessionId: session, cwd: process.cwd(), sourceGeneration: mode.generation }, + undefined, + false, + session, + ); const deadline = Date.now() + 10000; while (policy.status === 'pending' && Date.now() < deadline) { await setTimeout(25); - policy = await request('/multi/mod/policy', { sessionId, generation: policy.generation }); + policy = await request( + '/multi/mod/policy', + { sessionId: session, generation: policy.generation }, + undefined, + false, + session, + ); } if (policy.status !== 'ready') { throw new Error('Native policy did not become ready'); } - await request('/multi/mod/session', { - sessionId, - cwd: process.cwd(), - permissionMode: 'bypassPermissions', - model: 'multi/grok/e2e[1m]', - generation: mode.generation, - policyGeneration: policy.generation, - }); + await request( + '/multi/mod/session', + { + sessionId: session, + cwd: process.cwd(), + permissionMode: 'bypassPermissions', + model: 'multi/grok/e2e[1m]', + generation: mode.generation, + policyGeneration: policy.generation, + }, + undefined, + false, + session, + ); const body = { model: 'multi/grok/e2e[1m]', max_tokens: 64, @@ -59,7 +91,7 @@ if (kind === 'quota') { }; const replies = []; for (let attempt = 0; attempt < 2; attempt++) { - const reply = await request('/v1/messages', body, undefined, true); + const reply = await request('/v1/messages', body, undefined, true, session); replies.push(reply); console.log(JSON.stringify(reply)); } diff --git a/test/e2e/harness.ts b/test/e2e/harness.ts index ad193cf..81f2cb3 100644 --- a/test/e2e/harness.ts +++ b/test/e2e/harness.ts @@ -21,6 +21,8 @@ async function rejectingProxy(t: TestContext) { }); server.on('connect', (req, socket) => { connections.push(req.url ?? ''); + // A client that resets a refused tunnel must not fail the test process. + socket.on('error', () => {}); socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); diff --git a/test/e2e/model-routing-native.test.ts b/test/e2e/model-routing-native.test.ts index b56a729..4625cea 100644 --- a/test/e2e/model-routing-native.test.ts +++ b/test/e2e/model-routing-native.test.ts @@ -23,7 +23,7 @@ const grok: NativeScript = (request) => ({ { type: 'end', sessionId: request.args[request.args.indexOf('--session-id') + 1], - stopReason: 'stop', + stopReason: 'end_turn', }, ] .map((event) => JSON.stringify(event)) diff --git a/test/unit/native-approval.test.ts b/test/unit/native-approval.test.ts index d70c9fb..bb8a3b3 100644 --- a/test/unit/native-approval.test.ts +++ b/test/unit/native-approval.test.ts @@ -739,6 +739,12 @@ test('mixed-provider review rejects missing, cross-session, ambiguous, and unava const guard = await gateway.prepare(gpt, 'node unavailable.js'); assert.deepEqual(await guard.json(), {}); assert.equal((await gateway.classify('node unavailable.js')).status, 400); + // A classifier format the gateway cannot read may be that GPT action, so it fails closed. + const unreadable = request(1, JSON.stringify({ session_id: 'mixed' }), 'node unavailable.js'); + const instruction = unreadable.messages[0].content.at(-1); + assert(instruction); + instruction.text = 'A native classifier format unknown to the gateway.'; + assert.equal((await gateway.send(unreadable)).status, 400); // No observed provider action matches: Claude's own classifier, answered by Anthropic. assert.equal((await gateway.classify('node missing.js')).status, 200); assert.deepEqual(gateway.nativeReviews.length, 1); diff --git a/test/unit/native-gateway.test.ts b/test/unit/native-gateway.test.ts index 014193f..0879e27 100644 --- a/test/unit/native-gateway.test.ts +++ b/test/unit/native-gateway.test.ts @@ -624,7 +624,7 @@ test('OpenAI main and worker requests adapt instructions without losing runtime seen.push(JSON.parse(String(options.body))); return new Response(sse(textEvents)); }); - for (const name of ['gpt-6-astra', 'gpt-6-sol', 'gpt-5.6-terra', 'gpt-6-luna']) { + for (const name of ['gpt-6-astra', 'gpt-6.1-sol', 'gpt-5.6-terra', 'gpt-6-luna']) { await (await call({ ...payload, model: `multi/openai/${name}` })).text(); } await (await call(payload, { 'x-claude-code-agent-id': 'worker-a' })).text(); @@ -753,7 +753,7 @@ test('all registered model and reasoning choices reach OpenAI without substituti return new Response(sse(textEvents)); }); assert.equal(OPENAI_WORKER_EFFORT, 'medium'); - for (const slug of ['gpt-6-astra', 'gpt-6-sol', 'gpt-5.6-terra', 'gpt-6-luna']) { + for (const slug of ['gpt-6-astra', 'gpt-6.1-sol', 'gpt-5.6-terra', 'gpt-6-luna']) { assert(Object.values(MODELS).includes(slug), slug); for (const effort of ['low', 'medium', 'high', 'xhigh', 'max']) { const response = await call( diff --git a/test/unit/native-install.test.ts b/test/unit/native-install.test.ts index 801325d..6ec73c9 100644 --- a/test/unit/native-install.test.ts +++ b/test/unit/native-install.test.ts @@ -179,7 +179,10 @@ test('wrapper follows installed core updates and enables only selected providers await f.install(); const old = await core(f.directory, 'core-v1'); await writeFile(f.listing, JSON.stringify(plugins(old))); - const args = ['--settings', '{"model":"sonnet"}', '--', 'literal $() and spaces']; + // The Windows fake Claude is a .cmd file, and cmd.exe cannot carry a `"` to it: the + // launcher refuses that argument, so this passthrough uses a settings file path. + const settings = f.windows ? 'C:\\settings dir\\multi.json' : '{"model":"sonnet"}'; + const args = ['--settings', settings, '--', 'literal $() and spaces']; const first = JSON.parse((await f.invoke('claude-multi', args)).stdout); assert.deepEqual(first.args, args); assert.equal(first.providers, 'zen'); From 6ee7ba3a1a3b5bb8e71b913ff4b08e32f150c504 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Mon, 5 Oct 2026 00:15:37 -0400 Subject: [PATCH 36/36] Run npm shims for native binaries directly on Windows npm links Claude Code's bin/claude.exe through claude.cmd. The shim resolver only unwrapped .js targets, so the gateway's plugin-permission probe ran it through cmd.exe and the metacharacter refusal rejected its inline --settings JSON: every harness worker failed on Windows with an npm-installed Claude. An .exe shim target now runs directly. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdKRYTowK4F5p9JdLb6Ac6 --- CHANGELOG.md | 4 +++- plugins/multi-core/src/gateway/executable.ts | 7 +++++- test/unit/native-executable.test.ts | 23 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc90f7..a655d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,7 +119,9 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and and joins text blocks with newlines, and OpenAI replies that end on a stop sequence record their billed usage. - **Harden Windows process handling and state files.** Arguments containing - cmd.exe metacharacters are refused for non-shim `.cmd`/`.bat` launchers, + cmd.exe metacharacters are refused for non-shim `.cmd`/`.bat` launchers, and + npm's shim for a native binary, such as an npm-installed Claude Code's + `claude.exe`, now runs that binary directly instead of through cmd.exe, cancellation asks the process tree to close before forcing it and never kills a reused PID, policy probes run `reg`, `powershell`, and `defaults` from system paths with a timeout, state locks survive hostname changes and recheck racing diff --git a/plugins/multi-core/src/gateway/executable.ts b/plugins/multi-core/src/gateway/executable.ts index 576708d..159d3b2 100644 --- a/plugins/multi-core/src/gateway/executable.ts +++ b/plugins/multi-core/src/gateway/executable.ts @@ -118,6 +118,11 @@ export function executableInvocation( return { command: executable, args: [...args], viaComSpec: false }; } const shimTarget = resolveNpmShimTarget(executable, shimOptions); + // npm links a package's native binary (Claude Code's bin/claude.exe) through the same + // shim, so that target runs directly instead of through cmd.exe. + if (shimTarget && /\.exe$/i.test(shimTarget)) { + return { command: shimTarget, args: [...args], viaComSpec: false }; + } if (shimTarget) { return { command: process.execPath, @@ -144,7 +149,7 @@ export function executableInvocation( } const MAX_SHIM_SIZE = 32 * 1024; -const SHIM_TARGET = /"%dp0%\\([^"\r\n]+\.(?:js|cjs|mjs))"[ \t]+%\*/gi; +const SHIM_TARGET = /"%dp0%\\([^"\r\n]+\.(?:js|cjs|mjs|exe))"[ \t]+%\*/gi; function resolveNpmShimTarget( executable: string, diff --git a/test/unit/native-executable.test.ts b/test/unit/native-executable.test.ts index a9478e3..bdfcf07 100644 --- a/test/unit/native-executable.test.ts +++ b/test/unit/native-executable.test.ts @@ -114,6 +114,29 @@ test('invokes a canonical npm cmd shim with Node directly', () => { assert.deepEqual(seen, [target]); }); +test('runs an npm cmd shim for a native binary directly', () => { + // npm's shim for Claude Code's bin/claude.exe: no Node program, the binary itself. + const shim = [ + '@ECHO off', + 'GOTO start', + ':find_dp0', + 'SET dp0=%~dp0', + 'EXIT /b', + ':start', + 'SETLOCAL', + 'CALL :find_dp0', + '"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*', + ].join('\r\n'); + const target = 'C:\\npm\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe'; + const options = { readShim: () => shim, exists: (filename: string) => filename === target }; + const settings = '{"permissions":{"allow":["Read"]}}'; + assert.deepEqual( + executableInvocation('C:\\npm\\claude.cmd', ['--settings', settings], 'win32', {}, options), + { command: target, args: ['--settings', settings], viaComSpec: false }, + ); + assert.equal(argvSafe('C:\\npm\\claude.cmd', settings, 'win32', options), true); +}); + test('resolves npm layouts in bat shims', () => { const target = 'C:\\tools\\node_modules\\claude\\cli.mjs'; assert.deepEqual(