amicus 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +66 -0
- package/README.md +13 -4
- package/electron/setup-ui-aliases.js +1 -1
- package/electron/setup-ui.js +27 -3
- package/package.json +2 -1
- package/skills/second-opinion/SKILL.md +2 -0
- package/skills/sidecar/SKILL.md +66 -38
- package/src/cli-handlers-resume-continue.js +31 -4
- package/src/cli-handlers-run.js +15 -4
- package/src/cli.js +17 -0
- package/src/mcp-server.js +99 -12
- package/src/mcp-tools.js +26 -4
- package/src/opencode-client.js +18 -2
- package/src/sidecar/continue.js +10 -3
- package/src/sidecar/fanout-leg.js +26 -1
- package/src/sidecar/fanout-output.js +5 -0
- package/src/sidecar/fanout-validate.js +81 -0
- package/src/sidecar/fanout.js +65 -77
- package/src/sidecar/models.js +39 -17
- package/src/sidecar/session-utils.js +6 -0
- package/src/sidecar/setup.js +2 -1
- package/src/utils/alias-resolver.js +6 -35
- package/src/utils/api-key-store.js +1 -9
- package/src/utils/auth-json.js +1 -1
- package/src/utils/config.js +98 -16
- package/src/utils/curated-models.js +99 -8
- package/src/utils/gateway-route-audit.js +103 -0
- package/src/utils/gateway-route-catalog.js +92 -0
- package/src/utils/gateway-router.js +131 -0
- package/src/utils/input-validators.js +12 -42
- package/src/utils/model-classification.js +65 -0
- package/src/utils/model-descriptor.js +72 -0
- package/src/utils/model-fetcher.js +46 -14
- package/src/utils/model-input-default.js +32 -0
- package/src/utils/model-validator.js +68 -84
- package/src/utils/provider-registry.js +57 -0
- package/src/utils/quick-picks.js +11 -3
- package/src/utils/result-schema-rebuild.js +98 -0
- package/src/utils/result-schema.js +15 -78
- package/src/utils/route-error.js +154 -0
- package/src/utils/route-launch.js +219 -0
- package/src/utils/start-helpers.js +96 -43
- package/src/utils/validators.js +1 -8
package/src/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
} = require('./utils/validators');
|
|
19
19
|
const { resolvePromptSource } = require('./utils/prompt-source');
|
|
20
20
|
const { logger } = require('./utils/logger');
|
|
21
|
+
const { GATEWAY_MODES } = require('./utils/model-descriptor');
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Default values per spec §4.1
|
|
@@ -137,6 +138,7 @@ function isBooleanFlag(key) {
|
|
|
137
138
|
'html', // council report: emit a self-contained HTML page
|
|
138
139
|
'md', // council report: emit Markdown (default)
|
|
139
140
|
'fix', // doctor: self-heal fixable checks in place (#56)
|
|
141
|
+
'strict', // models --check: exit non-zero on curated per-gateway drift (#gwid Task 6)
|
|
140
142
|
];
|
|
141
143
|
return booleanFlags.includes(key);
|
|
142
144
|
}
|
|
@@ -245,6 +247,15 @@ function validateStartArgs(args) {
|
|
|
245
247
|
}
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
// Validate --gateway (if provided) — #61 Task 7.1. resolveGatewayMode()
|
|
251
|
+
// already treats any non-'direct'/'openrouter' string as a pass-through
|
|
252
|
+
// (effectively silent auto fallback), so this pre-flight check exists
|
|
253
|
+
// purely to catch typos with a clear error instead of letting them slip
|
|
254
|
+
// through unnoticed.
|
|
255
|
+
if (args.gateway !== undefined && !GATEWAY_MODES.includes(args.gateway)) {
|
|
256
|
+
return { valid: false, error: `Error: --gateway must be one of: ${GATEWAY_MODES.join(', ')}` };
|
|
257
|
+
}
|
|
258
|
+
|
|
248
259
|
// Validate MCP spec format (if provided)
|
|
249
260
|
const mcpCheck = validateMcpSpec(args.mcp);
|
|
250
261
|
if (!mcpCheck.valid) {
|
|
@@ -401,6 +412,7 @@ Options for 'start':
|
|
|
401
412
|
--exclude-mcp <name> Exclude specific MCP server (repeatable)
|
|
402
413
|
--validate-model (Deprecated: validation is on by default)
|
|
403
414
|
--no-validate-model Skip model-catalog validation before launch
|
|
415
|
+
--gateway <mode> Routing: auto (direct-first), direct, or openrouter
|
|
404
416
|
--position <pos> Window position: right (default), left, center
|
|
405
417
|
`,
|
|
406
418
|
fanout: `
|
|
@@ -415,6 +427,7 @@ Options for 'fanout':
|
|
|
415
427
|
--json Emit the wave result as stable JSON on stdout
|
|
416
428
|
--max-cost <$> Refuse the wave if the estimated total exceeds $ (soft ceiling)
|
|
417
429
|
--no-cost-gate Disable the budget gate (per-$/Mtok threshold + ceiling) for this run
|
|
430
|
+
--gateway <mode> Routing: auto (direct-first), direct, or openrouter
|
|
418
431
|
Shared per-leg knobs: --agent, --thinking, --timeout, --summary-length,
|
|
419
432
|
--no-context, --context-*, --mcp*, --no-validate-model, --cwd
|
|
420
433
|
Exit codes: 0 all legs complete, 2 partial, 1 none complete / hard failure
|
|
@@ -424,6 +437,9 @@ Options for 'models':
|
|
|
424
437
|
--search <q> Filter by substring over model id and name
|
|
425
438
|
--refresh Force-refresh the catalog from provider APIs
|
|
426
439
|
--check Audit aliases against the catalog (exit = stale count)
|
|
440
|
+
--strict With --check: also exit non-zero on curated
|
|
441
|
+
per-gateway drift (stale/divergent direct or
|
|
442
|
+
openrouter forms). Informational without it.
|
|
427
443
|
--json Machine-readable output
|
|
428
444
|
`,
|
|
429
445
|
list: `
|
|
@@ -456,6 +472,7 @@ Options for 'continue':
|
|
|
456
472
|
<task_id> Required. Session to build on (positional)
|
|
457
473
|
--prompt <text> Required. Briefing for the new session
|
|
458
474
|
--model <model> Optional. Override the model (alias or provider/model)
|
|
475
|
+
--gateway <mode> Routing when --model is given: auto (direct-first), direct, or openrouter
|
|
459
476
|
--cwd <path> Project directory (default: cwd)
|
|
460
477
|
--no-ui Run without GUI (autonomous mode)
|
|
461
478
|
--json With --no-ui: emit the run result as stable JSON
|
package/src/mcp-server.js
CHANGED
|
@@ -4,7 +4,6 @@ const path = require('path');
|
|
|
4
4
|
const { writeFileAtomic } = require('./utils/atomic-write');
|
|
5
5
|
const { spawn } = require('child_process');
|
|
6
6
|
const { getTools, getGuideText } = require('./mcp-tools');
|
|
7
|
-
const { tryResolveModel } = require('./utils/config');
|
|
8
7
|
const os = require('os');
|
|
9
8
|
const { logger } = require('./utils/logger');
|
|
10
9
|
const { safeSessionDir } = require('./utils/validators');
|
|
@@ -241,7 +240,7 @@ function spawnSidecarProcess(args, sessionDir) {
|
|
|
241
240
|
/** Tool handler implementations */
|
|
242
241
|
const handlers = {
|
|
243
242
|
async amicus_start(input, project, mcpServer) {
|
|
244
|
-
// Validate
|
|
243
|
+
// Validate non-model inputs (prompt/timeout/agent) before any session creation.
|
|
245
244
|
const { validateStartInputs } = require('./utils/input-validators');
|
|
246
245
|
const validation = validateStartInputs(input);
|
|
247
246
|
if (!validation.valid) {
|
|
@@ -250,7 +249,40 @@ const handlers = {
|
|
|
250
249
|
content: [{ type: 'text', text: JSON.stringify(validation.error) }],
|
|
251
250
|
};
|
|
252
251
|
}
|
|
253
|
-
|
|
252
|
+
|
|
253
|
+
// Model routing (#61 Task 6.2): route through the gateway router for MCP
|
|
254
|
+
// parity with the CLI's resolveLaunchModel (start-helpers.js). Unlike the
|
|
255
|
+
// CLI, this handler must never process.exit — the MCP server is long-lived
|
|
256
|
+
// and serves many tool calls — so a routing failure returns a structured
|
|
257
|
+
// model_route_error response instead.
|
|
258
|
+
//
|
|
259
|
+
// Default resolution mirrors resolveLaunchModel: an omitted input.model
|
|
260
|
+
// falls back to the configured default before hitting the router, so
|
|
261
|
+
// "no model, no default" produces a clean invalid_descriptor structured
|
|
262
|
+
// error (via the router) rather than a crash. Shared with the CLI's
|
|
263
|
+
// resolveLaunchModel (start-helpers.js) via model-input-default.js.
|
|
264
|
+
const { resolveGatewayMode } = require('./utils/config');
|
|
265
|
+
const { resolveRouteForLaunch } = require('./utils/route-launch');
|
|
266
|
+
const { toStructuredError } = require('./utils/route-error');
|
|
267
|
+
const { resolveModelInputOrDefault } = require('./utils/model-input-default');
|
|
268
|
+
|
|
269
|
+
const modelInput = resolveModelInputOrDefault(input.model);
|
|
270
|
+
// input.gateway is now exposed by the MCP tool schema (#61 Task 7.2);
|
|
271
|
+
// resolveGatewayMode(undefined) falls back to config routing.prefer / 'auto'.
|
|
272
|
+
const routeResult = await resolveRouteForLaunch({
|
|
273
|
+
model: modelInput,
|
|
274
|
+
gatewayMode: resolveGatewayMode(input.gateway),
|
|
275
|
+
source: 'mcp',
|
|
276
|
+
allowSelection: false,
|
|
277
|
+
validateModel: true,
|
|
278
|
+
});
|
|
279
|
+
if (routeResult.kind !== 'resolved') {
|
|
280
|
+
return {
|
|
281
|
+
isError: true,
|
|
282
|
+
content: [{ type: 'text', text: JSON.stringify(toStructuredError(routeResult)) }],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const resolvedModel = routeResult.executableId;
|
|
254
286
|
|
|
255
287
|
const cwd = project || getProjectDir(input.project);
|
|
256
288
|
const { generateTaskId } = require('./sidecar/start');
|
|
@@ -268,7 +300,9 @@ const handlers = {
|
|
|
268
300
|
const briefingPath = path.join(sessionDir, 'briefing.md');
|
|
269
301
|
const detectedClient = detectClient(mcpServer);
|
|
270
302
|
const args = ['start', '--prompt-file', briefingPath, '--task-id', taskId, '--client', detectedClient];
|
|
271
|
-
|
|
303
|
+
// resolvedModel is always defined here — a routing failure already
|
|
304
|
+
// returned above — and is the router's executableId, not the raw alias.
|
|
305
|
+
args.push('--model', resolvedModel);
|
|
272
306
|
const agent = (input.noUi && (!input.agent || input.agent.toLowerCase() === 'chat'))
|
|
273
307
|
? 'build' : input.agent;
|
|
274
308
|
if (agent) { args.push('--agent', agent); }
|
|
@@ -296,7 +330,8 @@ const handlers = {
|
|
|
296
330
|
const { runHeadless } = require('./headless');
|
|
297
331
|
const { generateFoldNonce } = require('./utils/fold-marker');
|
|
298
332
|
const { finalizeHeadlessResult } = require('./sidecar/session-finalize');
|
|
299
|
-
// resolvedModel is already available from
|
|
333
|
+
// resolvedModel (the router's executableId) is already available from
|
|
334
|
+
// the model-routing step above
|
|
300
335
|
|
|
301
336
|
// #47: the shared OpenCode server is shared across projects, so the
|
|
302
337
|
// session must be created scoped to the resolved project directory
|
|
@@ -441,7 +476,15 @@ const handlers = {
|
|
|
441
476
|
taskId, status: 'running', mode: 'headless',
|
|
442
477
|
message: 'Amicus started in headless mode. Use amicus_status to check progress.',
|
|
443
478
|
});
|
|
444
|
-
|
|
479
|
+
// FIX 2 (#61 whole-branch review): surface the router's one-shot
|
|
480
|
+
// migration notice here too — resolveRouteForLaunch already burned
|
|
481
|
+
// the migration_notified flag for this vendor when it built
|
|
482
|
+
// routeResult above, so this is the only chance to show it on the
|
|
483
|
+
// shared-server path (no CLI stderr exists for an MCP caller).
|
|
484
|
+
const sharedServerContent = [{ type: 'text', text: body }];
|
|
485
|
+
if (routeResult.notice) { sharedServerContent.push({ type: 'text', text: routeResult.notice }); }
|
|
486
|
+
sharedServerContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
|
|
487
|
+
return { content: sharedServerContent };
|
|
445
488
|
} catch (err) {
|
|
446
489
|
logger.warn('Shared server path failed, falling back to spawn', { error: err.message });
|
|
447
490
|
// Clean up partial shared server state before falling through
|
|
@@ -490,10 +533,16 @@ const handlers = {
|
|
|
490
533
|
'Then wait for the user to tell you. Use amicus_read to get results once they confirm.';
|
|
491
534
|
|
|
492
535
|
const body = JSON.stringify({ taskId, status: 'running', mode, message });
|
|
536
|
+
// FIX 2 (#61 whole-branch review): the spawn path never touches stderr of
|
|
537
|
+
// the CLI child that will do the routing print — this handler already
|
|
538
|
+
// resolved the route in-process above, so surface its notice here.
|
|
539
|
+
const spawnContent = [{ type: 'text', text: body }];
|
|
540
|
+
if (routeResult.notice) { spawnContent.push({ type: 'text', text: routeResult.notice }); }
|
|
493
541
|
if (isHeadless) {
|
|
494
|
-
|
|
542
|
+
spawnContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
|
|
543
|
+
return { content: spawnContent };
|
|
495
544
|
}
|
|
496
|
-
return
|
|
545
|
+
return { content: spawnContent };
|
|
497
546
|
},
|
|
498
547
|
|
|
499
548
|
async amicus_status(input, project) {
|
|
@@ -795,11 +844,37 @@ const handlers = {
|
|
|
795
844
|
},
|
|
796
845
|
|
|
797
846
|
async amicus_continue(input, project, mcpServer) {
|
|
847
|
+
// Model routing parity with amicus_start (#61 whole-branch review FIX 3,
|
|
848
|
+
// Task 6.2 follow-up): an explicit --model on continue used to be
|
|
849
|
+
// pre-checked only with the legacy tryResolveModel (alias-existence-only),
|
|
850
|
+
// so an unroutable model (e.g. a gateway-only vendor with no OpenRouter
|
|
851
|
+
// key) spawned a child that then died opaquely trying to launch it.
|
|
852
|
+
// Route it through the SAME gateway router amicus_start uses instead, and
|
|
853
|
+
// return the same structured model_route_error on failure — never spawn.
|
|
854
|
+
// The no-`--model` inherit-prior-session-model path is UNCHANGED: nothing
|
|
855
|
+
// below runs, and handleContinue reuses the prior concrete model verbatim.
|
|
856
|
+
let resolvedModel;
|
|
798
857
|
if (input.model) {
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
858
|
+
const { resolveGatewayMode } = require('./utils/config');
|
|
859
|
+
const { resolveRouteForLaunch } = require('./utils/route-launch');
|
|
860
|
+
const { toStructuredError } = require('./utils/route-error');
|
|
861
|
+
const { resolveModelInputOrDefault } = require('./utils/model-input-default');
|
|
862
|
+
|
|
863
|
+
const modelInput = resolveModelInputOrDefault(input.model);
|
|
864
|
+
const routeResult = await resolveRouteForLaunch({
|
|
865
|
+
model: modelInput,
|
|
866
|
+
gatewayMode: resolveGatewayMode(input.gateway),
|
|
867
|
+
source: 'mcp',
|
|
868
|
+
allowSelection: false,
|
|
869
|
+
validateModel: true,
|
|
870
|
+
});
|
|
871
|
+
if (routeResult.kind !== 'resolved') {
|
|
872
|
+
return {
|
|
873
|
+
isError: true,
|
|
874
|
+
content: [{ type: 'text', text: JSON.stringify(toStructuredError(routeResult)) }],
|
|
875
|
+
};
|
|
802
876
|
}
|
|
877
|
+
resolvedModel = routeResult.executableId;
|
|
803
878
|
}
|
|
804
879
|
|
|
805
880
|
const cwd = project || getProjectDir(input.project);
|
|
@@ -814,7 +889,15 @@ const handlers = {
|
|
|
814
889
|
const briefingPath = path.join(sessionDir, 'briefing.md');
|
|
815
890
|
const args = ['continue', input.taskId, '--prompt-file', briefingPath,
|
|
816
891
|
'--task-id', newTaskId, '--client', detectClient(mcpServer), '--cwd', cwd];
|
|
817
|
-
|
|
892
|
+
// resolvedModel is the router's executableId (never the raw alias) — a
|
|
893
|
+
// routing failure already returned above, so this is only reached when
|
|
894
|
+
// input.model was absent (resolvedModel stays undefined) or resolved.
|
|
895
|
+
if (resolvedModel) { args.push('--model', resolvedModel); }
|
|
896
|
+
// #61 Task 7.3: forward the caller's gateway preference to the spawned
|
|
897
|
+
// CLI child too — handleContinue still does its OWN routing (it's the
|
|
898
|
+
// one wiring --model/--gateway into a resolved session), this just keeps
|
|
899
|
+
// the child's own resolution consistent with what was already decided.
|
|
900
|
+
if (input.gateway) { args.push('--gateway', input.gateway); }
|
|
818
901
|
if (input.noUi) { args.push('--no-ui', '--agent', 'build'); }
|
|
819
902
|
if (input.timeout) { args.push('--timeout', String(input.timeout)); }
|
|
820
903
|
if (input.contextTurns) { args.push('--context-turns', String(input.contextTurns)); }
|
|
@@ -955,6 +1038,10 @@ const handlers = {
|
|
|
955
1038
|
];
|
|
956
1039
|
const agent = input.agent || 'Build';
|
|
957
1040
|
args.push('--agent', agent);
|
|
1041
|
+
// #61 Task 7.3: forward the caller's gateway preference to the spawned
|
|
1042
|
+
// CLI child, which routes each leg's model (piece 2) — unlike
|
|
1043
|
+
// amicus_start, this handler never resolves any leg's route itself.
|
|
1044
|
+
if (input.gateway) { args.push('--gateway', input.gateway); }
|
|
958
1045
|
if (input.thinking) { args.push('--thinking', input.thinking); }
|
|
959
1046
|
if (input.timeout) { args.push('--timeout', String(input.timeout)); }
|
|
960
1047
|
if (input.summaryLength) { args.push('--summary-length', input.summaryLength); }
|
package/src/mcp-tools.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const { z } = require('zod');
|
|
11
11
|
const { formatAliasNames } = require('./utils/config');
|
|
12
12
|
const { READ_CAP_BYTES } = require('./utils/read-slice');
|
|
13
|
+
const { GATEWAY_MODES } = require('./utils/model-descriptor');
|
|
13
14
|
|
|
14
15
|
/** Zod pattern for safe task IDs (alphanumeric, hyphens, underscores only) */
|
|
15
16
|
const safeTaskId = z.string().regex(
|
|
@@ -55,9 +56,15 @@ function getTools() {
|
|
|
55
56
|
' Pass includeContext: false when the briefing is fully self-contained.',
|
|
56
57
|
inputSchema: {
|
|
57
58
|
model: safeModel.optional().describe(
|
|
58
|
-
`Short alias (${aliasNames}) or full
|
|
59
|
+
`Short alias (${aliasNames}) or full model ID ` +
|
|
60
|
+
'(bare provider/model is canonical and routes direct-first, e.g. anthropic/claude-opus-4.8; ' +
|
|
61
|
+
'openrouter/provider/model forces OpenRouter). ' +
|
|
59
62
|
'If omitted, uses the configured default. Call amicus_guide to see all aliases.'
|
|
60
63
|
),
|
|
64
|
+
gateway: z.enum(GATEWAY_MODES).optional().describe(
|
|
65
|
+
'Routing preference: auto (direct-first, default), direct (require a ' +
|
|
66
|
+
'direct provider key), or openrouter (force OpenRouter).'
|
|
67
|
+
),
|
|
61
68
|
prompt: z.string().describe(
|
|
62
69
|
'Detailed task briefing. Include: objective, background, ' +
|
|
63
70
|
'files of interest, success criteria.'
|
|
@@ -251,7 +258,13 @@ function getTools() {
|
|
|
251
258
|
'New task description for the continuation.'
|
|
252
259
|
),
|
|
253
260
|
model: safeModel.optional().describe(
|
|
254
|
-
`Override model — short alias (${aliasNames}) or full
|
|
261
|
+
`Override model — short alias (${aliasNames}) or full model ID. Bare provider/model routes ` +
|
|
262
|
+
'direct-first (canonical); openrouter/provider/model forces OpenRouter. Defaults to the ' +
|
|
263
|
+
"original session's model."
|
|
264
|
+
),
|
|
265
|
+
gateway: z.enum(GATEWAY_MODES).optional().describe(
|
|
266
|
+
'Routing preference: auto (direct-first, default), direct (require a ' +
|
|
267
|
+
'direct provider key), or openrouter (force OpenRouter).'
|
|
255
268
|
),
|
|
256
269
|
noUi: z.boolean().optional().default(false).describe(
|
|
257
270
|
'Run headless. Default false (opens Electron window).'
|
|
@@ -308,13 +321,19 @@ function getTools() {
|
|
|
308
321
|
'inside). Each leg is also an ordinary session readable by taskId.',
|
|
309
322
|
inputSchema: {
|
|
310
323
|
models: z.array(safeModel).min(1).max(10).optional().describe(
|
|
311
|
-
`1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full
|
|
324
|
+
`1-10 models (2+ for genuine fan-out). Short aliases (${aliasNames}) or full model IDs — ` +
|
|
325
|
+
'bare provider/model routes direct-first (canonical), openrouter/provider/model forces ' +
|
|
326
|
+
"OpenRouter. Duplicates allowed. Omit when using 'council'."
|
|
312
327
|
),
|
|
313
328
|
council: z.string().optional().describe(
|
|
314
329
|
"Run a saved council, or a built-in bench ('free', 'budget', 'frontier'), instead of 'models'. " +
|
|
315
330
|
"Expands to the council's members; a saved council of the same name shadows a built-in. " +
|
|
316
331
|
'Mutually exclusive with \'models\'.'
|
|
317
332
|
),
|
|
333
|
+
gateway: z.enum(GATEWAY_MODES).optional().describe(
|
|
334
|
+
'Routing preference: auto (direct-first, default), direct (require a ' +
|
|
335
|
+
'direct provider key), or openrouter (force OpenRouter).'
|
|
336
|
+
),
|
|
318
337
|
prompt: z.string().describe(
|
|
319
338
|
'The briefing sent to every model. Self-contained briefings work best (set includeContext false).'
|
|
320
339
|
),
|
|
@@ -492,7 +511,10 @@ Include: Objective, Background, Files of interest, Success criteria, Constraints
|
|
|
492
511
|
|-------|-------|
|
|
493
512
|
${aliasRows}
|
|
494
513
|
|
|
495
|
-
Or use full
|
|
514
|
+
Or use a full model ID. Bare \`provider/model\` (e.g. anthropic/claude-opus-4.8) is the canonical,
|
|
515
|
+
policy-routed form — it routes direct-first (your direct provider key if configured, else
|
|
516
|
+
OpenRouter). \`openrouter/provider/model\` is an explicit override that forces OpenRouter. The
|
|
517
|
+
\`gateway\` param (or \`routing.prefer\` in config.json) controls this per call or globally.
|
|
496
518
|
Run amicus_setup to configure defaults and add custom aliases.
|
|
497
519
|
|
|
498
520
|
## Session Matching
|
package/src/opencode-client.js
CHANGED
|
@@ -400,6 +400,10 @@ async function getSessionStatus(client, sessionId, directory) {
|
|
|
400
400
|
* @param {AbortSignal} [options.signal] - Abort signal to stop server
|
|
401
401
|
* @param {object} [options.mcp] - MCP server configurations
|
|
402
402
|
* @param {string} [options.model] - Default model
|
|
403
|
+
* @param {string[]} [options.models] - Resolved executable id(s) actually launched (#61 Task
|
|
404
|
+
* 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout) has no single
|
|
405
|
+
* default model, so ALL of them register in provider.models via this instead of `model`.
|
|
406
|
+
* Takes precedence over the `options.model` single-id fallback when non-empty.
|
|
403
407
|
* @param {string} [options.client] - Client type ('cowork', 'code-local', etc.)
|
|
404
408
|
* @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
|
|
405
409
|
* @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
|
|
@@ -473,9 +477,17 @@ function buildServerOptions(options = {}) {
|
|
|
473
477
|
}
|
|
474
478
|
|
|
475
479
|
// Sync sidecar aliases into OpenCode's provider.models so the UI
|
|
476
|
-
// model picker shows all configured models (single source of truth)
|
|
480
|
+
// model picker shows all configured models (single source of truth),
|
|
481
|
+
// and register the actually-resolved launch route's provider so it
|
|
482
|
+
// always matches config.model (the "sole input" invariant — see #61).
|
|
483
|
+
// A multi-model shared server (fanout, #61 Task 7.3) has no single
|
|
484
|
+
// config.model — options.models carries EVERY leg's resolved id instead,
|
|
485
|
+
// and takes precedence over the single-id options.model fallback.
|
|
477
486
|
const { buildProviderModels } = require('./utils/config');
|
|
478
|
-
|
|
487
|
+
const resolvedForProvider = (Array.isArray(options.models) && options.models.length)
|
|
488
|
+
? options.models
|
|
489
|
+
: (options.model ? [options.model] : []);
|
|
490
|
+
config.provider = buildProviderModels(resolvedForProvider);
|
|
479
491
|
|
|
480
492
|
// Register custom 'chat' agent: reads auto-approved, writes/bash require permission
|
|
481
493
|
const chatAgent = {
|
|
@@ -605,6 +617,10 @@ function buildServerHandle(sdkServer, deps = {}) {
|
|
|
605
617
|
* @param {AbortSignal} [options.signal] - Abort signal to stop server
|
|
606
618
|
* @param {object} [options.mcp] - MCP server configurations
|
|
607
619
|
* @param {string} [options.model] - Default model
|
|
620
|
+
* @param {string[]} [options.models] - Resolved executable id(s) actually launched (#61 Task
|
|
621
|
+
* 4.6/7.3 sole-input invariant) — a multi-model shared server (fanout) has no single
|
|
622
|
+
* default model, so ALL of them register in provider.models via this instead of `model`.
|
|
623
|
+
* Takes precedence over the `options.model` single-id fallback when non-empty.
|
|
608
624
|
* @param {string} [options.client] - Client type ('cowork', 'code-local', etc.)
|
|
609
625
|
* @returns {Promise<{client: object, server: {url: string, close: Function}}>}
|
|
610
626
|
*/
|
package/src/sidecar/continue.js
CHANGED
|
@@ -88,7 +88,7 @@ Build on the previous sidecar's findings. The user wants to continue or extend t
|
|
|
88
88
|
|
|
89
89
|
/** Create session metadata for continuation */
|
|
90
90
|
function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
91
|
-
const { model, briefing, headless, agent } = options;
|
|
91
|
+
const { model, briefing, headless, agent, gateway, resolutionVersion } = options;
|
|
92
92
|
|
|
93
93
|
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
94
94
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
@@ -104,6 +104,12 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
|
104
104
|
createdAt: new Date().toISOString(),
|
|
105
105
|
continuesFrom: oldTaskId
|
|
106
106
|
};
|
|
107
|
+
// #61 Task 5.2 (best-effort provenance): only present when THIS continue
|
|
108
|
+
// call freshly routed an explicit --model through the gateway router — the
|
|
109
|
+
// no-model inherit path never re-resolves, so gateway/resolutionVersion
|
|
110
|
+
// stay undefined there and are simply omitted (never written as `undefined`).
|
|
111
|
+
if (gateway !== undefined) { metadata.gateway = gateway; }
|
|
112
|
+
if (resolutionVersion !== undefined) { metadata.resolutionVersion = resolutionVersion; }
|
|
107
113
|
|
|
108
114
|
writeFileAtomic(SessionPaths.metadataFile(sessionDir), JSON.stringify(metadata, null, 2));
|
|
109
115
|
|
|
@@ -123,7 +129,8 @@ async function continueSidecar(options) {
|
|
|
123
129
|
headless = false,
|
|
124
130
|
timeout = 15,
|
|
125
131
|
agent,
|
|
126
|
-
mcp, mcpConfig, client, noMcp, excludeMcp, json = false
|
|
132
|
+
mcp, mcpConfig, client, noMcp, excludeMcp, json = false,
|
|
133
|
+
gateway, resolutionVersion, // #61 Task 5.2 (best-effort provenance)
|
|
127
134
|
} = options;
|
|
128
135
|
|
|
129
136
|
// Load previous session data
|
|
@@ -168,7 +175,7 @@ async function continueSidecar(options) {
|
|
|
168
175
|
logger.info('New continuation task', { newTaskId, oldTaskId });
|
|
169
176
|
|
|
170
177
|
const sessionDir = createContinueSessionMetadata(newTaskId, project, {
|
|
171
|
-
model, briefing, headless, agent: effectiveAgent
|
|
178
|
+
model, briefing, headless, agent: effectiveAgent, gateway, resolutionVersion,
|
|
172
179
|
}, oldTaskId);
|
|
173
180
|
|
|
174
181
|
// Lock the NEW continuation session dir too — not just the previous one — so a
|
|
@@ -35,6 +35,31 @@ function writeLegPatch(legDir, patch) {
|
|
|
35
35
|
return merged;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Synthesize an error run document for a leg whose MODEL never routed (#61
|
|
40
|
+
* Task 7.3) — the gateway router returned `error` (or, in principle,
|
|
41
|
+
* `selection_required`) for this leg's requested model before any session was
|
|
42
|
+
* created, so there is nothing to run. Mirrors the shape runLeg's own catch
|
|
43
|
+
* branch already produces for "setup threw before the session dir existed":
|
|
44
|
+
* no legDir, no summary, an `error` status — this never touches the shared
|
|
45
|
+
* server or the Promise.all in runFanout, so it can never sink a sibling leg.
|
|
46
|
+
* @param {{leg: {modelInput: string, routeResult: object}, legId: string,
|
|
47
|
+
* waveId: string, quiet?: boolean}} args
|
|
48
|
+
* @returns {object} run document (status 'error')
|
|
49
|
+
*/
|
|
50
|
+
function buildRoutingFailureLeg({ leg, legId, waveId, quiet }) {
|
|
51
|
+
const { toCliMessage } = require('../utils/route-error');
|
|
52
|
+
const { buildRunResult } = require('../utils/result-schema');
|
|
53
|
+
const message = toCliMessage(leg.routeResult);
|
|
54
|
+
if (!quiet) {
|
|
55
|
+
process.stderr.write(`[fanout] leg ${legId} (${leg.modelInput}): error (routing)\n`);
|
|
56
|
+
}
|
|
57
|
+
return buildRunResult({
|
|
58
|
+
taskId: legId, metadata: {}, result: { error: message, completed: false },
|
|
59
|
+
summary: null, modelInput: leg.modelInput, sessionDir: null, waveId,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
/**
|
|
39
64
|
* Run one leg end-to-end: session record → runHeadless (shared server) →
|
|
40
65
|
* leg finalize. Never throws — always resolves to a run document.
|
|
@@ -129,4 +154,4 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
|
|
|
129
154
|
});
|
|
130
155
|
}
|
|
131
156
|
|
|
132
|
-
module.exports = { legStatusFromResult, writeLegPatch, runLeg };
|
|
157
|
+
module.exports = { legStatusFromResult, writeLegPatch, runLeg, buildRoutingFailureLeg };
|
|
@@ -32,6 +32,11 @@ function formatWaveHuman(wave) {
|
|
|
32
32
|
lines.push('');
|
|
33
33
|
}
|
|
34
34
|
if (wave.error) { lines.push(`Error: ${wave.error}`); }
|
|
35
|
+
// #61 whole-branch review FIX 2: advisory per-leg migration notices (no
|
|
36
|
+
// routing effect) surfaced here since fanout has no CLI-single stderr path.
|
|
37
|
+
if (Array.isArray(wave.notices) && wave.notices.length) {
|
|
38
|
+
for (const notice of wave.notices) { lines.push(`Notice: ${notice}`); }
|
|
39
|
+
}
|
|
35
40
|
lines.push('─'.repeat(40));
|
|
36
41
|
const counts = wave.counts || { complete: '?', total: '?' };
|
|
37
42
|
lines.push(`Wave ${wave.waveId}: ${wave.status} — ${counts.complete}/${counts.total} complete in ${fmtDuration(wave.durationMs)}`);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// src/sidecar/fanout-validate.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module fanout-validate
|
|
6
|
+
* Fan-out --models parsing + per-leg gateway routing. Split out of fanout.js
|
|
7
|
+
* (#61 Task 7.3) to keep both files under the 300-line size gate — this
|
|
8
|
+
* module owns everything about turning a raw --models string into resolved
|
|
9
|
+
* (or per-leg-failed) legs; fanout.js owns running the wave itself.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Default max legs per wave (env-overridable). */
|
|
13
|
+
const DEFAULT_MAX_LEGS = 10;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split a --models value into trimmed, non-empty entries (duplicates allowed).
|
|
17
|
+
* @param {string|boolean|undefined} modelsArg
|
|
18
|
+
* @returns {string[]}
|
|
19
|
+
*/
|
|
20
|
+
function parseModelsList(modelsArg) {
|
|
21
|
+
if (typeof modelsArg !== 'string') { return []; }
|
|
22
|
+
return modelsArg.split(',').map(s => s.trim()).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve + gate every requested model through the gateway router (#61 Task
|
|
27
|
+
* 7.3), one leg at a time. Unlike the pre-#61 fail-fast validator, an
|
|
28
|
+
* individual leg's routing failure does NOT abort the whole wave: it comes
|
|
29
|
+
* back with `ok:false` and the router's RouteResult attached, so runFanout
|
|
30
|
+
* can synthesize a failed leg document (buildRoutingFailureLeg) while sibling
|
|
31
|
+
* legs still launch normally. Only whole-list problems — an empty list or
|
|
32
|
+
* exceeding the leg-count cap — remain wave-level fatal and are returned as
|
|
33
|
+
* a top-level `{error, code}` (nothing to route yet at that point).
|
|
34
|
+
* @param {string} modelsArg - Raw --models value
|
|
35
|
+
* @param {{noValidateModel?: boolean, gatewayMode?: string}} [opts]
|
|
36
|
+
* @returns {Promise<{legs: Array<{modelInput: string, ok: boolean, model?: string,
|
|
37
|
+
* pricing?: object, gateway?: string, provenance?: object, routeResult?: object}>}
|
|
38
|
+
* | {error: string, code: string}>}
|
|
39
|
+
*/
|
|
40
|
+
async function validateFanoutModels(modelsArg, opts = {}) {
|
|
41
|
+
const raw = parseModelsList(modelsArg);
|
|
42
|
+
if (raw.length === 0) {
|
|
43
|
+
return { error: 'Error: --models requires a comma-separated list (e.g. gemini,gpt,deepseek)', code: 'BAD_ARGS' };
|
|
44
|
+
}
|
|
45
|
+
// Invalid or non-positive AMICUS_FANOUT_MAX_LEGS (0, negative, garbage) falls back to the default.
|
|
46
|
+
const envCap = Number(process.env.AMICUS_FANOUT_MAX_LEGS);
|
|
47
|
+
const maxLegs = (Number.isInteger(envCap) && envCap > 0) ? envCap : DEFAULT_MAX_LEGS;
|
|
48
|
+
if (raw.length > maxLegs) {
|
|
49
|
+
return { error: `Error: --models exceeds the fan-out cap of ${maxLegs} legs (set AMICUS_FANOUT_MAX_LEGS to raise)`, code: 'BAD_ARGS' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { resolveRouteForLaunch } = require('../utils/route-launch');
|
|
53
|
+
const { lookupPricing } = require('../utils/pricing');
|
|
54
|
+
const validateModel = !opts.noValidateModel;
|
|
55
|
+
const gatewayMode = opts.gatewayMode || 'auto';
|
|
56
|
+
const legs = [];
|
|
57
|
+
for (const modelInput of raw) {
|
|
58
|
+
const routeResult = await resolveRouteForLaunch({
|
|
59
|
+
model: modelInput, gatewayMode, source: 'cli', allowSelection: false, validateModel,
|
|
60
|
+
});
|
|
61
|
+
if (routeResult.kind === 'resolved') {
|
|
62
|
+
legs.push({
|
|
63
|
+
modelInput, ok: true, model: routeResult.executableId,
|
|
64
|
+
pricing: lookupPricing(routeResult.executableId),
|
|
65
|
+
gateway: routeResult.gateway, provenance: routeResult.provenance,
|
|
66
|
+
// FIX 2 (#61 whole-branch review): resolveRouteForLaunch already
|
|
67
|
+
// burned the one-shot migration_notified flag for this vendor when it
|
|
68
|
+
// built routeResult — carry the notice out so runFanout can surface
|
|
69
|
+
// it on the wave doc (fanout had no other path to show it).
|
|
70
|
+
notice: routeResult.notice || null,
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
// 'error' (headless: allowSelection false, so the router never hands
|
|
74
|
+
// back 'selection_required' here — see gateway-router.js's catalogGate).
|
|
75
|
+
legs.push({ modelInput, ok: false, routeResult });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { legs };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { parseModelsList, DEFAULT_MAX_LEGS, validateFanoutModels };
|