@euqns/nudge-mcp 0.15.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";
@@ -10,6 +10,79 @@ import { NUDGE_MCP_VERSION } from "./version.js";
10
10
  const DEFAULT_APP_URL = "https://funjitsu-nudge.vercel.app";
11
11
  const DEFAULT_PORT = 47653;
12
12
  const MAX_BODY_BYTES = 64 * 1024;
13
+ const PROVIDERS = {
14
+ codex: {
15
+ displayName: "Codex",
16
+ binary: process.platform === "win32" ? "codex.cmd" : "codex",
17
+ loginCommand: "codex login",
18
+ },
19
+ claude: {
20
+ displayName: "Claude Code",
21
+ binary: process.platform === "win32" ? "claude.cmd" : "claude",
22
+ loginCommand: "claude auth login",
23
+ },
24
+ cursor: {
25
+ displayName: "Cursor",
26
+ binary: process.platform === "win32" ? "cursor-agent.cmd" : "cursor-agent",
27
+ loginCommand: "cursor-agent login",
28
+ },
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
+ ];
13
86
  const require = createRequire(import.meta.url);
14
87
  const REASONING_EFFORTS = new Set([
15
88
  "none",
@@ -22,7 +95,8 @@ const REASONING_EFFORTS = new Set([
22
95
  "ultra",
23
96
  ]);
24
97
  function reasoningEffort(value) {
25
- return typeof value === "string" && REASONING_EFFORTS.has(value)
98
+ return typeof value === "string" &&
99
+ REASONING_EFFORTS.has(value)
26
100
  ? value
27
101
  : null;
28
102
  }
@@ -60,7 +134,10 @@ class CodexMetadataClient {
60
134
  });
61
135
  this.child = child;
62
136
  this.stderr = "";
63
- const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
137
+ const lines = readline.createInterface({
138
+ input: child.stdout,
139
+ crlfDelay: Infinity,
140
+ });
64
141
  lines.on("line", (line) => this.handleLine(line));
65
142
  child.stderr.on("data", (chunk) => {
66
143
  this.stderr = `${this.stderr}${chunk.toString("utf8")}`.slice(-4_000);
@@ -73,7 +150,7 @@ class CodexMetadataClient {
73
150
  await this.rawRequest("initialize", {
74
151
  clientInfo: {
75
152
  name: "nudge-companion",
76
- title: "Nudge Local Codex",
153
+ title: "Nudge Agent",
77
154
  version: NUDGE_MCP_VERSION,
78
155
  },
79
156
  capabilities: {
@@ -147,7 +224,9 @@ class CodexMetadataClient {
147
224
  .filter((model) => typeof model.model === "string" && model.model.length > 0)
148
225
  .map((model) => ({
149
226
  model: model.model,
150
- displayName: typeof model.displayName === "string" ? model.displayName : model.model,
227
+ displayName: typeof model.displayName === "string"
228
+ ? model.displayName
229
+ : model.model,
151
230
  description: typeof model.description === "string" ? model.description : "",
152
231
  isDefault: model.isDefault === true,
153
232
  defaultReasoningEffort: reasoningEffort(model.defaultReasoningEffort) ?? "medium",
@@ -159,10 +238,14 @@ class CodexMetadataClient {
159
238
  const effort = reasoningEffort(entry.reasoningEffort);
160
239
  if (!effort)
161
240
  return [];
162
- return [{
241
+ return [
242
+ {
163
243
  reasoningEffort: effort,
164
- description: typeof entry.description === "string" ? entry.description : "",
165
- }];
244
+ description: typeof entry.description === "string"
245
+ ? entry.description
246
+ : "",
247
+ },
248
+ ];
166
249
  })
167
250
  : [],
168
251
  }));
@@ -204,7 +287,7 @@ class CodexMetadataClient {
204
287
  currentReasoningEffort: selectedEffort,
205
288
  models,
206
289
  planType: account.account?.type === "chatgpt"
207
- ? account.account.planType ?? null
290
+ ? (account.account.planType ?? null)
208
291
  : null,
209
292
  rateLimits,
210
293
  updatedAt: Date.now(),
@@ -227,6 +310,7 @@ function parseArgs(argv) {
227
310
  let openBrowserOnStart = true;
228
311
  let port = DEFAULT_PORT;
229
312
  let portWasExplicit = false;
313
+ let preferredProvider;
230
314
  for (let index = 0; index < argv.length; index++) {
231
315
  const arg = argv[index];
232
316
  const next = argv[index + 1];
@@ -256,8 +340,22 @@ function parseArgs(argv) {
256
340
  else if (arg === "--no-open") {
257
341
  openBrowserOnStart = false;
258
342
  }
343
+ else if (arg === "--provider" && next) {
344
+ if (!(next in PROVIDERS)) {
345
+ throw new Error("--provider must be codex, claude, or cursor");
346
+ }
347
+ preferredProvider = next;
348
+ index++;
349
+ }
350
+ else if (arg.startsWith("--provider=")) {
351
+ const value = arg.slice("--provider=".length);
352
+ if (!(value in PROVIDERS)) {
353
+ throw new Error("--provider must be codex, claude, or cursor");
354
+ }
355
+ preferredProvider = value;
356
+ }
259
357
  else {
260
- throw new Error(`Unknown codex companion option: ${arg}`);
358
+ throw new Error(`Unknown agent companion option: ${arg}`);
261
359
  }
262
360
  }
263
361
  if (!Number.isInteger(port) || port < 1024 || port > 65535) {
@@ -269,6 +367,7 @@ function parseArgs(argv) {
269
367
  openBrowser: openBrowserOnStart,
270
368
  port,
271
369
  portWasExplicit,
370
+ preferredProvider,
272
371
  };
273
372
  }
274
373
  async function listenOnLoopback(server, preferredPort, allowFallback) {
@@ -332,8 +431,10 @@ function writeJson(res, status, value, headers) {
332
431
  res.writeHead(status, { ...headers, "Content-Type": "application/json" });
333
432
  res.end(JSON.stringify(value));
334
433
  }
335
- function buildPrompt(prompt, context) {
336
- const pathname = typeof context?.pathname === "string" ? context.pathname.slice(0, 500) : undefined;
434
+ export function buildCompanionPrompt(prompt, context) {
435
+ const pathname = typeof context?.pathname === "string"
436
+ ? context.pathname.slice(0, 500)
437
+ : undefined;
337
438
  const routeContext = pathname
338
439
  ? `The user opened this chat from the Nudge route ${JSON.stringify(pathname)}.`
339
440
  : "The user opened this chat from Nudge.";
@@ -347,21 +448,62 @@ function buildPrompt(prompt, context) {
347
448
  source: context.boardSource === "cloud" || context.boardSource === "local"
348
449
  ? context.boardSource
349
450
  : undefined,
350
- view: typeof context.view === "string" ? context.view.slice(0, 50) : undefined,
451
+ role: context.boardRole === "owner" ||
452
+ context.boardRole === "editor" ||
453
+ context.boardRole === "viewer"
454
+ ? context.boardRole
455
+ : undefined,
456
+ view: typeof context.view === "string"
457
+ ? context.view.slice(0, 50)
458
+ : undefined,
351
459
  canvasId: typeof context.canvasId === "string"
352
460
  ? context.canvasId.slice(0, 200)
353
461
  : undefined,
354
- cardId: typeof context.cardId === "string" ? context.cardId.slice(0, 200) : undefined,
355
- listCount: typeof context.listCount === "number" ? context.listCount : undefined,
356
- cardCount: typeof context.cardCount === "number" ? context.cardCount : undefined,
462
+ cardId: typeof context.cardId === "string"
463
+ ? context.cardId.slice(0, 200)
464
+ : undefined,
465
+ cardTitle: typeof context.cardTitle === "string"
466
+ ? context.cardTitle.slice(0, 500)
467
+ : undefined,
468
+ cardDescription: typeof context.cardDescription === "string"
469
+ ? context.cardDescription.slice(0, 4_000)
470
+ : undefined,
471
+ cardHref: typeof context.cardHref === "string"
472
+ ? context.cardHref.slice(0, 1_000)
473
+ : undefined,
474
+ listId: typeof context.listId === "string"
475
+ ? context.listId.slice(0, 200)
476
+ : undefined,
477
+ listTitle: typeof context.listTitle === "string"
478
+ ? context.listTitle.slice(0, 500)
479
+ : undefined,
480
+ selectionSource: typeof context.selectionSource === "string"
481
+ ? context.selectionSource.slice(0, 50)
482
+ : undefined,
483
+ repository: context.repository && typeof context.repository === "object"
484
+ ? context.repository
485
+ : undefined,
486
+ listCount: typeof context.listCount === "number"
487
+ ? context.listCount
488
+ : undefined,
489
+ cardCount: typeof context.cardCount === "number"
490
+ ? context.cardCount
491
+ : undefined,
357
492
  }),
358
493
  "Use this exact board id for Nudge MCP calls unless the user names another board.",
494
+ context.cardId
495
+ ? "Treat the selected card as the subject when the user says this task, this card, or it."
496
+ : "No card is selected; do not guess which card the user means.",
359
497
  ]
360
498
  : [];
361
499
  return [
362
500
  routeContext,
363
501
  ...boardContext,
364
502
  "Use the configured Nudge MCP tools when the request needs board or card data.",
503
+ "Board mutations use a two-step confirmation workflow. First inspect enough data to show a concise preview naming every item and field that will change. Do not call a write tool until a later user message explicitly confirms that preview. A request such as 'create this' starts the preview; it is not confirmation. After execution, report success and include the returned Nudge link so the result opens in chat.",
504
+ "Call out deletes, replacement descriptions, membership changes, and terminal status moves as irreversible or potentially disruptive before confirmation. If permissions reject an operation, explain the required board role and offer a retry after access changes.",
505
+ "Local boards cannot be changed through Nudge MCP, and viewer-role cloud boards are read-only. Explain that permission state before proposing a write and do not attempt the mutation.",
506
+ "For a coding-agent handoff, use the nudge.agent-handoff/v1 sections: task brief, acceptance criteria, relevant links, repository context, and return path. The format must work for Claude Code, Codex, and Cursor. Runner output should be summarized back to the originating card with add_comment, after the same write preview and confirmation.",
365
507
  "Be concise about progress and return a clear final answer for the Nudge chat UI.",
366
508
  "The local filesystem is read-only. Nudge MCP tools may update boards when requested; do not claim that local files were changed.",
367
509
  "",
@@ -386,13 +528,198 @@ export function assertChatGptLogin() {
386
528
  ].join("\n"));
387
529
  }
388
530
  }
389
- export function subscriptionEnvironment() {
531
+ export function assertCompanionProvider(provider) {
532
+ if (provider === "codex") {
533
+ assertChatGptLogin();
534
+ return;
535
+ }
536
+ const definition = PROVIDERS[provider];
537
+ const args = provider === "claude" ? ["auth", "status"] : ["status"];
538
+ const result = spawnSync(definition.binary, args, {
539
+ encoding: "utf8",
540
+ stdio: ["ignore", "pipe", "pipe"],
541
+ });
542
+ if (result.error) {
543
+ throw new Error(`${definition.displayName} CLI was not found. Install it, then run \`${definition.loginCommand}\`.`);
544
+ }
545
+ if (result.status !== 0) {
546
+ const detail = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
547
+ throw new Error(`${definition.displayName} is not authenticated. Run \`${definition.loginCommand}\`.${detail ? `\nCurrent status: ${detail}` : ""}`);
548
+ }
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
+ }
564
+ export function subscriptionEnvironment(environment = process.env) {
390
565
  const blocked = new Set([
566
+ "ANTHROPIC_API_KEY",
567
+ "CURSOR_API_KEY",
391
568
  "CODEX_ACCESS_TOKEN",
392
569
  "CODEX_API_KEY",
393
570
  "OPENAI_API_KEY",
394
571
  ]);
395
- return Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined && !blocked.has(entry[0])));
572
+ return Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== undefined && !blocked.has(entry[0])));
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
+ };
396
723
  }
397
724
  export function companionMcpTransport() {
398
725
  const companionFile = fileURLToPath(import.meta.url);
@@ -432,6 +759,194 @@ export function companionCodexConfig(env) {
432
759
  },
433
760
  };
434
761
  }
762
+ function companionClaudeConfig() {
763
+ const transport = companionMcpTransport();
764
+ return JSON.stringify({
765
+ mcpServers: {
766
+ nudge: {
767
+ command: transport.command,
768
+ args: transport.args,
769
+ },
770
+ },
771
+ });
772
+ }
773
+ export function companionCliInvocation(provider, prompt, threadId, model = null, effort = null) {
774
+ const definition = PROVIDERS[provider];
775
+ if (provider === "claude") {
776
+ return {
777
+ command: definition.binary,
778
+ args: [
779
+ "-p",
780
+ "--output-format",
781
+ "stream-json",
782
+ "--verbose",
783
+ "--permission-mode",
784
+ "bypassPermissions",
785
+ "--disallowedTools",
786
+ "Edit",
787
+ "Write",
788
+ "NotebookEdit",
789
+ "Bash",
790
+ "--mcp-config",
791
+ companionClaudeConfig(),
792
+ "--strict-mcp-config",
793
+ ...(model && model !== "default" ? ["--model", model] : []),
794
+ ...(effort && effort !== "none" ? ["--effort", effort] : []),
795
+ ...(threadId ? ["--resume", threadId] : []),
796
+ ],
797
+ stdin: prompt,
798
+ };
799
+ }
800
+ return {
801
+ command: definition.binary,
802
+ args: [
803
+ "-p",
804
+ "--mode=ask",
805
+ "--output-format",
806
+ "stream-json",
807
+ ...(model ? ["--model", model] : []),
808
+ ...(threadId ? ["--resume", threadId] : []),
809
+ prompt,
810
+ ],
811
+ stdin: null,
812
+ };
813
+ }
814
+ export function companionPairingUrl(appUrl, pairing) {
815
+ return `${appUrl}/#nudge-agent=${pairing}`;
816
+ }
817
+ function syntheticUsage(usage) {
818
+ return {
819
+ input_tokens: typeof usage?.input_tokens === "number" ? usage.input_tokens : 0,
820
+ cached_input_tokens: typeof usage?.cache_read_input_tokens === "number"
821
+ ? usage.cache_read_input_tokens
822
+ : 0,
823
+ cache_write_input_tokens: typeof usage?.cache_creation_input_tokens === "number"
824
+ ? usage.cache_creation_input_tokens
825
+ : 0,
826
+ output_tokens: typeof usage?.output_tokens === "number" ? usage.output_tokens : 0,
827
+ reasoning_output_tokens: 0,
828
+ };
829
+ }
830
+ function cliActivity(provider, event) {
831
+ if (provider === "cursor" && event.type === "tool_call") {
832
+ const call = event.tool_call;
833
+ if (!call || typeof call !== "object")
834
+ return "Used a Cursor tool";
835
+ const name = Object.keys(call)[0];
836
+ return name
837
+ ? `Used ${name.replace(/ToolCall$/, "")}`
838
+ : "Used a Cursor tool";
839
+ }
840
+ if (provider === "claude" && event.type === "assistant") {
841
+ const message = event.message;
842
+ const blocks = Array.isArray(message?.content) ? message.content : [];
843
+ const tool = blocks.find((block) => !!block &&
844
+ typeof block === "object" &&
845
+ block.type === "tool_use");
846
+ return tool?.name ? `Used ${String(tool.name)}` : null;
847
+ }
848
+ return null;
849
+ }
850
+ async function runSubscriptionCliTurn({ provider, prompt, cwd, threadId, model, effort, signal, send, }) {
851
+ const definition = PROVIDERS[provider];
852
+ const invocation = companionCliInvocation(provider, prompt, threadId, model, effort);
853
+ send({ type: "agent", event: { type: "turn.started" } });
854
+ return await new Promise((resolve, reject) => {
855
+ const child = spawn(invocation.command, invocation.args, {
856
+ cwd,
857
+ stdio: ["pipe", "pipe", "pipe"],
858
+ env: subscriptionEnvironment(),
859
+ });
860
+ let stderr = "";
861
+ let activeThreadId = threadId;
862
+ let finalText = "";
863
+ let usage;
864
+ const lines = readline.createInterface({
865
+ input: child.stdout,
866
+ crlfDelay: Infinity,
867
+ });
868
+ lines.on("line", (line) => {
869
+ if (!line.trim())
870
+ return;
871
+ let event;
872
+ try {
873
+ event = JSON.parse(line);
874
+ }
875
+ catch {
876
+ return;
877
+ }
878
+ if (typeof event.session_id === "string") {
879
+ activeThreadId = event.session_id;
880
+ if (event.type === "system") {
881
+ send({
882
+ type: "agent",
883
+ event: { type: "thread.started", thread_id: activeThreadId },
884
+ });
885
+ }
886
+ }
887
+ const activity = cliActivity(provider, event);
888
+ if (activity) {
889
+ const id = typeof event.call_id === "string"
890
+ ? event.call_id
891
+ : `tool-${Date.now().toString(36)}`;
892
+ send({
893
+ type: "agent",
894
+ event: {
895
+ type: event.subtype === "completed" ? "item.completed" : "item.started",
896
+ item: { id, type: "reasoning", text: activity },
897
+ },
898
+ });
899
+ }
900
+ if (event.type === "result") {
901
+ if (typeof event.result === "string")
902
+ finalText = event.result;
903
+ if (event.usage && typeof event.usage === "object") {
904
+ usage = event.usage;
905
+ }
906
+ }
907
+ });
908
+ child.stderr.on("data", (chunk) => {
909
+ stderr = `${stderr}${chunk.toString("utf8")}`.slice(-8_000);
910
+ });
911
+ const onAbort = () => child.kill("SIGTERM");
912
+ signal.addEventListener("abort", onAbort, { once: true });
913
+ child.once("error", (error) => {
914
+ signal.removeEventListener("abort", onAbort);
915
+ reject(error);
916
+ });
917
+ child.once("close", (code) => {
918
+ signal.removeEventListener("abort", onAbort);
919
+ if (signal.aborted) {
920
+ resolve(activeThreadId);
921
+ return;
922
+ }
923
+ if (code !== 0) {
924
+ reject(new Error(`${definition.displayName} exited ${code}: ${stderr.trim().slice(-800) || "no error detail"}`));
925
+ return;
926
+ }
927
+ send({
928
+ type: "agent",
929
+ event: {
930
+ type: "item.completed",
931
+ item: {
932
+ id: `message-${Date.now().toString(36)}`,
933
+ type: "agent_message",
934
+ text: finalText,
935
+ },
936
+ },
937
+ });
938
+ send({
939
+ type: "agent",
940
+ event: { type: "turn.completed", usage: syntheticUsage(usage) },
941
+ });
942
+ resolve(activeThreadId);
943
+ });
944
+ if (invocation.stdin !== null) {
945
+ child.stdin.write(invocation.stdin);
946
+ }
947
+ child.stdin.end();
948
+ });
949
+ }
435
950
  function companionThreadOptions(cwd, model, effort) {
436
951
  return {
437
952
  approvalPolicy: "never",
@@ -444,20 +959,59 @@ function companionThreadOptions(cwd, model, effort) {
444
959
  modelReasoningEffort: effort,
445
960
  };
446
961
  }
447
- export async function runCodexCompanion(argv) {
962
+ const PROVIDER_IDS = Object.keys(PROVIDERS);
963
+ export async function runAgentCompanion(argv) {
448
964
  const options = parseArgs(argv);
449
965
  const allowedOrigin = new URL(options.appUrl).origin;
450
966
  const token = crypto.randomBytes(32).toString("base64url");
451
- assertChatGptLogin();
452
- const codex = new Codex({
453
- config: companionCodexConfig(),
454
- env: subscriptionEnvironment(),
455
- });
456
- const metadataClient = new CodexMetadataClient();
457
- let thread = null;
458
- let selectedModel = null;
459
- 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));
460
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
+ };
461
1015
  const corsHeaders = (origin) => ({
462
1016
  ...(origin === allowedOrigin
463
1017
  ? { "Access-Control-Allow-Origin": allowedOrigin }
@@ -473,6 +1027,9 @@ export async function runCodexCompanion(argv) {
473
1027
  void (async () => {
474
1028
  const origin = req.headers.origin;
475
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;
476
1033
  if (origin && origin !== allowedOrigin) {
477
1034
  writeJson(res, 403, { error: "Origin not allowed" }, headers);
478
1035
  return;
@@ -486,36 +1043,44 @@ export async function runCodexCompanion(argv) {
486
1043
  writeJson(res, 401, { error: "Invalid companion token" }, headers);
487
1044
  return;
488
1045
  }
489
- 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") {
490
1051
  writeJson(res, 200, {
491
1052
  ok: true,
492
1053
  cwd: options.cwd,
493
1054
  running: activeAbort !== null,
494
- threadId: thread?.id ?? null,
495
- model: selectedModel,
496
- effort: selectedEffort,
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),
497
1062
  }, headers);
498
1063
  return;
499
1064
  }
500
- if (req.method === "GET" && req.url === "/v1/metadata") {
1065
+ if (req.method === "GET" && requestUrl.pathname === "/v1/metadata") {
501
1066
  try {
502
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
503
- selectedModel = metadata.currentModel;
504
- selectedEffort = metadata.currentReasoningEffort;
1067
+ const metadata = await readMetadata(runtime);
1068
+ runtime.selectedModel = metadata.currentModel;
1069
+ runtime.selectedEffort = metadata.currentReasoningEffort;
505
1070
  writeJson(res, 200, metadata, headers);
506
1071
  }
507
1072
  catch (error) {
508
1073
  writeJson(res, 503, {
509
1074
  error: error instanceof Error
510
1075
  ? error.message
511
- : "Codex metadata is unavailable",
1076
+ : `${provider.displayName} metadata is unavailable`,
512
1077
  }, headers);
513
1078
  }
514
1079
  return;
515
1080
  }
516
- if (req.method === "POST" && req.url === "/v1/model") {
1081
+ if (req.method === "POST" && requestUrl.pathname === "/v1/model") {
517
1082
  if (activeAbort) {
518
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1083
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
519
1084
  return;
520
1085
  }
521
1086
  let parsed;
@@ -527,21 +1092,29 @@ export async function runCodexCompanion(argv) {
527
1092
  return;
528
1093
  }
529
1094
  const model = typeof parsed.model === "string" ? parsed.model.trim() : "";
530
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
1095
+ const metadata = await readMetadata(runtime);
531
1096
  const modelInfo = metadata.models.find((candidate) => candidate.model === model);
532
1097
  if (!modelInfo) {
533
- writeJson(res, 400, { error: "That Codex model is unavailable" }, headers);
1098
+ writeJson(res, 400, { error: `That ${provider.displayName} model is unavailable` }, headers);
534
1099
  return;
535
1100
  }
536
- selectedModel = model;
537
- selectedEffort = modelInfo.defaultReasoningEffort;
538
- thread = null;
539
- 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);
540
1106
  return;
541
1107
  }
542
- if (req.method === "POST" && req.url === "/v1/effort") {
1108
+ if (req.method === "POST" && requestUrl.pathname === "/v1/effort") {
543
1109
  if (activeAbort) {
544
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1110
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
1111
+ return;
1112
+ }
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);
545
1118
  return;
546
1119
  }
547
1120
  let parsed;
@@ -553,31 +1126,32 @@ export async function runCodexCompanion(argv) {
553
1126
  return;
554
1127
  }
555
1128
  const effort = reasoningEffort(parsed.effort);
556
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
557
1129
  const modelInfo = metadata.models.find((candidate) => candidate.model === metadata.currentModel);
558
1130
  if (!effort ||
559
1131
  !modelInfo?.supportedReasoningEfforts.some((candidate) => candidate.reasoningEffort === effort)) {
560
1132
  writeJson(res, 400, { error: "That reasoning effort is unavailable" }, headers);
561
1133
  return;
562
1134
  }
563
- selectedModel = metadata.currentModel;
564
- selectedEffort = effort;
565
- thread = null;
566
- 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);
567
1140
  return;
568
1141
  }
569
- if (req.method === "POST" && req.url === "/v1/new") {
1142
+ if (req.method === "POST" && requestUrl.pathname === "/v1/new") {
570
1143
  if (activeAbort) {
571
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1144
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
572
1145
  return;
573
1146
  }
574
- thread = null;
1147
+ runtime.thread = null;
1148
+ runtime.cliThreadId = null;
575
1149
  writeJson(res, 200, { ok: true }, headers);
576
1150
  return;
577
1151
  }
578
- if (req.method === "POST" && req.url === "/v1/resume") {
1152
+ if (req.method === "POST" && requestUrl.pathname === "/v1/resume") {
579
1153
  if (activeAbort) {
580
- writeJson(res, 409, { error: "A Codex turn is still running" }, headers);
1154
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is still running` }, headers);
581
1155
  return;
582
1156
  }
583
1157
  let parsed;
@@ -590,20 +1164,53 @@ export async function runCodexCompanion(argv) {
590
1164
  }
591
1165
  const threadId = typeof parsed.threadId === "string" ? parsed.threadId.trim() : "";
592
1166
  if (!/^[a-zA-Z0-9-]{10,100}$/.test(threadId)) {
593
- writeJson(res, 400, { error: "Invalid Codex thread id" }, headers);
1167
+ writeJson(res, 400, { error: "Invalid coding-agent session id" }, headers);
1168
+ return;
1169
+ }
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);
594
1200
  return;
595
1201
  }
596
1202
  const requestedModel = typeof parsed.model === "string" ? parsed.model.trim() : "";
597
1203
  const requestedEffort = reasoningEffort(parsed.effort);
598
- const metadata = await metadataClient.metadata(selectedModel, selectedEffort);
1204
+ const metadata = await runtime.metadataClient.metadata(runtime.selectedModel, runtime.selectedEffort);
599
1205
  if (requestedModel) {
600
1206
  if (!metadata.models.some((candidate) => candidate.model === requestedModel)) {
601
1207
  writeJson(res, 400, { error: "That Codex model is unavailable" }, headers);
602
1208
  return;
603
1209
  }
604
- selectedModel = requestedModel;
1210
+ runtime.selectedModel = requestedModel;
605
1211
  }
606
- 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));
607
1214
  if (parsed.effort !== undefined && !requestedEffort) {
608
1215
  writeJson(res, 400, { error: "Invalid reasoning effort" }, headers);
609
1216
  return;
@@ -613,22 +1220,28 @@ export async function runCodexCompanion(argv) {
613
1220
  writeJson(res, 400, { error: "That reasoning effort is unavailable" }, headers);
614
1221
  return;
615
1222
  }
616
- selectedEffort = requestedEffort ?? modelInfo?.defaultReasoningEffort ?? null;
617
- thread = codex.resumeThread(threadId, companionThreadOptions(options.cwd, selectedModel, selectedEffort));
618
- 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);
619
1232
  return;
620
1233
  }
621
- if (req.method === "POST" && req.url === "/v1/cancel") {
1234
+ if (req.method === "POST" && requestUrl.pathname === "/v1/cancel") {
622
1235
  activeAbort?.abort();
623
1236
  writeJson(res, 200, { ok: true }, headers);
624
1237
  return;
625
1238
  }
626
- if (req.method !== "POST" || req.url !== "/v1/chat") {
1239
+ if (req.method !== "POST" || requestUrl.pathname !== "/v1/chat") {
627
1240
  writeJson(res, 404, { error: "Not found" }, headers);
628
1241
  return;
629
1242
  }
630
1243
  if (activeAbort) {
631
- writeJson(res, 409, { error: "A Codex turn is already running" }, headers);
1244
+ writeJson(res, 409, { error: `A ${provider.displayName} turn is already running` }, headers);
632
1245
  return;
633
1246
  }
634
1247
  let parsed;
@@ -644,7 +1257,9 @@ export async function runCodexCompanion(argv) {
644
1257
  writeJson(res, 400, { error: "prompt is required" }, headers);
645
1258
  return;
646
1259
  }
647
- 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));
1262
+ }
648
1263
  const abort = new AbortController();
649
1264
  activeAbort = abort;
650
1265
  let responseFinished = false;
@@ -662,14 +1277,41 @@ export async function runCodexCompanion(argv) {
662
1277
  res.write(`${JSON.stringify(value)}\n`);
663
1278
  };
664
1279
  try {
665
- send({ type: "connected", cwd: options.cwd, threadId: thread.id });
666
- const streamed = await thread.runStreamed(buildPrompt(prompt, parsed.context), {
667
- signal: abort.signal,
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,
668
1287
  });
669
- for await (const event of streamed.events) {
670
- send({ type: "codex", event: event });
1288
+ const contextualPrompt = buildCompanionPrompt(prompt, parsed.context);
1289
+ if (runtime.codex && runtime.thread) {
1290
+ const streamed = await runtime.thread.runStreamed(contextualPrompt, {
1291
+ signal: abort.signal,
1292
+ });
1293
+ for await (const event of streamed.events) {
1294
+ send({ type: "agent", event: event });
1295
+ }
671
1296
  }
672
- send({ type: "done", threadId: thread.id });
1297
+ else {
1298
+ runtime.cliThreadId = await runSubscriptionCliTurn({
1299
+ provider: runtime.id,
1300
+ prompt: contextualPrompt,
1301
+ cwd: options.cwd,
1302
+ threadId: runtime.cliThreadId,
1303
+ model: runtime.selectedModel,
1304
+ effort: runtime.selectedEffort,
1305
+ signal: abort.signal,
1306
+ send,
1307
+ });
1308
+ }
1309
+ send({
1310
+ type: "done",
1311
+ threadId: runtime.codex
1312
+ ? (runtime.thread?.id ?? null)
1313
+ : runtime.cliThreadId,
1314
+ });
673
1315
  }
674
1316
  catch (error) {
675
1317
  send({
@@ -692,10 +1334,16 @@ export async function runCodexCompanion(argv) {
692
1334
  });
693
1335
  });
694
1336
  const port = await listenOnLoopback(server, options.port, !options.portWasExplicit);
695
- const pairing = Buffer.from(JSON.stringify({ port, token }), "utf8").toString("base64url");
696
- const url = `${options.appUrl}/#nudge-codex=${pairing}`;
1337
+ const pairing = Buffer.from(JSON.stringify({
1338
+ port,
1339
+ token,
1340
+ provider: providerInfo(defaultProviderId),
1341
+ providers: Array.from(runtimes.keys(), providerInfo),
1342
+ }), "utf8").toString("base64url");
1343
+ const url = companionPairingUrl(options.appUrl, pairing);
697
1344
  console.log("");
698
- console.log("Nudge Codex companion is running");
1345
+ console.log("Nudge Agent companion is running");
1346
+ console.log(` Agents: ${Array.from(runtimes.keys(), (id) => PROVIDERS[id].displayName).join(", ")}`);
699
1347
  console.log(` App: ${options.appUrl}`);
700
1348
  console.log(` Workspace: ${options.cwd}`);
701
1349
  console.log(` Listener: http://127.0.0.1:${port}`);
@@ -704,7 +1352,15 @@ export async function runCodexCompanion(argv) {
704
1352
  }
705
1353
  console.log(" Files: read-only");
706
1354
  console.log("");
707
- console.log("Codex will reuse the account from `codex login`.");
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
+ }
708
1364
  console.log("Keep this terminal open while using Nudge chat.");
709
1365
  console.log("");
710
1366
  if (options.openBrowser)
@@ -714,11 +1370,16 @@ export async function runCodexCompanion(argv) {
714
1370
  await new Promise((resolve) => {
715
1371
  const shutdown = () => {
716
1372
  activeAbort?.abort();
717
- metadataClient.stop();
1373
+ for (const runtime of runtimes.values())
1374
+ runtime.metadataClient?.stop();
718
1375
  server.close(() => resolve());
719
1376
  };
720
1377
  process.once("SIGINT", shutdown);
721
1378
  process.once("SIGTERM", shutdown);
722
1379
  });
723
1380
  }
1381
+ /** Backward-compatible alias for the original Codex-only command. */
1382
+ export async function runCodexCompanion(argv) {
1383
+ await runAgentCompanion(["--provider", "codex", ...argv]);
1384
+ }
724
1385
  //# sourceMappingURL=codex-companion.js.map