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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +66 -0
  3. package/README.md +13 -4
  4. package/electron/setup-ui-aliases.js +1 -1
  5. package/electron/setup-ui.js +27 -3
  6. package/package.json +2 -1
  7. package/skills/second-opinion/SKILL.md +2 -0
  8. package/skills/sidecar/SKILL.md +66 -38
  9. package/src/cli-handlers-resume-continue.js +31 -4
  10. package/src/cli-handlers-run.js +15 -4
  11. package/src/cli.js +17 -0
  12. package/src/mcp-server.js +99 -12
  13. package/src/mcp-tools.js +26 -4
  14. package/src/opencode-client.js +18 -2
  15. package/src/sidecar/continue.js +10 -3
  16. package/src/sidecar/fanout-leg.js +26 -1
  17. package/src/sidecar/fanout-output.js +5 -0
  18. package/src/sidecar/fanout-validate.js +81 -0
  19. package/src/sidecar/fanout.js +65 -77
  20. package/src/sidecar/models.js +39 -17
  21. package/src/sidecar/session-utils.js +6 -0
  22. package/src/sidecar/setup.js +2 -1
  23. package/src/utils/alias-resolver.js +6 -35
  24. package/src/utils/api-key-store.js +1 -9
  25. package/src/utils/auth-json.js +1 -1
  26. package/src/utils/config.js +98 -16
  27. package/src/utils/curated-models.js +99 -8
  28. package/src/utils/gateway-route-audit.js +103 -0
  29. package/src/utils/gateway-route-catalog.js +92 -0
  30. package/src/utils/gateway-router.js +131 -0
  31. package/src/utils/input-validators.js +12 -42
  32. package/src/utils/model-classification.js +65 -0
  33. package/src/utils/model-descriptor.js +72 -0
  34. package/src/utils/model-fetcher.js +46 -14
  35. package/src/utils/model-input-default.js +32 -0
  36. package/src/utils/model-validator.js +68 -84
  37. package/src/utils/provider-registry.js +57 -0
  38. package/src/utils/quick-picks.js +11 -3
  39. package/src/utils/result-schema-rebuild.js +98 -0
  40. package/src/utils/result-schema.js +15 -78
  41. package/src/utils/route-error.js +154 -0
  42. package/src/utils/route-launch.js +219 -0
  43. package/src/utils/start-helpers.js +96 -43
  44. 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 all inputs before any session creation
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
- const resolvedModel = validation.resolvedModel;
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
- if (resolvedModel) { args.push('--model', resolvedModel); }
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 validateStartInputs() above
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
- return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
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
- return { content: [{ type: 'text', text: body }, { type: 'text', text: HEADLESS_START_REMINDER }] };
542
+ spawnContent.push({ type: 'text', text: HEADLESS_START_REMINDER });
543
+ return { content: spawnContent };
495
544
  }
496
- return textResult(body);
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 modelCheck = tryResolveModel(input.model);
800
- if (modelCheck.error) {
801
- return textResult(modelCheck.error, true);
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
- if (input.model) { args.push('--model', input.model); }
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 provider/model ID. ` +
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 provider/model ID. Defaults to the original session's model.`
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 provider/model IDs. Duplicates allowed. Omit when using 'council'.`
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 IDs in provider/model format (e.g., openrouter/provider/model-id).
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
@@ -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
- config.provider = buildProviderModels();
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
  */
@@ -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 };