@euqns/nudge-mcp 0.16.0 → 1.0.0

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.
@@ -1,5 +1,5 @@
1
1
  import { Codex, } from "@openai/codex-sdk";
2
- import { spawn, spawnSync } from "node:child_process";
2
+ import { spawn, spawnSync, } from "node:child_process";
3
3
  import crypto from "node:crypto";
4
4
  import http from "node:http";
5
5
  import { createRequire } from "node:module";
@@ -27,6 +27,62 @@ const PROVIDERS = {
27
27
  loginCommand: "cursor-agent login",
28
28
  },
29
29
  };
30
+ const CLAUDE_EFFORTS = [
31
+ {
32
+ reasoningEffort: "low",
33
+ description: "Faster responses with lighter reasoning.",
34
+ },
35
+ {
36
+ reasoningEffort: "medium",
37
+ description: "Balanced reasoning for everyday work.",
38
+ },
39
+ {
40
+ reasoningEffort: "high",
41
+ description: "More reasoning for difficult tasks.",
42
+ },
43
+ {
44
+ reasoningEffort: "xhigh",
45
+ description: "Extended reasoning for complex tasks.",
46
+ },
47
+ {
48
+ reasoningEffort: "max",
49
+ description: "Claude Code's maximum reasoning effort.",
50
+ },
51
+ ];
52
+ const CLAUDE_MODELS = [
53
+ {
54
+ model: "default",
55
+ displayName: "Claude Code default",
56
+ description: "Use the default model from your Claude Code account.",
57
+ isDefault: true,
58
+ defaultReasoningEffort: "medium",
59
+ supportedReasoningEfforts: CLAUDE_EFFORTS,
60
+ },
61
+ {
62
+ model: "sonnet",
63
+ displayName: "Sonnet",
64
+ description: "Claude Code's balanced model alias.",
65
+ isDefault: false,
66
+ defaultReasoningEffort: "medium",
67
+ supportedReasoningEfforts: CLAUDE_EFFORTS,
68
+ },
69
+ {
70
+ model: "opus",
71
+ displayName: "Opus",
72
+ description: "Claude Code's most capable model alias.",
73
+ isDefault: false,
74
+ defaultReasoningEffort: "high",
75
+ supportedReasoningEfforts: CLAUDE_EFFORTS,
76
+ },
77
+ {
78
+ model: "haiku",
79
+ displayName: "Haiku",
80
+ description: "Claude Code's fastest model alias.",
81
+ isDefault: false,
82
+ defaultReasoningEffort: "low",
83
+ supportedReasoningEfforts: CLAUDE_EFFORTS,
84
+ },
85
+ ];
30
86
  const require = createRequire(import.meta.url);
