@euqns/nudge-mcp 0.16.0 → 1.1.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,8 +1,10 @@
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
+ import { readdir, realpath, stat } from "node:fs/promises";
4
5
  import http from "node:http";
5
6
  import { createRequire } from "node:module";
7
+ import os from "node:os";
6
8
  import path from "node:path";
7
9
  import readline from "node:readline";
8
10
  import { fileURLToPath } from "node:url";
@@ -27,6 +29,62 @@ const PROVIDERS = {
27
29
  loginCommand: "cursor-agent login",
28
30
  },
29
31
  };
32
+ const CLAUDE_EFFORTS = [
33
+ {
34
+ reasoningEffort: "low",
35
+ description: "Faster responses with lighter reasoning.",
36
+ },
37
+ {
38
+ reasoningEffort: "medium",
39
+ description: "Balanced reasoning for everyday work.",
40
+ },
41
+ {
42
+ reasoningEffort: "high",
43
+ description: "More reasoning for difficult tasks.",
44
+ },
45
+ {
46
+ reasoningEffort: "xhigh",
47
+ description: "Extended reasoning for complex tasks.",
48
+ },
49
+ {
50
+ reasoningEffort: "max",
51
+ description: "Claude Code's maximum reasoning effort.",
52
+ },
53
+ ];
54
+ const CLAUDE_MODELS = [
55
+ {
56
+ model: "default",
57
+ displayName: "Claude Code default",
58
+ description: "Use the default model from your Claude Code account.",
59
+ isDefault: true,
60
+ defaultReasoningEffort: "medium",
61
+ supportedReasoningEfforts: CLAUDE_EFFORTS,
62
+ },
63
+ {
64
+ model: "sonnet",
65
+ displayName: "Sonnet",
66
+ description: "Claude Code's balanced model alias.",
67
+ isDefault: false,
68
+ defaultReasoningEffort: "medium",
69
+ supportedReasoningEfforts: CLAUDE_EFFORTS,
70
+ },
71
+ {
72
+ model: "opus",
73
+ displayName: "Opus",
74
+ description: "Claude Code's most capable model alias.",
75
+ isDefault: false,
76
+ defaultReasoningEffort: "high",
77
+ supportedReasoningEfforts: CLAUDE_EFFORTS,
78
+ },
79
+ {
80
+ model: "haiku",
81
+ displayName: "Haiku",
82
+ description: "Claude Code's fastest model alias.",
83
+ isDefault: false,
84
+ defaultReasoningEffort: "low",
85
+ supportedReasoningEfforts: CLAUDE_EFFORTS,
86
+ },
87
+ ];
30
88
  const require = createRequire(import.meta.url);