31
87
  const REASONING_EFFORTS = new Set([
32
88
  "none",
@@ -39,7 +95,8 @@ const REASONING_EFFORTS = new Set([
39
95
  "ultra",
40
96
  ]);
41
97
  function reasoningEffort(value) {
42
- return typeof value === "string" && REASONING_EFFORTS.has(value)
98
+ return typeof value === "string" &&
99
+ REASONING_EFFORTS.has(value)
43
100
  ? value
44
101
  : null;
45
102
  }
@@ -77,7 +134,10 @@ class CodexMetadataClient {
77
134
  });
78
135
  this.child = child;
79
136
  this.stderr = "";
80
- const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
137
+ const lines = readline.createInterface({
138
+ input: child.stdout,
139
+ crlfDelay: Infinity,
140
+ });
81
141
  lines.on("line", (line) => this.handleLine(line));
82
142
  child.stderr.on("data", (chunk) => {
83
143
  this.stderr = `${this.stderr}${chunk.toString("utf8")}`.slice(-4_000);
@@ -164,7 +224,9 @@ class CodexMetadataClient {
164
224
  .filter((model) => typeof model.model === "string" && model.model.length > 0)
165
225
  .map((model) => ({
166
226
  model: model.model,
167
- displayName: typeof model.displayName === "string" ? model.displayName : model.model,
227
+ displayName: typeof model.displayName === "string"
228
+ ? model.displayName
229
+ : model.model,
168
230
  description: typeof model.description === "string" ? model.description : "",
169
231
  isDefault: model.isDefault === true,
170
232
  defaultReasoningEffort: reasoningEffort(model.defaultReasoningEffort) ?? "medium",
@@ -176,10 +238,14 @@ class CodexMetadataClient {
176
238
  const effort = reasoningEffort(entry.reasoningEffort);
177
239
  if (!effort)
178
240
  return [];
179
- return [{
241
+ return [
242
+ {
180
243
  reasoningEffort: effort,
181
- description: typeof entry.description === "string" ? entry.description : "",
182
- }];
244
+ description: typeof entry.description === "string"
245
+ ? entry.description
246
+ : "",
247
+ },
248
+ ];
183
249
  })
184
250
  : [],
185
251
  }));
@@ -221,7 +287,7 @@ class CodexMetadataClient {
221
287
  currentReasoningEffort: selectedEffort,
222
288
  models,
223
289
  planType: account.account?.type === "chatgpt"
224
- ? account.account.planType ?? null
290
+ ? (account.account.planType ?? null)
225
291
  : null,
226
292
  rateLimits,
227
293
  updatedAt: Date.now(),
@@ -244,7 +310,7 @@ function parseArgs(argv) {
244
310
  let openBrowserOnStart = true;
245
311
  let port = DEFAULT_PORT;
246
312
  let portWasExplicit = false;
247
- let provider = "codex";
313
+ let preferredProvider;
248
314
  for (let index = 0; index < argv.length; index++) {
249
315
  const arg = argv[index];
250
316
  const next = argv[index + 1];
@@ -278,7 +344,7 @@ function parseArgs(argv) {
278
344
  if (!(next in PROVIDERS)) {
279
345
  throw new Error("--provider must be codex, claude, or cursor");
280
346
  }
281
- provider = next;
347
+ preferredProvider = next;
282
348
  index++;
283
349
  }
284
350
  else if (arg.startsWith("--provider=")) {
@@ -286,7 +352,7 @@ function parseArgs(argv) {
286
352
  if (!(value in PROVIDERS)) {
287
353
  throw new Error("--provider must be codex, claude, or cursor");
288
354
  }
289
- provider = value;
355
+ preferredProvider = value;
290
356
  }
291
357
  else {
292
358
  throw new Error(`Unknown agent companion option: ${arg}`);
@@ -301,7 +367,7 @@ function parseArgs(argv) {
301
367
  openBrowser: openBrowserOnStart,
302
368
  port,
303
369
  portWasExplicit,
304
- provider,
370
+ preferredProvider,
305
371
  };
306
372
  }
307
373
  async function listenOnLoopback(server, preferredPort, allowFallback) {
@@ -366,7 +432,9 @@ function writeJson(res, status, value, headers) {
366
432
  res.end(JSON.stringify(value));
367
433
  }
368
434
  export function buildCompanionPrompt(prompt, context) {
369
- const pathname = typeof context?.pathname === "string" ? context.pathname.slice(0, 500) : undefined;
435
+ const pathname = typeof context?.pathname === "string"
436
+ ? context.pathname.slice(0, 500)
437
+ : undefined;
370
438
  const routeContext = pathname
371
439
  ? `The user opened this chat from the Nudge route ${JSON.stringify(pathname)}.`
372
440
  : "The user opened this chat from Nudge.";
@@ -385,11 +453,15 @@ export function buildCompanionPrompt(prompt, context) {
385
453
  context.boardRole === "viewer"
386
454
  ? context.boardRole
387
455
  : undefined,
388
- view: typeof context.view === "string" ? context.view.slice(0, 50) : undefined,
456
+ view: typeof context.view === "string"
457
+ ? context.view.slice(0, 50)
458
+ : undefined,
389
459
  canvasId: typeof context.canvasId === "string"
390
460
  ? context.canvasId.slice(0, 200)
391
461
  : undefined,
392
- cardId: typeof context.cardId === "string" ? context.cardId.slice(0, 200) : undefined,
462
+ cardId: typeof context.cardId === "string"
463
+ ? context.cardId.slice(0, 200)
464
+ : undefined,
393
465
  cardTitle: typeof context.cardTitle === "string"
394
466
  ? context.cardTitle.slice(0, 500)
395
467
  : undefined,
@@ -399,7 +471,9 @@ export function buildCompanionPrompt(prompt, context) {
399
471
  cardHref: typeof context.cardHref === "string"
400
472
  ? context.cardHref.slice(0, 1_000)
401
473
  : undefined,
402
- listId: typeof context.listId === "string" ? context.listId.slice(0, 200) : undefined,
474
+ listId: typeof context.listId === "string"
475
+ ? context.listId.slice(0, 200)
476
+ : undefined,
403
477
  listTitle: typeof context.listTitle === "string"
404
478
  ? context.listTitle.slice(0, 500)
405
479
  : undefined,
@@ -409,8 +483,12 @@ export function buildCompanionPrompt(prompt, context) {
409
483
  repository: context.repository && typeof context.repository === "object"
410
484
  ? context.repository
411
485
  : undefined,
412
- listCount: typeof context.listCount === "number" ? context.listCount : undefined,
413
- cardCount: typeof context.cardCount === "number" ? context.cardCount : undefined,
486
+ listCount: typeof context.listCount === "number"
487
+ ? context.listCount
488
+ : undefined,
489
+ cardCount: typeof context.cardCount === "number"
490
+ ? context.cardCount
491
+ : undefined,
414
492
  }),
415
493
  "Use this exact board id for Nudge MCP calls unless the user names another board.",
416
494
  context.cardId
@@ -469,6 +547,20 @@ export function assertCompanionProvider(provider) {
469
547
  throw new Error(`${definition.displayName} is not authenticated. Run \`${definition.loginCommand}\`.${detail ? `\nCurrent status: ${detail}` : ""}`);
470
548
  }
471
549
  }
550
+ export function validatedCompanionProviders(validate = assertCompanionProvider) {
551
+ const available = [];
552
+ const failures = new Map();
553
+ for (const provider of Object.keys(PROVIDERS)) {
554
+ try {
555
+ validate(provider);
556
+ available.push(provider);
557
+ }
558
+ catch (error) {
559
+ failures.set(provider, error instanceof Error ? error.message : String(error));
560
+ }
561
+ }
562
+ return { available, failures };
563
+ }
472
564
  export function subscriptionEnvironment(environment = process.env) {
473
565
  const blocked = new Set([
474
566
  "ANTHROPIC_API_KEY",
@@ -479,6 +571,156 @@ export function subscriptionEnvironment(environment = process.env) {
479
571
  ]);
480
572
  return Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== undefined && !blocked.has(entry[0])));
481
573
  }
574
+ function modelDisplayName(model) {
575
+ if (model === "auto")
576
+ return "Auto";
577
+ return model
578
+ .replace(/[-_]+/g, " ")
579
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
580
+ }
581
+ function cursorModel(candidate) {
582
+ const record = candidate && typeof candidate === "object"
583
+ ? candidate
584
+ : null;
585
+ const rawModel = typeof candidate === "string"
586
+ ? candidate
587
+ : typeof record?.model === "string"
588
+ ? record.model
589
+ : typeof record?.id === "string"
590
+ ? record.id
591
+ : typeof record?.value === "string"
592
+ ? record.value
593
+ : "";
594
+ const model = rawModel.trim();
595
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:/\[\]=,-]*$/.test(model))
596
+ return null;
597
+ const displayName = typeof record?.displayName === "string"
598
+ ? record.displayName
599
+ : typeof record?.name === "string"
600
+ ? record.name
601
+ : modelDisplayName(model);
602
+ return {
603
+ model,
604
+ displayName,
605
+ description: "Available to your Cursor account.",
606
+ isDefault: record?.isDefault === true || model === "auto",
607
+ defaultReasoningEffort: "none",
608
+ supportedReasoningEfforts: [],
609
+ };
610
+ }
611
+ /** Parse both current line output and future JSON output from Cursor's model command. */
612
+ export function parseCursorModels(output) {
613
+ const trimmed = output.trim();
614
+ let candidates = [];
615
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
616
+ try {
617
+ const parsed = JSON.parse(trimmed);
618
+ if (Array.isArray(parsed))
619
+ candidates = parsed;
620
+ else if (parsed && typeof parsed === "object") {
621
+ const record = parsed;
622
+ if (Array.isArray(record.models))
623
+ candidates = record.models;
624
+ }
625
+ }
626
+ catch {
627
+ // Older Cursor releases print one model id per line.
628
+ }
629
+ }
630
+ if (candidates.length === 0) {
631
+ candidates = output
632
+ .replace(/\u001b\[[0-9;]*m/g, "")
633
+ .split(/\r?\n/)
634
+ .map((line) => line.trim())
635
+ .filter((line) => line.length > 0)
636
+ .filter((line) => !/^(available\s+)?models?:?$/i.test(line))
637
+ .map((line) => line
638
+ .replace(/^[*✓●>•-]\s*/, "")
639
+ .replace(/\s+\((?:default|current)\)$/i, ""))
640
+ .map((line) => line.split(/\t|\s{2,}/, 1)[0]);
641
+ }
642
+ const models = candidates
643
+ .map((candidate) => cursorModel(candidate))
644
+ .filter((model) => model !== null);
645
+ const deduplicated = Array.from(new Map(models.map((model) => [model.model, model])).values());
646
+ if (deduplicated.length > 0 &&
647
+ !deduplicated.some((model) => model.isDefault)) {
648
+ deduplicated[0] = { ...deduplicated[0], isDefault: true };
649
+ }
650
+ return deduplicated;
651
+ }
652
+ function captureCliOutput(command, args) {
653
+ return new Promise((resolve, reject) => {
654
+ const child = spawn(command, args, {
655
+ env: subscriptionEnvironment(),
656
+ stdio: ["ignore", "pipe", "pipe"],
657
+ });
658
+ let stdout = "";
659
+ let stderr = "";
660
+ const timer = setTimeout(() => child.kill("SIGTERM"), 8_000);
661
+ child.stdout.on("data", (chunk) => {
662
+ stdout = `${stdout}${chunk.toString("utf8")}`.slice(-256_000);
663
+ });
664
+ child.stderr.on("data", (chunk) => {
665
+ stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8_000);
666
+ });
667
+ child.once("error", (error) => {
668
+ clearTimeout(timer);
669
+ reject(error);
670
+ });
671
+ child.once("close", (code) => {
672
+ clearTimeout(timer);
673
+ if (code === 0)
674
+ resolve(stdout);
675
+ else
676
+ reject(new Error(stderr.trim() || `Model discovery exited ${code}`));
677
+ });
678
+ });
679
+ }
680
+ async function subscriptionMetadata(provider, currentModel, currentEffort) {
681
+ let models;
682
+ if (provider === "claude") {
683
+ models = CLAUDE_MODELS;
684
+ }
685
+ else {
686
+ try {
687
+ models = parseCursorModels(await captureCliOutput(PROVIDERS.cursor.binary, ["--list-models"]));
688
+ }
689
+ catch {
690
+ models = [];
691
+ }
692
+ if (models.length === 0) {
693
+ models = [
694
+ {
695
+ model: "auto",
696
+ displayName: "Auto",
697
+ description: "Let Cursor choose the best available model.",
698
+ isDefault: true,
699
+ defaultReasoningEffort: "none",
700
+ supportedReasoningEfforts: [],
701
+ },
702
+ ];
703
+ }
704
+ }
705
+ const fallback = models.find((model) => model.isDefault) ?? models[0];
706
+ const selected = models.find((model) => model.model === currentModel) ?? fallback;
707
+ const effort = selected.supportedReasoningEfforts.some((candidate) => candidate.reasoningEffort === currentEffort)
708
+ ? currentEffort
709
+ : selected.defaultReasoningEffort;
710
+ return {
711
+ currentModel: selected.model,
712
+ currentReasoningEffort: effort,
713
+ models,
714
+ planType: "subscription",
715
+ rateLimits: [],
716
+ updatedAt: Date.now(),
717
+ provider: { id: provider, displayName: PROVIDERS[provider].displayName },
718
+ capabilities: {
719
+ modelSelection: true,
720
+ effortSelection: provider === "claude",
721
+ },
722
+ };
723
+ }
482
724
  export function companionMcpTransport() {
483
725
  const companionFile = fileURLToPath(import.meta.url);
484
726
  const runningFromTypeScript = companionFile.endsWith(".ts");
@@ -528,7 +770,7 @@ function companionClaudeConfig() {
528
770
  },
529
771
  });
530
772
  }
531
- export function companionCliInvocation(provider, prompt, threadId) {
773
+ export function companionCliInvocation(provider, prompt, threadId, model = null, effort = null) {
532
774
  const definition = PROVIDERS[provider];
533
775
  if (provider === "claude") {
534
776
  return {
@@ -548,6 +790,8 @@ export function companionCliInvocation(provider, prompt, threadId) {
548
790
  "--mcp-config",
549
791
  companionClaudeConfig(),
550
792
  "--strict-mcp-config",
793
+ ...(model && model !== "default" ? ["--model", model] : []),
794
+ ...(effort && effort !== "none" ? ["--effort", effort] : []),
551
795
  ...(threadId ? ["--resume", threadId] : []),
552
796
  ],
553
797
  stdin: prompt,
@@ -560,6 +804,7 @@ export function companionCliInvocation(provider, prompt, threadId) {
560
804
  "--mode=ask",
561
805
  "--output-format",
562
806
  "stream-json",
807
+ ...(model ? ["--model", model] : []),
563
808
  ...(threadId ? ["--resume", threadId] : []),
564
809
  prompt,
565
810
  ],
@@ -588,7 +833,9 @@ function cliActivity(provider, event) {
588
833
  if (!call || typeof call !== "object")
589
834
  return "Used a Cursor tool";
590
835
  const name = Object.keys(call)[0];
591
- return name ? `Used ${name.replace(/ToolCall$/, "")}` : "Used a Cursor tool";
836
+ return name
837
+ ? `Used ${name.replace(/ToolCall$/, "")}`
838
+ : "Used a Cursor tool";
592
839
  }
593
840
  if (provider === "claude" && event.type === "assistant") {
594
841
  const message = event.message;
@@ -600,9 +847,9 @@ function cliActivity(provider, event) {
600
847
  }
601
848
  return null;
602
849
  }
603
- async function runSubscriptionCliTurn({ provider, prompt, cwd, threadId, signal, send, }) {
850
+ async function runSubscriptionCliTurn({ provider, prompt, cwd, threadId, model, effort, signal, send, }) {
604
851
  const definition = PROVIDERS[provider];
605
- const invocation = companionCliInvocation(provider, prompt, threadId);
852
+ const invocation = companionCliInvocation(provider, prompt, threadId, model, effort);
606
853
  send({ type: "agent", event: { type: "turn.started" } });
607
854
  return await new Promise((resolve, reject) => {
608
855
  const child = spawn(invocation.command, invocation.args, {
@@ -614,7 +861,10 @@ async function runSubscriptionCliTurn({ provider, prompt, cwd, threadId, signal,
614
861
  let activeThreadId = threadId;
615
862
  let finalText = "";
616
863
  let usage;
617
- const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
864
+ const lines = readline.createInterface({
865
+ input: child.stdout,
866
+ crlfDelay: Infinity,
867
+ });
618
868
  lines.on("line", (line) => {
619
869
  if (!line.trim())
620
870
  return;
@@ -709,26 +959,59 @@ function companionThreadOptions(cwd, model, effort) {
709
959
  modelReasoningEffort: effort,
710
960
  };
711
961
  }
962
+ const PROVIDER_IDS = Object.keys(PROVIDERS);
712
963
  export async function runAgentCompanion(argv) {
713
964
  const options = parseArgs(argv);
714
- const provider = PROVIDERS[options.provider];
715
965
  const allowedOrigin = new URL(options.appUrl).origin;
716
966
  const token = crypto.randomBytes(32).toString("base64url");
717
- assertCompanionProvider(options.provider);
718
- const codex = options.provider === "codex"
719
- ? new Codex({
720
- config: companionCodexConfig(),
721
- env: subscriptionEnvironment(),
722
- })
723
- : null;
724
- const metadataClient = options.provider === "codex"
725
- ? new CodexMetadataClient()
726
- : null;
727
- let thread = null;
728
- let cliThreadId = null;
729
- let selectedModel = null;
730
- let selectedEffort = null;
967
+ const validation = validatedCompanionProviders();
968
+ const failures = validation.failures;
969
+ const runtimes = new Map();
970
+ for (const id of validation.available) {
971
+ runtimes.set(id, {
972
+ id,
973
+ codex: id === "codex"
974
+ ? new Codex({
975
+ config: companionCodexConfig(),
976
+ env: subscriptionEnvironment(),
977
+ })
978
+ : null,
979
+ metadataClient: id === "codex" ? new CodexMetadataClient() : null,
980
+ thread: null,
981
+ cliThreadId: null,
982
+ selectedModel: null,
983
+ selectedEffort: null,
984
+ });
985
+ }
986
+ if (runtimes.size === 0) {
987
+ throw new Error([
988
+ "Nudge Agent could not find an authenticated coding-agent subscription.",
989
+ ...PROVIDER_IDS.map((id) => `${PROVIDERS[id].displayName}: ${failures.get(id) ?? "unavailable"}`),
990
+ ].join("\n"));
991
+ }
992
+ const defaultProviderId = options.preferredProvider && runtimes.has(options.preferredProvider)
993
+ ? options.preferredProvider
994
+ : PROVIDER_IDS.find((id) => runtimes.has(id));
731
995
  let activeAbort = null;
996
+ const providerInfo = (id) => ({
997
+ id,
998
+ displayName: PROVIDERS[id].displayName,
999
+ });
1000
+ const resolveRuntime = (value) => {
1001
+ const id = (value ?? defaultProviderId);
1002
+ return PROVIDER_IDS.includes(id) ? (runtimes.get(id) ?? null) : null;
1003
+ };
1004
+ const readMetadata = async (runtime) => {
1005
+ if (runtime.metadataClient) {
1006
+ const metadata = await runtime.metadataClient.metadata(runtime.selectedModel, runtime.selectedEffort);
1007
+ return {
1008
+ ...metadata,
1009
+ provider: providerInfo(runtime.id),
1010
+ capabilities: { modelSelection: true, effortSelection: true },
1011
+ };
1012
+ }
1013
+ return subscriptionMetadata(runtime.id, runtime.selectedModel, runtime.selectedEffort);
1014
+ };
732
1015
  const corsHeaders = (origin) => ({
733
1016
  ...(origin === allowedOrigin
734
1017
  ? { "Access-Control-Allow-Origin": allowedOrigin }
@@ -744,6 +1027,9 @@ export async function runAgentCompanion(argv) {
744
1027
  void (async () => {
745
1028
  const origin = req.headers.origin;
746
1029
  const headers = corsHeaders(origin);
1030
+ const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
1031
+ const runtime = resolveRuntime(requestUrl.searchParams.get("provider"));
1032
+ const provider = runtime ? PROVIDERS[runtime.id] : null;
747
1033
  if (origin && origin !== allowedOrigin) {
748
1034
  writeJson(res, 403, { error: "Origin not allowed" }, headers);
749
1035
  return;
@@ -757,67 +1043,44 @@ export async function runAgentCompanion(argv) {
757
1043
  writeJson(res, 401, { error: "Invalid companion token" }, headers);
758
1044
  return;
759
1045
  }
760
- if (req.method === "GET" && req.url === "/v1/health") {
1046
+ if (!runtime || !provider) {
1047
+ writeJson(res, 400, { error: "That coding-agent subscription is unavailable" }, headers);
1048
+ return;
1049
+ }
1050
+ if (req.method === "GET" && requestUrl.pathname === "/v1/health") {
761
1051
  writeJson(res, 200, {
762
1052
  ok: true,
763
1053
  cwd: options.cwd,
764
1054
  running: activeAbort !== null,
765
- threadId: options.provider === "codex" ? thread?.id ?? null : cliThreadId,
766
- model: selectedModel,
767
- effort: selectedEffort,
768
- provider: { id: options.provider, displayName: provider.displayName },
1055
+ threadId: runtime.codex
1056
+ ? (runtime.thread?.id ?? null)
1057
+ : runtime.cliThreadId,
1058
+ model: runtime.selectedModel,
1059
+ effort: runtime.selectedEffort,
1060
+ provider: providerInfo(runtime.id),
1061
+ providers: Array.from(runtimes.keys(), providerInfo),
769
1062
  }, headers);
770
1063
  return;
771
1064
  }
772
- if (req.method === "GET" && req.url === "/v1/metadata") {
773
- if (!metadataClient) {
774
- writeJson(res, 200, {
775
- currentModel: options.provider,
776
- currentReasoningEffort: "none",
777
- models: [
778
- {
779
- model: options.provider,
780
- displayName: `${provider.displayName} default`,
781
- description: `Uses the model selected by your ${provider.displayName} CLI account.`,
782
- isDefault: true,
783
- defaultReasoningEffort: "none",
784
- supportedReasoningEfforts: [],
785
- },
786
- ],
787
- planType: "subscription",
788
- rateLimits: [],
789
- updatedAt: Date.now(),
790
- provider: { id: options.provider, displayName: provider.displayName },
791
- capabilities: { modelSelection: false, effortSelection: false },
792
- }, headers);
793
- return;
794
- }
1065
+ if (req.method === "GET" && requestUrl.pathname === "/v1/metadata") {
795
1066
  try {
796
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
797
- selectedModel = metadata.currentModel;
798
- selectedEffort = metadata.currentReasoningEffort;
799
- writeJson(res, 200, {
800
- ...metadata,
801
- provider: { id: options.provider, displayName: provider.displayName },
802
- capabilities: { modelSelection: true, effortSelection: true },
803
- }, headers);
1067
+ const metadata = await readMetadata(runtime);
1068
+ runtime.selectedModel = metadata.currentModel;
1069
+ runtime.selectedEffort = metadata.currentReasoningEffort;
1070
+ writeJson(res, 200, metadata, headers);
804
1071
  }
805
1072
  catch (error) {
806
1073
  writeJson(res, 503, {
807
1074
  error: error instanceof Error
808
1075
  ? error.message
809
- : "Codex metadata is unavailable",
1076
+ : `${provider.displayName} metadata is unavailable`,
810
1077
  }, headers);
811
1078
  }
812
1079
  return;
813
1080
  }
814
- if (req.method === "POST" && req.url === "/v1/model") {
815
- if (!metadataClient) {
816
- writeJson(res, 400, { error: `${provider.displayName} controls model selection in its own CLI.` }, headers);
817
- return;
818
- }
1081
+ if (req.method === "POST" && requestUrl.pathname === "/v1/model") {
819
1082
  if (activeAbort) {
820
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1083
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
821
1084
  return;
822
1085
  }
823
1086
  let parsed;
@@ -829,25 +1092,29 @@ export async function runAgentCompanion(argv) {
829
1092
  return;
830
1093
  }
831
1094
  const model = typeof parsed.model === "string" ? parsed.model.trim() : "";
832
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
1095
+ const metadata = await readMetadata(runtime);
833
1096
  const modelInfo = metadata.models.find((candidate) => candidate.model === model);
834
1097
  if (!modelInfo) {
835
- writeJson(res, 400, { error: "That Codex model is unavailable" }, headers);
1098
+ writeJson(res, 400, { error: `That ${provider.displayName} model is unavailable` }, headers);
836
1099
  return;
837
1100
  }
838
- selectedModel = model;
839
- selectedEffort = modelInfo.defaultReasoningEffort;
840
- thread = null;
841
- writeJson(res, 200, { ok: true, model, effort: selectedEffort }, headers);
1101
+ runtime.selectedModel = model;
1102
+ runtime.selectedEffort = modelInfo.defaultReasoningEffort;
1103
+ runtime.thread = null;
1104
+ runtime.cliThreadId = null;
1105
+ writeJson(res, 200, { ok: true, model, effort: runtime.selectedEffort }, headers);
842
1106
  return;
843
1107
  }
844
- if (req.method === "POST" && req.url === "/v1/effort") {
845
- if (!metadataClient) {
846
- writeJson(res, 400, { error: `${provider.displayName} controls reasoning settings in its own CLI.` }, headers);
1108
+ if (req.method === "POST" && requestUrl.pathname === "/v1/effort") {
1109
+ if (activeAbort) {
1110
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
847
1111
  return;
848
1112
  }
849
- if (activeAbort) {
850
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1113
+ const metadata = await readMetadata(runtime);
1114
+ if (metadata.capabilities?.effortSelection === false) {
1115
+ writeJson(res, 400, {
1116
+ error: `${provider.displayName} controls reasoning settings in its own CLI.`,
1117
+ }, headers);
851
1118
  return;
852
1119
  }
853
1120
  let parsed;
@@ -859,33 +1126,32 @@ export async function runAgentCompanion(argv) {
859
1126
  return;
860
1127
  }
861
1128
  const effort = reasoningEffort(parsed.effort);
862
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
863
1129
  const modelInfo = metadata.models.find((candidate) => candidate.model === metadata.currentModel);
864
1130
  if (!effort ||
865
1131
  !modelInfo?.supportedReasoningEfforts.some((candidate) => candidate.reasoningEffort === effort)) {
866
1132
  writeJson(res, 400, { error: "That reasoning effort is unavailable" }, headers);
867
1133
  return;
868
1134
  }
869
- selectedModel = metadata.currentModel;
870
- selectedEffort = effort;
871
- thread = null;
872
- cliThreadId = null;
873
- writeJson(res, 200, { ok: true, model: selectedModel, effort }, headers);
1135
+ runtime.selectedModel = metadata.currentModel;
1136
+ runtime.selectedEffort = effort;
1137
+ runtime.thread = null;
1138
+ runtime.cliThreadId = null;
1139
+ writeJson(res, 200, { ok: true, model: runtime.selectedModel, effort }, headers);
874
1140
  return;
875
1141
  }
876
- if (req.method === "POST" && req.url === "/v1/new") {
1142
+ if (req.method === "POST" && requestUrl.pathname === "/v1/new") {
877
1143
  if (activeAbort) {
878
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1144
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
879
1145
  return;
880
1146
  }
881
- thread = null;
882
- cliThreadId = null;
1147
+ runtime.thread = null;
1148
+ runtime.cliThreadId = null;
883
1149
  writeJson(res, 200, { ok: true }, headers);
884
1150
  return;
885
1151
  }
886
- if (req.method === "POST" && req.url === "/v1/resume") {
1152
+ if (req.method === "POST" && requestUrl.pathname === "/v1/resume") {
887
1153
  if (activeAbort) {
888
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1154
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
889
1155
  return;
890
1156
  }
891
1157
  let parsed;
@@ -901,22 +1167,50 @@ export async function runAgentCompanion(argv) {
901
1167
  writeJson(res, 400, { error: "Invalid coding-agent session id" }, headers);
902
1168
  return;
903
1169
  }
904
- if (!metadataClient || !codex) {
905
- cliThreadId = threadId;
906
- writeJson(res, 200, { ok: true, threadId }, headers);
1170
+ if (!runtime.metadataClient || !runtime.codex) {
1171
+ const metadata = await readMetadata(runtime);
1172
+ const requestedModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
1173
+ const requestedEffort = reasoningEffort(parsed.effort);
1174
+ if (requestedModel &&
1175
+ !metadata.models.some((candidate) => candidate.model === requestedModel)) {
1176
+ writeJson(res, 400, { error: `That ${provider.displayName} model is unavailable` }, headers);
1177
+ return;
1178
+ }
1179
+ const nextModel = requestedModel || metadata.currentModel;
1180
+ const modelInfo = metadata.models.find((candidate) => candidate.model === nextModel);
1181
+ const cursorDefaultEffort = metadata.capabilities?.effortSelection === false &&
1182
+ requestedEffort === "none";
1183
+ if (parsed.effort !== undefined &&
1184
+ (!requestedEffort ||
1185
+ (!cursorDefaultEffort &&
1186
+ !modelInfo?.supportedReasoningEfforts.some((candidate) => candidate.reasoningEffort === requestedEffort)))) {
1187
+ writeJson(res, 400, { error: "That reasoning effort is unavailable" }, headers);
1188
+ return;
1189
+ }
1190
+ runtime.selectedModel = nextModel;
1191
+ runtime.selectedEffort =
1192
+ requestedEffort ?? modelInfo?.defaultReasoningEffort ?? "none";
1193
+ runtime.cliThreadId = threadId;
1194
+ writeJson(res, 200, {
1195
+ ok: true,
1196
+ threadId,
1197
+ model: runtime.selectedModel,
1198
+ effort: runtime.selectedEffort,
1199
+ }, headers);
907
1200
  return;
908
1201
  }
909
1202
  const requestedModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
910
1203
  const requestedEffort = reasoningEffort(parsed.effort);
911
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
1204
+ const metadata = await runtime.metadataClient.metadata(runtime.selectedModel, runtime.selectedEffort);
912
1205
  if (requestedModel) {
913
1206
  if (!metadata.models.some((candidate) => candidate.model === requestedModel)) {
914
1207
  writeJson(res, 400, { error: "That Codex model is unavailable" }, headers);
915
1208
  return;
916
1209
  }
917
- selectedModel = requestedModel;
1210
+ runtime.selectedModel = requestedModel;
918
1211
  }
919
- const modelInfo = metadata.models.find((candidate) => candidate.model === (selectedModel ?? metadata.currentModel));
1212
+ const modelInfo = metadata.models.find((candidate) => candidate.model ===
1213
+ (runtime.selectedModel ?? metadata.currentModel));
920
1214
  if (parsed.effort !== undefined && !requestedEffort) {
921
1215
  writeJson(res, 400, { error: "Invalid reasoning effort" }, headers);
922
1216
  return;
@@ -926,17 +1220,23 @@ export async function runAgentCompanion(argv) {
926
1220
  writeJson(res, 400, { error: "That reasoning effort is unavailable" }, headers);
927
1221
  return;
928
1222
  }
929
- selectedEffort = requestedEffort ?? modelInfo?.defaultReasoningEffort ?? null;
930
- thread = codex.resumeThread(threadId, companionThreadOptions(options.cwd, selectedModel, selectedEffort));
931
- writeJson(res, 200, { ok: true, threadId, model: selectedModel, effort: selectedEffort }, headers);
1223
+ runtime.selectedEffort =
1224
+ requestedEffort ?? modelInfo?.defaultReasoningEffort ?? null;
1225
+ runtime.thread = runtime.codex.resumeThread(threadId, companionThreadOptions(options.cwd, runtime.selectedModel, runtime.selectedEffort));
1226
+ writeJson(res, 200, {
1227
+ ok: true,
1228
+ threadId,
1229
+ model: runtime.selectedModel,
1230
+ effort: runtime.selectedEffort,
1231
+ }, headers);
932
1232
  return;
933
1233
  }
934
- if (req.method === "POST" && req.url === "/v1/cancel") {
1234
+ if (req.method === "POST" && requestUrl.pathname === "/v1/cancel") {
935
1235
  activeAbort?.abort();
936
1236
  writeJson(res, 200, { ok: true }, headers);
937
1237
  return;
938
1238
  }
939
- if (req.method !== "POST" || req.url !== "/v1/chat") {
1239
+ if (req.method !== "POST" || requestUrl.pathname !== "/v1/chat") {
940
1240
  writeJson(res, 404, { error: "Not found" }, headers);
941
1241
  return;
942
1242
  }
@@ -957,8 +1257,8 @@ export async function runAgentCompanion(argv) {
957
1257
  writeJson(res, 400, { error: "prompt is required" }, headers);
958
1258
  return;
959
1259
  }
960
- if (codex) {
961
- thread ??= codex.startThread(companionThreadOptions(options.cwd, selectedModel, selectedEffort));
1260
+ if (runtime.codex) {
1261
+ runtime.thread ??= runtime.codex.startThread(companionThreadOptions(options.cwd, runtime.selectedModel, runtime.selectedEffort));
962
1262
  }
963
1263
  const abort = new AbortController();
964
1264
  activeAbort = abort;
@@ -977,11 +1277,17 @@ export async function runAgentCompanion(argv) {
977
1277
  res.write(`${JSON.stringify(value)}\n`);
978
1278
  };
979
1279
  try {
980
- const currentThreadId = codex ? thread?.id ?? null : cliThreadId;
981
- send({ type: "connected", cwd: options.cwd, threadId: currentThreadId });
1280
+ const currentThreadId = runtime.codex
1281
+ ? (runtime.thread?.id ?? null)
1282
+ : runtime.cliThreadId;
1283
+ send({
1284
+ type: "connected",
1285
+ cwd: options.cwd,
1286
+ threadId: currentThreadId,
1287
+ });
982
1288
  const contextualPrompt = buildCompanionPrompt(prompt, parsed.context);
983
- if (codex && thread) {
984
- const streamed = await thread.runStreamed(contextualPrompt, {
1289
+ if (runtime.codex && runtime.thread) {
1290
+ const streamed = await runtime.thread.runStreamed(contextualPrompt, {
985
1291
  signal: abort.signal,
986
1292
  });
987
1293
  for await (const event of streamed.events) {
@@ -989,16 +1295,23 @@ export async function runAgentCompanion(argv) {
989
1295
  }
990
1296
  }
991
1297
  else {
992
- cliThreadId = await runSubscriptionCliTurn({
993
- provider: options.provider,
1298
+ runtime.cliThreadId = await runSubscriptionCliTurn({
1299
+ provider: runtime.id,
994
1300
  prompt: contextualPrompt,
995
1301
  cwd: options.cwd,
996
- threadId: cliThreadId,
1302
+ threadId: runtime.cliThreadId,
1303
+ model: runtime.selectedModel,
1304
+ effort: runtime.selectedEffort,
997
1305
  signal: abort.signal,
998
1306
  send,
999
1307
  });
1000
1308
  }
1001
- send({ type: "done", threadId: codex ? thread?.id ?? null : cliThreadId });
1309
+ send({
1310
+ type: "done",
1311
+ threadId: runtime.codex
1312
+ ? (runtime.thread?.id ?? null)
1313
+ : runtime.cliThreadId,
1314
+ });
1002
1315
  }
1003
1316
  catch (error) {
1004
1317
  send({
@@ -1024,12 +1337,13 @@ export async function runAgentCompanion(argv) {
1024
1337
  const pairing = Buffer.from(JSON.stringify({
1025
1338
  port,
1026
1339
  token,
1027
- provider: { id: options.provider, displayName: provider.displayName },
1340
+ provider: providerInfo(defaultProviderId),
1341
+ providers: Array.from(runtimes.keys(), providerInfo),
1028
1342
  }), "utf8").toString("base64url");
1029
1343
  const url = companionPairingUrl(options.appUrl, pairing);
1030
1344
  console.log("");
1031
1345
  console.log("Nudge Agent companion is running");
1032
- console.log(` Agent: ${provider.displayName}`);
1346
+ console.log(` Agents: ${Array.from(runtimes.keys(), (id) => PROVIDERS[id].displayName).join(", ")}`);
1033
1347
  console.log(` App: ${options.appUrl}`);
1034
1348
  console.log(` Workspace: ${options.cwd}`);
1035
1349
  console.log(` Listener: http://127.0.0.1:${port}`);
@@ -1038,7 +1352,15 @@ export async function runAgentCompanion(argv) {
1038
1352
  }
1039
1353
  console.log(" Files: read-only");
1040
1354
  console.log("");
1041
- console.log(`${provider.displayName} will reuse the account from \`${provider.loginCommand}\`.`);
1355
+ for (const id of PROVIDER_IDS) {
1356
+ const definition = PROVIDERS[id];
1357
+ if (runtimes.has(id)) {
1358
+ console.log(` ready ${definition.displayName} — reusing \`${definition.loginCommand}\``);
1359
+ }
1360
+ else {
1361
+ console.log(` unavailable ${definition.displayName} — ${failures.get(id) ?? "not found"}`);
1362
+ }
1363
+ }
1042
1364
  console.log("Keep this terminal open while using Nudge chat.");
1043
1365
  console.log("");
1044
1366
  if (options.openBrowser)
@@ -1048,7 +1370,8 @@ export async function runAgentCompanion(argv) {
1048
1370
  await new Promise((resolve) => {
1049
1371
  const shutdown = () => {
1050
1372
  activeAbort?.abort();
1051
- metadataClient?.stop();
1373
+ for (const runtime of runtimes.values())
1374
+ runtime.metadataClient?.stop();
1052
1375
  server.close(() => resolve());
1053
1376
  };
1054
1377
  process.once("SIGINT", shutdown);