31
89
  const REASONING_EFFORTS = new Set([
32
90
  "none",
@@ -39,7 +97,8 @@ const REASONING_EFFORTS = new Set([
39
97
  "ultra",
40
98
  ]);
41
99
  function reasoningEffort(value) {
42
- return typeof value === "string" && REASONING_EFFORTS.has(value)
100
+ return typeof value === "string" &&
101
+ REASONING_EFFORTS.has(value)
43
102
  ? value
44
103
  : null;
45
104
  }
@@ -77,7 +136,10 @@ class CodexMetadataClient {
77
136
  });
78
137
  this.child = child;
79
138
  this.stderr = "";
80
- const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
139
+ const lines = readline.createInterface({
140
+ input: child.stdout,
141
+ crlfDelay: Infinity,
142
+ });
81
143
  lines.on("line", (line) => this.handleLine(line));
82
144
  child.stderr.on("data", (chunk) => {
83
145
  this.stderr = `${this.stderr}${chunk.toString("utf8")}`.slice(-4_000);
@@ -164,7 +226,9 @@ class CodexMetadataClient {
164
226
  .filter((model) => typeof model.model === "string" && model.model.length > 0)
165
227
  .map((model) => ({
166
228
  model: model.model,
167
- displayName: typeof model.displayName === "string" ? model.displayName : model.model,
229
+ displayName: typeof model.displayName === "string"
230
+ ? model.displayName
231
+ : model.model,
168
232
  description: typeof model.description === "string" ? model.description : "",
169
233
  isDefault: model.isDefault === true,
170
234
  defaultReasoningEffort: reasoningEffort(model.defaultReasoningEffort) ?? "medium",
@@ -176,10 +240,14 @@ class CodexMetadataClient {
176
240
  const effort = reasoningEffort(entry.reasoningEffort);
177
241
  if (!effort)
178
242
  return [];
179
- return [{
243
+ return [
244
+ {
180
245
  reasoningEffort: effort,
181
- description: typeof entry.description === "string" ? entry.description : "",
182
- }];
246
+ description: typeof entry.description === "string"
247
+ ? entry.description
248
+ : "",
249
+ },
250
+ ];
183
251
  })
184
252
  : [],
185
253
  }));
@@ -221,7 +289,7 @@ class CodexMetadataClient {
221
289
  currentReasoningEffort: selectedEffort,
222
290
  models,
223
291
  planType: account.account?.type === "chatgpt"
224
- ? account.account.planType ?? null
292
+ ? (account.account.planType ?? null)
225
293
  : null,
226
294
  rateLimits,
227
295
  updatedAt: Date.now(),
@@ -244,7 +312,7 @@ function parseArgs(argv) {
244
312
  let openBrowserOnStart = true;
245
313
  let port = DEFAULT_PORT;
246
314
  let portWasExplicit = false;
247
- let provider = "codex";
315
+ let preferredProvider;
248
316
  for (let index = 0; index < argv.length; index++) {
249
317
  const arg = argv[index];
250
318
  const next = argv[index + 1];
@@ -278,7 +346,7 @@ function parseArgs(argv) {
278
346
  if (!(next in PROVIDERS)) {
279
347
  throw new Error("--provider must be codex, claude, or cursor");
280
348
  }
281
- provider = next;
349
+ preferredProvider = next;
282
350
  index++;
283
351
  }
284
352
  else if (arg.startsWith("--provider=")) {
@@ -286,7 +354,7 @@ function parseArgs(argv) {
286
354
  if (!(value in PROVIDERS)) {
287
355
  throw new Error("--provider must be codex, claude, or cursor");
288
356
  }
289
- provider = value;
357
+ preferredProvider = value;
290
358
  }
291
359
  else {
292
360
  throw new Error(`Unknown agent companion option: ${arg}`);
@@ -301,7 +369,7 @@ function parseArgs(argv) {
301
369
  openBrowser: openBrowserOnStart,
302
370
  port,
303
371
  portWasExplicit,
304
- provider,
372
+ preferredProvider,
305
373
  };
306
374
  }
307
375
  async function listenOnLoopback(server, preferredPort, allowFallback) {
@@ -365,8 +433,44 @@ function writeJson(res, status, value, headers) {
365
433
  res.writeHead(status, { ...headers, "Content-Type": "application/json" });
366
434
  res.end(JSON.stringify(value));
367
435
  }
436
+ export function resolveCompanionWorkspacePath(value, currentCwd, homeDirectory = os.homedir()) {
437
+ const input = value.trim();
438
+ if (!input || input.includes("\0") || input.length > 4_096) {
439
+ throw new Error("Enter a valid folder path");
440
+ }
441
+ if (input === "~")
442
+ return path.resolve(homeDirectory);
443
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
444
+ return path.resolve(homeDirectory, input.slice(2));
445
+ }
446
+ return path.resolve(currentCwd, input);
447
+ }
448
+ export async function browseCompanionWorkspace(value, currentCwd) {
449
+ const requested = resolveCompanionWorkspacePath(value, currentCwd);
450
+ const info = await stat(requested);
451
+ if (!info.isDirectory())
452
+ throw new Error("That path is not a folder");
453
+ const cwd = await realpath(requested);
454
+ const entries = await readdir(cwd, { withFileTypes: true });
455
+ const directories = entries
456
+ .filter((entry) => entry.isDirectory())
457
+ .map((entry) => ({ name: entry.name, path: path.join(cwd, entry.name) }))
458
+ .sort((left, right) => left.name.localeCompare(right.name, undefined, {
459
+ numeric: true,
460
+ sensitivity: "base",
461
+ }));
462
+ const root = path.parse(cwd).root;
463
+ return {
464
+ cwd,
465
+ parent: cwd === root ? null : path.dirname(cwd),
466
+ directories: directories.slice(0, 500),
467
+ truncated: directories.length > 500,
468
+ };
469
+ }
368
470
  export function buildCompanionPrompt(prompt, context) {
369
- const pathname = typeof context?.pathname === "string" ? context.pathname.slice(0, 500) : undefined;
471
+ const pathname = typeof context?.pathname === "string"
472
+ ? context.pathname.slice(0, 500)
473
+ : undefined;
370
474
  const routeContext = pathname
371
475
  ? `The user opened this chat from the Nudge route ${JSON.stringify(pathname)}.`
372
476
  : "The user opened this chat from Nudge.";
@@ -385,11 +489,15 @@ export function buildCompanionPrompt(prompt, context) {
385
489
  context.boardRole === "viewer"
386
490
  ? context.boardRole
387
491
  : undefined,
388
- view: typeof context.view === "string" ? context.view.slice(0, 50) : undefined,
492
+ view: typeof context.view === "string"
493
+ ? context.view.slice(0, 50)
494
+ : undefined,
389
495
  canvasId: typeof context.canvasId === "string"
390
496
  ? context.canvasId.slice(0, 200)
391
497
  : undefined,
392
- cardId: typeof context.cardId === "string" ? context.cardId.slice(0, 200) : undefined,
498
+ cardId: typeof context.cardId === "string"
499
+ ? context.cardId.slice(0, 200)
500
+ : undefined,
393
501
  cardTitle: typeof context.cardTitle === "string"
394
502
  ? context.cardTitle.slice(0, 500)
395
503
  : undefined,
@@ -399,7 +507,9 @@ export function buildCompanionPrompt(prompt, context) {
399
507
  cardHref: typeof context.cardHref === "string"
400
508
  ? context.cardHref.slice(0, 1_000)
401
509
  : undefined,
402
- listId: typeof context.listId === "string" ? context.listId.slice(0, 200) : undefined,
510
+ listId: typeof context.listId === "string"
511
+ ? context.listId.slice(0, 200)
512
+ : undefined,
403
513
  listTitle: typeof context.listTitle === "string"
404
514
  ? context.listTitle.slice(0, 500)
405
515
  : undefined,
@@ -409,8 +519,12 @@ export function buildCompanionPrompt(prompt, context) {
409
519
  repository: context.repository && typeof context.repository === "object"
410
520
  ? context.repository
411
521
  : undefined,
412
- listCount: typeof context.listCount === "number" ? context.listCount : undefined,
413
- cardCount: typeof context.cardCount === "number" ? context.cardCount : undefined,
522
+ listCount: typeof context.listCount === "number"
523
+ ? context.listCount
524
+ : undefined,
525
+ cardCount: typeof context.cardCount === "number"
526
+ ? context.cardCount
527
+ : undefined,
414
528
  }),
415
529
  "Use this exact board id for Nudge MCP calls unless the user names another board.",
416
530
  context.cardId
@@ -469,6 +583,20 @@ export function assertCompanionProvider(provider) {
469
583
  throw new Error(`${definition.displayName} is not authenticated. Run \`${definition.loginCommand}\`.${detail ? `\nCurrent status: ${detail}` : ""}`);
470
584
  }
471
585
  }
586
+ export function validatedCompanionProviders(validate = assertCompanionProvider) {
587
+ const available = [];
588
+ const failures = new Map();
589
+ for (const provider of Object.keys(PROVIDERS)) {
590
+ try {
591
+ validate(provider);
592
+ available.push(provider);
593
+ }
594
+ catch (error) {
595
+ failures.set(provider, error instanceof Error ? error.message : String(error));
596
+ }
597
+ }
598
+ return { available, failures };
599
+ }
472
600
  export function subscriptionEnvironment(environment = process.env) {
473
601
  const blocked = new Set([
474
602
  "ANTHROPIC_API_KEY",
@@ -479,6 +607,156 @@ export function subscriptionEnvironment(environment = process.env) {
479
607
  ]);
480
608
  return Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== undefined && !blocked.has(entry[0])));
481
609
  }
610
+ function modelDisplayName(model) {
611
+ if (model === "auto")
612
+ return "Auto";
613
+ return model
614
+ .replace(/[-_]+/g, " ")
615
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
616
+ }
617
+ function cursorModel(candidate) {
618
+ const record = candidate && typeof candidate === "object"
619
+ ? candidate
620
+ : null;
621
+ const rawModel = typeof candidate === "string"
622
+ ? candidate
623
+ : typeof record?.model === "string"
624
+ ? record.model
625
+ : typeof record?.id === "string"
626
+ ? record.id
627
+ : typeof record?.value === "string"
628
+ ? record.value
629
+ : "";
630
+ const model = rawModel.trim();
631
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._:/\[\]=,-]*$/.test(model))
632
+ return null;
633
+ const displayName = typeof record?.displayName === "string"
634
+ ? record.displayName
635
+ : typeof record?.name === "string"
636
+ ? record.name
637
+ : modelDisplayName(model);
638
+ return {
639
+ model,
640
+ displayName,
641
+ description: "Available to your Cursor account.",
642
+ isDefault: record?.isDefault === true || model === "auto",
643
+ defaultReasoningEffort: "none",
644
+ supportedReasoningEfforts: [],
645
+ };
646
+ }
647
+ /** Parse both current line output and future JSON output from Cursor's model command. */
648
+ export function parseCursorModels(output) {
649
+ const trimmed = output.trim();
650
+ let candidates = [];
651
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
652
+ try {
653
+ const parsed = JSON.parse(trimmed);
654
+ if (Array.isArray(parsed))
655
+ candidates = parsed;
656
+ else if (parsed && typeof parsed === "object") {
657
+ const record = parsed;
658
+ if (Array.isArray(record.models))
659
+ candidates = record.models;
660
+ }
661
+ }
662
+ catch {
663
+ // Older Cursor releases print one model id per line.
664
+ }
665
+ }
666
+ if (candidates.length === 0) {
667
+ candidates = output
668
+ .replace(/\u001b\[[0-9;]*m/g, "")
669
+ .split(/\r?\n/)
670
+ .map((line) => line.trim())
671
+ .filter((line) => line.length > 0)
672
+ .filter((line) => !/^(available\s+)?models?:?$/i.test(line))
673
+ .map((line) => line
674
+ .replace(/^[*✓●>•-]\s*/, "")
675
+ .replace(/\s+\((?:default|current)\)$/i, ""))
676
+ .map((line) => line.split(/\t|\s{2,}/, 1)[0]);
677
+ }
678
+ const models = candidates
679
+ .map((candidate) => cursorModel(candidate))
680
+ .filter((model) => model !== null);
681
+ const deduplicated = Array.from(new Map(models.map((model) => [model.model, model])).values());
682
+ if (deduplicated.length > 0 &&
683
+ !deduplicated.some((model) => model.isDefault)) {
684
+ deduplicated[0] = { ...deduplicated[0], isDefault: true };
685
+ }
686
+ return deduplicated;
687
+ }
688
+ function captureCliOutput(command, args) {
689
+ return new Promise((resolve, reject) => {
690
+ const child = spawn(command, args, {
691
+ env: subscriptionEnvironment(),
692
+ stdio: ["ignore", "pipe", "pipe"],
693
+ });
694
+ let stdout = "";
695
+ let stderr = "";
696
+ const timer = setTimeout(() => child.kill("SIGTERM"), 8_000);
697
+ child.stdout.on("data", (chunk) => {
698
+ stdout = `${stdout}${chunk.toString("utf8")}`.slice(-256_000);
699
+ });
700
+ child.stderr.on("data", (chunk) => {
701
+ stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8_000);
702
+ });
703
+ child.once("error", (error) => {
704
+ clearTimeout(timer);
705
+ reject(error);
706
+ });
707
+ child.once("close", (code) => {
708
+ clearTimeout(timer);
709
+ if (code === 0)
710
+ resolve(stdout);
711
+ else
712
+ reject(new Error(stderr.trim() || `Model discovery exited ${code}`));
713
+ });
714
+ });
715
+ }
716
+ async function subscriptionMetadata(provider, currentModel, currentEffort) {
717
+ let models;
718
+ if (provider === "claude") {
719
+ models = CLAUDE_MODELS;
720
+ }
721
+ else {
722
+ try {
723
+ models = parseCursorModels(await captureCliOutput(PROVIDERS.cursor.binary, ["--list-models"]));
724
+ }
725
+ catch {
726
+ models = [];
727
+ }
728
+ if (models.length === 0) {
729
+ models = [
730
+ {
731
+ model: "auto",
732
+ displayName: "Auto",
733
+ description: "Let Cursor choose the best available model.",
734
+ isDefault: true,
735
+ defaultReasoningEffort: "none",
736
+ supportedReasoningEfforts: [],
737
+ },
738
+ ];
739
+ }
740
+ }
741
+ const fallback = models.find((model) => model.isDefault) ?? models[0];
742
+ const selected = models.find((model) => model.model === currentModel) ?? fallback;
743
+ const effort = selected.supportedReasoningEfforts.some((candidate) => candidate.reasoningEffort === currentEffort)
744
+ ? currentEffort
745
+ : selected.defaultReasoningEffort;
746
+ return {
747
+ currentModel: selected.model,
748
+ currentReasoningEffort: effort,
749
+ models,
750
+ planType: "subscription",
751
+ rateLimits: [],
752
+ updatedAt: Date.now(),
753
+ provider: { id: provider, displayName: PROVIDERS[provider].displayName },
754
+ capabilities: {
755
+ modelSelection: true,
756
+ effortSelection: provider === "claude",
757
+ },
758
+ };
759
+ }
482
760
  export function companionMcpTransport() {
483
761
  const companionFile = fileURLToPath(import.meta.url);
484
762
  const runningFromTypeScript = companionFile.endsWith(".ts");
@@ -528,7 +806,7 @@ function companionClaudeConfig() {
528
806
  },
529
807
  });
530
808
  }
531
- export function companionCliInvocation(provider, prompt, threadId) {
809
+ export function companionCliInvocation(provider, prompt, threadId, model = null, effort = null) {
532
810
  const definition = PROVIDERS[provider];
533
811
  if (provider === "claude") {
534
812
  return {
@@ -548,6 +826,8 @@ export function companionCliInvocation(provider, prompt, threadId) {
548
826
  "--mcp-config",
549
827
  companionClaudeConfig(),
550
828
  "--strict-mcp-config",
829
+ ...(model && model !== "default" ? ["--model", model] : []),
830
+ ...(effort && effort !== "none" ? ["--effort", effort] : []),
551
831
  ...(threadId ? ["--resume", threadId] : []),
552
832
  ],
553
833
  stdin: prompt,
@@ -560,6 +840,7 @@ export function companionCliInvocation(provider, prompt, threadId) {
560
840
  "--mode=ask",
561
841
  "--output-format",
562
842
  "stream-json",
843
+ ...(model ? ["--model", model] : []),
563
844
  ...(threadId ? ["--resume", threadId] : []),
564
845
  prompt,
565
846
  ],
@@ -588,7 +869,9 @@ function cliActivity(provider, event) {
588
869
  if (!call || typeof call !== "object")
589
870
  return "Used a Cursor tool";
590
871
  const name = Object.keys(call)[0];
591
- return name ? `Used ${name.replace(/ToolCall$/, "")}` : "Used a Cursor tool";
872
+ return name
873
+ ? `Used ${name.replace(/ToolCall$/, "")}`
874
+ : "Used a Cursor tool";
592
875
  }
593
876
  if (provider === "claude" && event.type === "assistant") {
594
877
  const message = event.message;
@@ -600,9 +883,9 @@ function cliActivity(provider, event) {
600
883
  }
601
884
  return null;
602
885
  }
603
- async function runSubscriptionCliTurn({ provider, prompt, cwd, threadId, signal, send, }) {
886
+ async function runSubscriptionCliTurn({ provider, prompt, cwd, threadId, model, effort, signal, send, }) {
604
887
  const definition = PROVIDERS[provider];
605
- const invocation = companionCliInvocation(provider, prompt, threadId);
888
+ const invocation = companionCliInvocation(provider, prompt, threadId, model, effort);
606
889
  send({ type: "agent", event: { type: "turn.started" } });
607
890
  return await new Promise((resolve, reject) => {
608
891
  const child = spawn(invocation.command, invocation.args, {
@@ -614,7 +897,10 @@ async function runSubscriptionCliTurn({ provider, prompt, cwd, threadId, signal,
614
897
  let activeThreadId = threadId;
615
898
  let finalText = "";
616
899
  let usage;
617
- const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
900
+ const lines = readline.createInterface({
901
+ input: child.stdout,
902
+ crlfDelay: Infinity,
903
+ });
618
904
  lines.on("line", (line) => {
619
905
  if (!line.trim())
620
906
  return;
@@ -709,26 +995,66 @@ function companionThreadOptions(cwd, model, effort) {
709
995
  modelReasoningEffort: effort,
710
996
  };
711
997
  }
998
+ const PROVIDER_IDS = Object.keys(PROVIDERS);
712
999
  export async function runAgentCompanion(argv) {
713
1000
  const options = parseArgs(argv);
714
- const provider = PROVIDERS[options.provider];
1001
+ let workspaceCwd = options.cwd;
715
1002
  const allowedOrigin = new URL(options.appUrl).origin;
716
1003
  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;
1004
+ const validation = validatedCompanionProviders();
1005
+ const failures = validation.failures;
1006
+ const runtimes = new Map();
1007
+ for (const id of validation.available) {
1008
+ runtimes.set(id, {
1009
+ id,
1010
+ codex: id === "codex"
1011
+ ? new Codex({
1012
+ config: companionCodexConfig(),
1013
+ env: subscriptionEnvironment(),
1014
+ })
1015
+ : null,
1016
+ metadataClient: id === "codex" ? new CodexMetadataClient() : null,
1017
+ thread: null,
1018
+ cliThreadId: null,
1019
+ selectedModel: null,
1020
+ selectedEffort: null,
1021
+ });
1022
+ }
1023
+ if (runtimes.size === 0) {
1024
+ throw new Error([
1025
+ "Nudge Agent could not find an authenticated coding-agent subscription.",
1026
+ ...PROVIDER_IDS.map((id) => `${PROVIDERS[id].displayName}: ${failures.get(id) ?? "unavailable"}`),
1027
+ ].join("\n"));
1028
+ }
1029
+ const defaultProviderId = options.preferredProvider && runtimes.has(options.preferredProvider)
1030
+ ? options.preferredProvider
1031
+ : PROVIDER_IDS.find((id) => runtimes.has(id));
731
1032
  let activeAbort = null;
1033
+ const providerInfo = (id) => ({
1034
+ id,
1035
+ displayName: PROVIDERS[id].displayName,
1036
+ });
1037
+ const resolveRuntime = (value) => {
1038
+ const id = (value ?? defaultProviderId);
1039
+ return PROVIDER_IDS.includes(id) ? (runtimes.get(id) ?? null) : null;
1040
+ };
1041
+ const resetThreads = () => {
1042
+ for (const candidate of runtimes.values()) {
1043
+ candidate.thread = null;
1044
+ candidate.cliThreadId = null;
1045
+ }
1046
+ };
1047
+ const readMetadata = async (runtime) => {
1048
+ if (runtime.metadataClient) {
1049
+ const metadata = await runtime.metadataClient.metadata(runtime.selectedModel, runtime.selectedEffort);
1050
+ return {
1051
+ ...metadata,
1052
+ provider: providerInfo(runtime.id),
1053
+ capabilities: { modelSelection: true, effortSelection: true },
1054
+ };
1055
+ }
1056
+ return subscriptionMetadata(runtime.id, runtime.selectedModel, runtime.selectedEffort);
1057
+ };
732
1058
  const corsHeaders = (origin) => ({
733
1059
  ...(origin === allowedOrigin
734
1060
  ? { "Access-Control-Allow-Origin": allowedOrigin }
@@ -744,6 +1070,9 @@ export async function runAgentCompanion(argv) {
744
1070
  void (async () => {
745
1071
  const origin = req.headers.origin;
746
1072
  const headers = corsHeaders(origin);
1073
+ const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
1074
+ const runtime = resolveRuntime(requestUrl.searchParams.get("provider"));
1075
+ const provider = runtime ? PROVIDERS[runtime.id] : null;
747
1076
  if (origin && origin !== allowedOrigin) {
748
1077
  writeJson(res, 403, { error: "Origin not allowed" }, headers);
749
1078
  return;
@@ -757,67 +1086,82 @@ export async function runAgentCompanion(argv) {
757
1086
  writeJson(res, 401, { error: "Invalid companion token" }, headers);
758
1087
  return;
759
1088
  }
760
- if (req.method === "GET" && req.url === "/v1/health") {
1089
+ if (!runtime || !provider) {
1090
+ writeJson(res, 400, { error: "That coding-agent subscription is unavailable" }, headers);
1091
+ return;
1092
+ }
1093
+ if (req.method === "GET" && requestUrl.pathname === "/v1/health") {
761
1094
  writeJson(res, 200, {
762
1095
  ok: true,
763
- cwd: options.cwd,
1096
+ cwd: workspaceCwd,
764
1097
  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 },
1098
+ threadId: runtime.codex
1099
+ ? (runtime.thread?.id ?? null)
1100
+ : runtime.cliThreadId,
1101
+ model: runtime.selectedModel,
1102
+ effort: runtime.selectedEffort,
1103
+ provider: providerInfo(runtime.id),
1104
+ providers: Array.from(runtimes.keys(), providerInfo),
769
1105
  }, headers);
770
1106
  return;
771
1107
  }
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 },
1108
+ if (req.method === "GET" && requestUrl.pathname === "/v1/workspace") {
1109
+ try {
1110
+ const requestedPath = requestUrl.searchParams.get("path") ?? workspaceCwd;
1111
+ const listing = await browseCompanionWorkspace(requestedPath, workspaceCwd);
1112
+ writeJson(res, 200, listing, headers);
1113
+ }
1114
+ catch (error) {
1115
+ writeJson(res, 400, {
1116
+ error: error instanceof Error
1117
+ ? error.message
1118
+ : "That folder is unavailable",
792
1119
  }, headers);
1120
+ }
1121
+ return;
1122
+ }
1123
+ if (req.method === "POST" && requestUrl.pathname === "/v1/workspace") {
1124
+ if (activeAbort) {
1125
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
793
1126
  return;
794
1127
  }
1128
+ let parsed;
795
1129
  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 },
1130
+ parsed = JSON.parse(await readBody(req));
1131
+ const requestedPath = typeof parsed.cwd === "string" ? parsed.cwd : "";
1132
+ const listing = await browseCompanionWorkspace(requestedPath, workspaceCwd);
1133
+ workspaceCwd = listing.cwd;
1134
+ resetThreads();
1135
+ writeJson(res, 200, { ok: true, cwd: workspaceCwd }, headers);
1136
+ }
1137
+ catch (error) {
1138
+ writeJson(res, 400, {
1139
+ error: error instanceof Error
1140
+ ? error.message
1141
+ : "That folder is unavailable",
803
1142
  }, headers);
804
1143
  }
1144
+ return;
1145
+ }
1146
+ if (req.method === "GET" && requestUrl.pathname === "/v1/metadata") {
1147
+ try {
1148
+ const metadata = await readMetadata(runtime);
1149
+ runtime.selectedModel = metadata.currentModel;
1150
+ runtime.selectedEffort = metadata.currentReasoningEffort;
1151
+ writeJson(res, 200, metadata, headers);
1152
+ }
805
1153
  catch (error) {
806
1154
  writeJson(res, 503, {
807
1155
  error: error instanceof Error
808
1156
  ? error.message
809
- : "Codex metadata is unavailable",
1157
+ : `${provider.displayName} metadata is unavailable`,
810
1158
  }, headers);
811
1159
  }
812
1160
  return;
813
1161
  }
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
- }
1162
+ if (req.method === "POST" && requestUrl.pathname === "/v1/model") {
819
1163
  if (activeAbort) {
820
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1164
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
821
1165
  return;
822
1166
  }
823
1167
  let parsed;
@@ -829,25 +1173,29 @@ export async function runAgentCompanion(argv) {
829
1173
  return;
830
1174
  }
831
1175
  const model = typeof parsed.model === "string" ? parsed.model.trim() : "";
832
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
1176
+ const metadata = await readMetadata(runtime);
833
1177
  const modelInfo = metadata.models.find((candidate) => candidate.model === model);
834
1178
  if (!modelInfo) {
835
- writeJson(res, 400, { error: "That Codex model is unavailable" }, headers);
1179
+ writeJson(res, 400, { error: `That ${provider.displayName} model is unavailable` }, headers);
836
1180
  return;
837
1181
  }
838
- selectedModel = model;
839
- selectedEffort = modelInfo.defaultReasoningEffort;
840
- thread = null;
841
- writeJson(res, 200, { ok: true, model, effort: selectedEffort }, headers);
1182
+ runtime.selectedModel = model;
1183
+ runtime.selectedEffort = modelInfo.defaultReasoningEffort;
1184
+ runtime.thread = null;
1185
+ runtime.cliThreadId = null;
1186
+ writeJson(res, 200, { ok: true, model, effort: runtime.selectedEffort }, headers);
842
1187
  return;
843
1188
  }
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);
1189
+ if (req.method === "POST" && requestUrl.pathname === "/v1/effort") {
1190
+ if (activeAbort) {
1191
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
847
1192
  return;
848
1193
  }
849
- if (activeAbort) {
850
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1194
+ const metadata = await readMetadata(runtime);
1195
+ if (metadata.capabilities?.effortSelection === false) {
1196
+ writeJson(res, 400, {
1197
+ error: `${provider.displayName} controls reasoning settings in its own CLI.`,
1198
+ }, headers);
851
1199
  return;
852
1200
  }
853
1201
  let parsed;
@@ -859,33 +1207,32 @@ export async function runAgentCompanion(argv) {
859
1207
  return;
860
1208
  }
861
1209
  const effort = reasoningEffort(parsed.effort);
862
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
863
1210
  const modelInfo = metadata.models.find((candidate) => candidate.model === metadata.currentModel);
864
1211
  if (!effort ||
865
1212
  !modelInfo?.supportedReasoningEfforts.some((candidate) => candidate.reasoningEffort === effort)) {
866
1213
  writeJson(res, 400, { error: "That reasoning effort is unavailable" }, headers);
867
1214
  return;
868
1215
  }
869
- selectedModel = metadata.currentModel;
870
- selectedEffort = effort;
871
- thread = null;
872
- cliThreadId = null;
873
- writeJson(res, 200, { ok: true, model: selectedModel, effort }, headers);
1216
+ runtime.selectedModel = metadata.currentModel;
1217
+ runtime.selectedEffort = effort;
1218
+ runtime.thread = null;
1219
+ runtime.cliThreadId = null;
1220
+ writeJson(res, 200, { ok: true, model: runtime.selectedModel, effort }, headers);
874
1221
  return;
875
1222
  }
876
- if (req.method === "POST" && req.url === "/v1/new") {
1223
+ if (req.method === "POST" && requestUrl.pathname === "/v1/new") {
877
1224
  if (activeAbort) {
878
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1225
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
879
1226
  return;
880
1227
  }
881
- thread = null;
882
- cliThreadId = null;
1228
+ runtime.thread = null;
1229
+ runtime.cliThreadId = null;
883
1230
  writeJson(res, 200, { ok: true }, headers);
884
1231
  return;
885
1232
  }
886
- if (req.method === "POST" && req.url === "/v1/resume") {
1233
+ if (req.method === "POST" && requestUrl.pathname === "/v1/resume") {
887
1234
  if (activeAbort) {
888
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1235
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
889
1236
  return;
890
1237
  }
891
1238
  let parsed;
@@ -901,22 +1248,50 @@ export async function runAgentCompanion(argv) {
901
1248
  writeJson(res, 400, { error: "Invalid coding-agent session id" }, headers);
902
1249
  return;
903
1250
  }
904
- if (!metadataClient || !codex) {
905
- cliThreadId = threadId;
906
- writeJson(res, 200, { ok: true, threadId }, headers);
1251
+ if (!runtime.metadataClient || !runtime.codex) {
1252
+ const metadata = await readMetadata(runtime);
1253
+ const requestedModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
1254
+ const requestedEffort = reasoningEffort(parsed.effort);
1255
+ if (requestedModel &&
1256
+ !metadata.models.some((candidate) => candidate.model === requestedModel)) {
1257
+ writeJson(res, 400, { error: `That ${provider.displayName} model is unavailable` }, headers);
1258
+ return;
1259
+ }
1260
+ const nextModel = requestedModel || metadata.currentModel;
1261
+ const modelInfo = metadata.models.find((candidate) => candidate.model === nextModel);
1262
+ const cursorDefaultEffort = metadata.capabilities?.effortSelection === false &&
1263
+ requestedEffort === "none";
1264
+ if (parsed.effort !== undefined &&
1265
+ (!requestedEffort ||
1266
+ (!cursorDefaultEffort &&
1267
+ !modelInfo?.supportedReasoningEfforts.some((candidate) => candidate.reasoningEffort === requestedEffort)))) {
1268
+ writeJson(res, 400, { error: "That reasoning effort is unavailable" }, headers);
1269
+ return;
1270
+ }
1271
+ runtime.selectedModel = nextModel;
1272
+ runtime.selectedEffort =
1273
+ requestedEffort ?? modelInfo?.defaultReasoningEffort ?? "none";
1274
+ runtime.cliThreadId = threadId;
1275
+ writeJson(res, 200, {
1276
+ ok: true,
1277
+ threadId,
1278
+ model: runtime.selectedModel,
1279
+ effort: runtime.selectedEffort,
1280
+ }, headers);
907
1281
  return;
908
1282
  }
909
1283
  const requestedModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
910
1284
  const requestedEffort = reasoningEffort(parsed.effort);
911
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
1285
+ const metadata = await runtime.metadataClient.metadata(runtime.selectedModel, runtime.selectedEffort);
912
1286
  if (requestedModel) {
913
1287
  if (!metadata.models.some((candidate) => candidate.model === requestedModel)) {
914
1288
  writeJson(res, 400, { error: "That Codex model is unavailable" }, headers);
915
1289
  return;
916
1290
  }
917
- selectedModel = requestedModel;
1291
+ runtime.selectedModel = requestedModel;
918
1292
  }
919
- const modelInfo = metadata.models.find((candidate) => candidate.model === (selectedModel ?? metadata.currentModel));
1293
+ const modelInfo = metadata.models.find((candidate) => candidate.model ===
1294
+ (runtime.selectedModel ?? metadata.currentModel));
920
1295
  if (parsed.effort !== undefined && !requestedEffort) {
921
1296
  writeJson(res, 400, { error: "Invalid reasoning effort" }, headers);
922
1297
  return;
@@ -926,17 +1301,23 @@ export async function runAgentCompanion(argv) {
926
1301
  writeJson(res, 400, { error: "That reasoning effort is unavailable" }, headers);
927
1302
  return;
928
1303
  }
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);
1304
+ runtime.selectedEffort =
1305
+ requestedEffort ?? modelInfo?.defaultReasoningEffort ?? null;
1306
+ runtime.thread = runtime.codex.resumeThread(threadId, companionThreadOptions(workspaceCwd, runtime.selectedModel, runtime.selectedEffort));
1307
+ writeJson(res, 200, {
1308
+ ok: true,
1309
+ threadId,
1310
+ model: runtime.selectedModel,
1311
+ effort: runtime.selectedEffort,
1312
+ }, headers);
932
1313
  return;
933
1314
  }
934
- if (req.method === "POST" && req.url === "/v1/cancel") {
1315
+ if (req.method === "POST" && requestUrl.pathname === "/v1/cancel") {
935
1316
  activeAbort?.abort();
936
1317
  writeJson(res, 200, { ok: true }, headers);
937
1318
  return;
938
1319
  }
939
- if (req.method !== "POST" || req.url !== "/v1/chat") {
1320
+ if (req.method !== "POST" || requestUrl.pathname !== "/v1/chat") {
940
1321
  writeJson(res, 404, { error: "Not found" }, headers);
941
1322
  return;
942
1323
  }
@@ -957,8 +1338,8 @@ export async function runAgentCompanion(argv) {
957
1338
  writeJson(res, 400, { error: "prompt is required" }, headers);
958
1339
  return;
959
1340
  }
960
- if (codex) {
961
- thread ??= codex.startThread(companionThreadOptions(options.cwd, selectedModel, selectedEffort));
1341
+ if (runtime.codex) {
1342
+ runtime.thread ??= runtime.codex.startThread(companionThreadOptions(workspaceCwd, runtime.selectedModel, runtime.selectedEffort));
962
1343
  }
963
1344
  const abort = new AbortController();
964
1345
  activeAbort = abort;
@@ -977,11 +1358,17 @@ export async function runAgentCompanion(argv) {
977
1358
  res.write(`${JSON.stringify(value)}\n`);
978
1359
  };
979
1360
  try {
980
- const currentThreadId = codex ? thread?.id ?? null : cliThreadId;
981
- send({ type: "connected", cwd: options.cwd, threadId: currentThreadId });
1361
+ const currentThreadId = runtime.codex
1362
+ ? (runtime.thread?.id ?? null)
1363
+ : runtime.cliThreadId;
1364
+ send({
1365
+ type: "connected",
1366
+ cwd: workspaceCwd,
1367
+ threadId: currentThreadId,
1368
+ });
982
1369
  const contextualPrompt = buildCompanionPrompt(prompt, parsed.context);
983
- if (codex && thread) {
984
- const streamed = await thread.runStreamed(contextualPrompt, {
1370
+ if (runtime.codex && runtime.thread) {
1371
+ const streamed = await runtime.thread.runStreamed(contextualPrompt, {
985
1372
  signal: abort.signal,
986
1373
  });
987
1374
  for await (const event of streamed.events) {
@@ -989,16 +1376,23 @@ export async function runAgentCompanion(argv) {
989
1376
  }
990
1377
  }
991
1378
  else {
992
- cliThreadId = await runSubscriptionCliTurn({
993
- provider: options.provider,
1379
+ runtime.cliThreadId = await runSubscriptionCliTurn({
1380
+ provider: runtime.id,
994
1381
  prompt: contextualPrompt,
995
- cwd: options.cwd,
996
- threadId: cliThreadId,
1382
+ cwd: workspaceCwd,
1383
+ threadId: runtime.cliThreadId,
1384
+ model: runtime.selectedModel,
1385
+ effort: runtime.selectedEffort,
997
1386
  signal: abort.signal,
998
1387
  send,
999
1388
  });
1000
1389
  }
1001
- send({ type: "done", threadId: codex ? thread?.id ?? null : cliThreadId });
1390
+ send({
1391
+ type: "done",
1392
+ threadId: runtime.codex
1393
+ ? (runtime.thread?.id ?? null)
1394
+ : runtime.cliThreadId,
1395
+ });
1002
1396
  }
1003
1397
  catch (error) {
1004
1398
  send({
@@ -1024,12 +1418,13 @@ export async function runAgentCompanion(argv) {
1024
1418
  const pairing = Buffer.from(JSON.stringify({
1025
1419
  port,
1026
1420
  token,
1027
- provider: { id: options.provider, displayName: provider.displayName },
1421
+ provider: providerInfo(defaultProviderId),
1422
+ providers: Array.from(runtimes.keys(), providerInfo),
1028
1423
  }), "utf8").toString("base64url");
1029
1424
  const url = companionPairingUrl(options.appUrl, pairing);
1030
1425
  console.log("");
1031
1426
  console.log("Nudge Agent companion is running");
1032
- console.log(` Agent: ${provider.displayName}`);
1427
+ console.log(` Agents: ${Array.from(runtimes.keys(), (id) => PROVIDERS[id].displayName).join(", ")}`);
1033
1428
  console.log(` App: ${options.appUrl}`);
1034
1429
  console.log(` Workspace: ${options.cwd}`);
1035
1430
  console.log(` Listener: http://127.0.0.1:${port}`);
@@ -1038,7 +1433,15 @@ export async function runAgentCompanion(argv) {
1038
1433
  }
1039
1434
  console.log(" Files: read-only");
1040
1435
  console.log("");
1041
- console.log(`${provider.displayName} will reuse the account from \`${provider.loginCommand}\`.`);
1436
+ for (const id of PROVIDER_IDS) {
1437
+ const definition = PROVIDERS[id];
1438
+ if (runtimes.has(id)) {
1439
+ console.log(` ready ${definition.displayName} — reusing \`${definition.loginCommand}\``);
1440
+ }
1441
+ else {
1442
+ console.log(` unavailable ${definition.displayName} — ${failures.get(id) ?? "not found"}`);
1443
+ }
1444
+ }
1042
1445
  console.log("Keep this terminal open while using Nudge chat.");
1043
1446
  console.log("");
1044
1447
  if (options.openBrowser)
@@ -1048,7 +1451,8 @@ export async function runAgentCompanion(argv) {
1048
1451
  await new Promise((resolve) => {
1049
1452
  const shutdown = () => {
1050
1453
  activeAbort?.abort();
1051
- metadataClient?.stop();
1454
+ for (const runtime of runtimes.values())
1455
+ runtime.metadataClient?.stop();
1052
1456
  server.close(() => resolve());
1053
1457
  };
1054
1458
  process.once("SIGINT", shutdown);