codeep 1.3.42 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +208 -0
  2. package/dist/acp/commands.js +770 -7
  3. package/dist/acp/protocol.d.ts +11 -2
  4. package/dist/acp/server.js +179 -11
  5. package/dist/acp/session.d.ts +3 -0
  6. package/dist/acp/session.js +5 -0
  7. package/dist/api/index.js +39 -6
  8. package/dist/config/index.d.ts +13 -0
  9. package/dist/config/index.js +45 -0
  10. package/dist/config/providers.js +76 -1
  11. package/dist/renderer/App.d.ts +12 -0
  12. package/dist/renderer/App.js +109 -4
  13. package/dist/renderer/agentExecution.js +5 -0
  14. package/dist/renderer/commands.js +638 -2
  15. package/dist/renderer/components/Help.js +28 -0
  16. package/dist/renderer/components/Login.d.ts +1 -0
  17. package/dist/renderer/components/Login.js +24 -9
  18. package/dist/renderer/handlers.d.ts +11 -1
  19. package/dist/renderer/handlers.js +30 -0
  20. package/dist/renderer/main.js +73 -0
  21. package/dist/utils/agent.d.ts +17 -0
  22. package/dist/utils/agent.js +91 -7
  23. package/dist/utils/agentChat.d.ts +10 -2
  24. package/dist/utils/agentChat.js +48 -9
  25. package/dist/utils/agentStream.js +6 -2
  26. package/dist/utils/checkpoints.d.ts +93 -0
  27. package/dist/utils/checkpoints.js +205 -0
  28. package/dist/utils/context.d.ts +24 -0
  29. package/dist/utils/context.js +57 -0
  30. package/dist/utils/customCommands.d.ts +62 -0
  31. package/dist/utils/customCommands.js +201 -0
  32. package/dist/utils/hooks.d.ts +97 -0
  33. package/dist/utils/hooks.js +223 -0
  34. package/dist/utils/mcpClient.d.ts +229 -0
  35. package/dist/utils/mcpClient.js +497 -0
  36. package/dist/utils/mcpConfig.d.ts +55 -0
  37. package/dist/utils/mcpConfig.js +177 -0
  38. package/dist/utils/mcpMarketplace.d.ts +49 -0
  39. package/dist/utils/mcpMarketplace.js +175 -0
  40. package/dist/utils/mcpRegistry.d.ts +129 -0
  41. package/dist/utils/mcpRegistry.js +427 -0
  42. package/dist/utils/mcpSamplingBridge.d.ts +32 -0
  43. package/dist/utils/mcpSamplingBridge.js +88 -0
  44. package/dist/utils/mcpStreamableHttp.d.ts +65 -0
  45. package/dist/utils/mcpStreamableHttp.js +207 -0
  46. package/dist/utils/openrouterPrefs.d.ts +36 -0
  47. package/dist/utils/openrouterPrefs.js +83 -0
  48. package/dist/utils/skillBundles.d.ts +84 -0
  49. package/dist/utils/skillBundles.js +257 -0
  50. package/dist/utils/skillBundlesCloud.d.ts +69 -0
  51. package/dist/utils/skillBundlesCloud.js +202 -0
  52. package/dist/utils/tokenTracker.d.ts +14 -2
  53. package/dist/utils/tokenTracker.js +59 -41
  54. package/dist/utils/toolExecution.d.ts +17 -1
  55. package/dist/utils/toolExecution.js +184 -6
  56. package/dist/utils/tools.d.ts +22 -6
  57. package/dist/utils/tools.js +83 -8
  58. package/package.json +3 -2
  59. package/bin/codeep-macos-arm64 +0 -0
  60. package/bin/codeep-macos-x64 +0 -0
@@ -61,9 +61,18 @@ export interface InitializeResult {
61
61
  }
62
62
  export interface McpServer {
63
63
  name: string;
64
- command: string;
65
- args: string[];
64
+ /** Spawn command (stdio transport). Mutually exclusive with `url`. */
65
+ command?: string;
66
+ args?: string[];
66
67
  env?: Record<string, string>;
68
+ /**
69
+ * If set, the client uses MCP Streamable HTTP transport against this URL
70
+ * instead of spawning a child process. Per spec, the same endpoint
71
+ * accepts POST (request) and GET (server-side SSE stream).
72
+ */
73
+ url?: string;
74
+ /** Optional headers for the HTTP transport (Authorization etc.). */
75
+ headers?: Record<string, string>;
67
76
  }
68
77
  export interface SessionMode {
69
78
  id: string;
@@ -6,6 +6,10 @@ import { readFile } from 'fs/promises';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { StdioTransport } from './transport.js';
8
8
  import { runAgentSession } from './session.js';
9
+ import { loadCustomCommands } from '../utils/customCommands.js';
10
+ import { registerSessionServers, disposeSession as disposeMcpSession, disposeAllSessions as disposeAllMcpSessions } from '../utils/mcpRegistry.js';
11
+ import { loadMcpServerConfig, mergeMcpServers } from '../utils/mcpConfig.js';
12
+ import { handleMcpSamplingRequest } from '../utils/mcpSamplingBridge.js';
9
13
  import { executeCommandAsync } from '../utils/shell.js';
10
14
  import { initWorkspace, loadWorkspace, handleCommand } from './commands.js';
11
15
  import { autoSaveSession, config, setProvider, setApiKey, listSessionsWithInfo, deleteSession as deleteSessionFile } from '../config/index.js';
@@ -22,6 +26,7 @@ const AVAILABLE_COMMANDS = [
22
26
  { name: 'help', description: 'Show available commands' },
23
27
  { name: 'status', description: 'Show current config and session info' },
24
28
  { name: 'version', description: 'Show version and current model' },
29
+ { name: 'provider', description: 'List or switch provider', input: { hint: '<provider-id>' } },
25
30
  { name: 'model', description: 'List or switch model', input: { hint: '<model-id>' } },
26
31
  { name: 'login', description: 'Set API key for a provider', input: { hint: '<providerId> <apiKey>' } },
27
32
  { name: 'apikey', description: 'Show or set API key for current provider', input: { hint: '<key>' } },
@@ -38,13 +43,24 @@ const AVAILABLE_COMMANDS = [
38
43
  { name: 'undo', description: 'Undo last agent action' },
39
44
  { name: 'undo-all', description: 'Undo all agent actions in session' },
40
45
  { name: 'changes', description: 'Show all changes made in session' },
46
+ { name: 'cost', description: 'Show per-session token usage and estimated cost' },
47
+ { name: 'compact', description: 'Summarize older messages to free up context', input: { hint: '[keepN]' } },
48
+ { name: 'checkpoint', description: 'Save a named snapshot of the current session (or `delete <id>`)', input: { hint: '[name] | delete <id>' } },
49
+ { name: 'checkpoints', description: 'List saved checkpoints in this workspace' },
50
+ { name: 'rewind', description: 'Restore a session checkpoint by id', input: { hint: '<id>' } },
51
+ { name: 'hooks', description: 'List installed lifecycle hooks in .codeep/hooks/' },
52
+ { name: 'mcp', description: 'Manage MCP servers, marketplace, resources, prompts', input: { hint: '[browse | install <id> | add | remove | reload | resources | read <uri> | prompts | prompt <server> <name>]' } },
53
+ { name: 'openrouter', description: 'OpenRouter routing preferences (prefer/ignore/fallbacks/privacy/clear)', input: { hint: '[show | prefer <p,...> | ignore <p,...> | fallbacks on|off | privacy strict|allow | clear]' } },
41
54
  { name: 'export', description: 'Export conversation', input: { hint: 'json | md | txt' } },
42
55
  // Project intelligence
43
56
  { name: 'scan', description: 'Scan project structure and generate summary' },
44
57
  { name: 'review', description: 'Run code review on project or specific files', input: { hint: '[file…]' } },
45
58
  { name: 'learn', description: 'Learn coding preferences from project files' },
46
- // Skills
47
- { name: 'skills', description: 'List all available skills', input: { hint: '[query]' } },
59
+ { name: 'memory', description: 'Project memory notes — add / list / remove / clear', input: { hint: '<note> | list | remove <n> | clear' } },
60
+ { name: 'profile', description: 'Save / load / delete provider+model presets', input: { hint: 'save | load | delete | list | <name>' } },
61
+ // Skills + custom commands
62
+ { name: 'skills', description: 'List/create/share skill bundles. Subcommands: bundles, create-bundle, show, publish, install, browse, unpublish', input: { hint: '[query] | bundles | create-bundle <name> | show <name> | publish <slug> [--public] | install <owner>/<slug> | browse [q] | unpublish <owner>/<slug>' } },
63
+ { name: 'commands', description: 'List user-authored commands from .codeep/commands/*.md' },
48
64
  { name: 'commit', description: 'Generate commit message and commit' },
49
65
  { name: 'fix', description: 'Fix bugs or issues' },
50
66
  { name: 'test', description: 'Write or run tests' },
@@ -313,6 +329,29 @@ export function startAcpServer() {
313
329
  const transport = new StdioTransport();
314
330
  // ACP sessionId → full AcpSession (includes history + codeep session tracking)
315
331
  const sessions = new Map();
332
+ // Tear down all MCP child processes when the CLI dies. Without this,
333
+ // killing `codeep acp` with Ctrl+C orphans any servers we spawned —
334
+ // they keep running until the user hunts them down with `ps`.
335
+ // Register only once per process; if the user starts multiple ACP servers
336
+ // in the same process (we don't but be defensive) the second listener
337
+ // would double-fire.
338
+ const shutdownSignals = ['SIGINT', 'SIGTERM'];
339
+ let shuttingDown = false;
340
+ const onShutdown = (signal) => {
341
+ if (shuttingDown)
342
+ return;
343
+ shuttingDown = true;
344
+ disposeAllMcpSessions().finally(() => {
345
+ // Mimic default Node exit behaviour after our cleanup runs.
346
+ process.exit(signal === 'SIGINT' ? 130 : 143);
347
+ });
348
+ };
349
+ for (const sig of shutdownSignals) {
350
+ // Only attach if nothing else has claimed the signal — Node prints
351
+ // a warning when listener count > 10 per signal.
352
+ if (process.listenerCount(sig) === 0)
353
+ process.on(sig, onShutdown);
354
+ }
316
355
  transport.start((msg) => {
317
356
  // Notifications have no id — handle separately
318
357
  if (!('id' in msg)) {
@@ -325,6 +364,9 @@ export function startAcpServer() {
325
364
  handleInitialize(req);
326
365
  break;
327
366
  case 'initialized': /* no-op acknowledgment */ break;
367
+ case 'authenticate':
368
+ handleAuthenticate(req);
369
+ break;
328
370
  case 'session/new':
329
371
  handleSessionNew(req);
330
372
  break;
@@ -397,10 +439,31 @@ export function startAcpServer() {
397
439
  name: 'codeep',
398
440
  version: getCurrentVersion(),
399
441
  },
400
- authMethods: [],
442
+ // We advertise a single "agent"-typed auth method even though Codeep
443
+ // authenticates out-of-band (env var, `codeep` CLI `/login`, or the
444
+ // VS Code "Codeep: Set API Key" command). The acp-registry CI check
445
+ // requires at least one method with type `agent` or `terminal` — and
446
+ // having an entry here also gives Zed something to render in its
447
+ // "agent settings" surface so users discover where to put their key.
448
+ authMethods: [
449
+ {
450
+ id: 'codeep-cli',
451
+ name: 'Codeep CLI',
452
+ description: 'Authenticate via the codeep CLI: run `codeep` and use `/login <provider> <key>`, ' +
453
+ 'set the provider\'s env var (e.g. ZAI_API_KEY), or use "Codeep: Set API Key" in VS Code.',
454
+ },
455
+ ],
401
456
  };
402
457
  transport.respond(msg.id, result);
403
458
  }
459
+ // ── authenticate ────────────────────────────────────────────────────────────
460
+ // We don't actually run anything here — auth is handled out-of-band (env
461
+ // var, CLI /login, VS Code command). But per ACP spec the client may still
462
+ // dispatch authenticate after reading our advertised methods. Reply with
463
+ // empty success so the client unblocks and proceeds to session/new.
464
+ function handleAuthenticate(msg) {
465
+ transport.respond(msg.id, {});
466
+ }
404
467
  // ── helpers ──────────────────────────────────────────────────────────────────
405
468
  // Title hint to the client. The ACP spec does NOT define a
406
469
  // `session_info_update` variant — sending it caused Zed's internally-tagged
@@ -414,10 +477,51 @@ export function startAcpServer() {
414
477
  function sendSessionTitle(_sessionId, _history, _fallback) {
415
478
  // intentionally empty — see comment above
416
479
  }
480
+ /**
481
+ * Background-spawn MCP servers for a session, merging two sources:
482
+ *
483
+ * 1. On-disk config (`.codeep/mcp_servers.json` project + global) —
484
+ * so a user who just runs `codeep acp` (no Zed-style settings UI)
485
+ * can still drive MCP setup by editing a file.
486
+ * 2. `mcpServers` passed in the session/* params — clients that have
487
+ * their own config (Zed, Claude Desktop) keep using that. ACP-
488
+ * provided servers override file entries with the same name.
489
+ *
490
+ * Don't block the session/* response on process startup (a hung MCP
491
+ * server would otherwise keep the chat from opening). Errors are logged
492
+ * + cached for /mcp display. `mcpRegistry.callSessionTool` awaits the
493
+ * in-flight registration so tool calls don't race the startup.
494
+ */
495
+ function spawnMcpServersForSession(acpSessionId, cwd, acpServers, label) {
496
+ const fromConfig = loadMcpServerConfig(cwd);
497
+ const merged = mergeMcpServers(fromConfig, acpServers);
498
+ if (merged.length === 0)
499
+ return;
500
+ registerSessionServers(acpSessionId, merged, {
501
+ workspaceRoot: cwd,
502
+ // Servers that opted into the `sampling` capability can ask us to
503
+ // run a completion on their behalf. We bridge to chat() through
504
+ // mcpSamplingBridge — using the user's currently active provider
505
+ // and key. The bridge enforces a per-server rate limit + budget cap
506
+ // so a misbehaving server can't drain the user's API credits.
507
+ onSamplingRequest: (params, serverName) => handleMcpSamplingRequest(params, serverName),
508
+ })
509
+ .then(({ registered, errors }) => {
510
+ if (registered.length > 0) {
511
+ process.stderr.write(`[codeep-acp] MCP (${label}): registered ${registered.length} tool(s) from ${merged.length} server(s)\n`);
512
+ }
513
+ for (const e of errors) {
514
+ process.stderr.write(`[codeep-acp] MCP server "${e.server}" failed (${label}): ${e.error}\n`);
515
+ }
516
+ })
517
+ .catch(err => process.stderr.write(`[codeep-acp] MCP registration crashed (${label}): ${err.message}\n`));
518
+ }
417
519
  // ── session/new ─────────────────────────────────────────────────────────────
418
520
  function handleSessionNew(msg) {
419
521
  const params = msg.params;
420
522
  const acpSessionId = randomUUID();
523
+ // Spin up MCP servers in the background. Errors surface via /mcp.
524
+ spawnMcpServersForSession(acpSessionId, params.cwd, params.mcpServers, 'session/new');
421
525
  const { codeepSessionId, history, welcomeText } = initWorkspace(params.cwd, params.fresh);
422
526
  sessions.set(acpSessionId, {
423
527
  sessionId: acpSessionId,
@@ -443,7 +547,7 @@ export function startAcpServer() {
443
547
  // in a separate task). Without the delay the notification arrives ~1 ms
444
548
  // after the response and gets lost, which manifests as
445
549
  // "Available commands: none" in the slash menu.
446
- sendCommandsDelayed(acpSessionId);
550
+ sendCommandsDelayed(acpSessionId, params.cwd);
447
551
  // Send title immediately so Zed "Recent" panel shows something useful
448
552
  sendSessionTitle(acpSessionId, history, pathBasename(params.cwd));
449
553
  // Send welcome message
@@ -459,13 +563,36 @@ export function startAcpServer() {
459
563
  // 200 ms is comfortably above the observed ~1 ms race window without
460
564
  // making the slash menu feel laggy on first paint.
461
565
  const COMMANDS_DELAY_MS = Number(process.env.CODEEP_ACP_COMMANDS_DELAY_MS ?? 200);
462
- function sendCommandsDelayed(sessionId) {
566
+ /**
567
+ * Build the autocomplete catalog for a session: built-in commands plus any
568
+ * user-authored Markdown templates under `.codeep/commands/`. Custom ones
569
+ * are tagged in the description so the user can tell them apart from
570
+ * built-ins in Zed / VS Code dropdowns.
571
+ */
572
+ function getAvailableCommandsForSession(workspaceRoot) {
573
+ try {
574
+ const custom = loadCustomCommands(workspaceRoot).map(c => ({
575
+ name: c.name,
576
+ description: `[${c.scope === 'project' ? 'project' : 'global'}] ${c.description}`,
577
+ input: { hint: '[args]' },
578
+ }));
579
+ // Custom commands can't override built-ins (would break /help, /status etc.)
580
+ const builtinNames = new Set(AVAILABLE_COMMANDS.map(c => c.name));
581
+ const safeCustom = custom.filter(c => !builtinNames.has(c.name));
582
+ return [...AVAILABLE_COMMANDS, ...safeCustom];
583
+ }
584
+ catch {
585
+ // Custom-command loading must never block the autocomplete catalog.
586
+ return AVAILABLE_COMMANDS;
587
+ }
588
+ }
589
+ function sendCommandsDelayed(sessionId, workspaceRoot) {
463
590
  setTimeout(() => {
464
591
  transport.notify('session/update', {
465
592
  sessionId,
466
593
  update: {
467
594
  sessionUpdate: 'available_commands_update',
468
- availableCommands: AVAILABLE_COMMANDS,
595
+ availableCommands: getAvailableCommandsForSession(workspaceRoot),
469
596
  },
470
597
  });
471
598
  }, COMMANDS_DELAY_MS);
@@ -478,6 +605,9 @@ export function startAcpServer() {
478
605
  if (existing) {
479
606
  // Session already in memory — update cwd if changed
480
607
  existing.workspaceRoot = params.cwd;
608
+ // Re-spawn any MCP servers the client passed in (they may have changed
609
+ // since session/new; old ones get disposed by registerSessionServers).
610
+ spawnMcpServersForSession(params.sessionId, params.cwd, params.mcpServers, 'session/load (warm)');
481
611
  const result = {
482
612
  modes: AGENT_MODES,
483
613
  configOptions: buildConfigOptions(),
@@ -488,6 +618,7 @@ export function startAcpServer() {
488
618
  // Session not in memory — try to load from disk
489
619
  const { codeepSessionId, history, welcomeText } = loadWorkspace(params.cwd, params.sessionId);
490
620
  const acpSessionId = randomUUID();
621
+ spawnMcpServersForSession(acpSessionId, params.cwd, params.mcpServers, 'session/load (cold)');
491
622
  sessions.set(acpSessionId, {
492
623
  sessionId: acpSessionId,
493
624
  workspaceRoot: params.cwd,
@@ -508,7 +639,7 @@ export function startAcpServer() {
508
639
  transport.respond(msg.id, result);
509
640
  // Re-advertise commands (delayed for the same race-condition reason as
510
641
  // session/new — see sendCommandsDelayed comment).
511
- sendCommandsDelayed(acpSessionId);
642
+ sendCommandsDelayed(acpSessionId, params.cwd);
512
643
  // Send title immediately so Zed "Recent" panel shows something useful
513
644
  sendSessionTitle(params.sessionId, history, pathBasename(params.cwd));
514
645
  // Send restored session welcome
@@ -530,6 +661,9 @@ export function startAcpServer() {
530
661
  const existing = sessions.get(params.sessionId);
531
662
  if (existing) {
532
663
  existing.workspaceRoot = params.cwd;
664
+ // Resume can carry an updated mcpServers list (e.g. workspace switched
665
+ // config) — re-register so old servers are torn down and new ones spawn.
666
+ spawnMcpServersForSession(params.sessionId, params.cwd, params.mcpServers, 'session/resume (warm)');
533
667
  const result = {
534
668
  sessionId: params.sessionId,
535
669
  modes: AGENT_MODES,
@@ -537,13 +671,14 @@ export function startAcpServer() {
537
671
  };
538
672
  transport.respond(msg.id, result);
539
673
  // Delayed — see sendCommandsDelayed comment for the race-condition rationale.
540
- sendCommandsDelayed(params.sessionId);
674
+ sendCommandsDelayed(params.sessionId, params.cwd);
541
675
  return;
542
676
  }
543
677
  // Session not in memory — load from disk but skip the welcome banner and
544
678
  // the history echo (resume contract: client already has history).
545
679
  const { codeepSessionId, history } = loadWorkspace(params.cwd, params.sessionId);
546
680
  const acpSessionId = randomUUID();
681
+ spawnMcpServersForSession(acpSessionId, params.cwd, params.mcpServers, 'session/resume (cold)');
547
682
  sessions.set(acpSessionId, {
548
683
  sessionId: acpSessionId,
549
684
  workspaceRoot: params.cwd,
@@ -561,7 +696,7 @@ export function startAcpServer() {
561
696
  configOptions: buildConfigOptions(),
562
697
  };
563
698
  transport.respond(msg.id, result);
564
- sendCommandsDelayed(acpSessionId);
699
+ sendCommandsDelayed(acpSessionId, params.cwd);
565
700
  }
566
701
  // ── session/set_mode ────────────────────────────────────────────────────────
567
702
  function handleSetMode(msg) {
@@ -672,6 +807,9 @@ export function startAcpServer() {
672
807
  const { sessionId, cwd } = (msg.params ?? {});
673
808
  // Remove from in-memory sessions map if present
674
809
  sessions.delete(sessionId);
810
+ // Tear down any MCP server processes attached to this session — leaks
811
+ // children otherwise. Fire-and-forget; client doesn't wait on stop().
812
+ disposeMcpSession(sessionId).catch(() => { });
675
813
  // Try project dir first, then global
676
814
  const deleted = deleteSessionFile(sessionId, cwd || undefined);
677
815
  if (!deleted && cwd)
@@ -707,12 +845,13 @@ export function startAcpServer() {
707
845
  // `available_commands_update` from session/new because the thread_view
708
846
  // isn't registered yet on Zed's side (race against the session/new
709
847
  // response). Re-sending here guarantees `/` autocomplete works by the
710
- // time the user could plausibly type the next prompt.
848
+ // time the user could plausibly type the next prompt. Also picks up any
849
+ // custom command Markdown files the user added since session start.
711
850
  transport.notify('session/update', {
712
851
  sessionId: params.sessionId,
713
852
  update: {
714
853
  sessionUpdate: 'available_commands_update',
715
- availableCommands: AVAILABLE_COMMANDS,
854
+ availableCommands: getAvailableCommandsForSession(session.workspaceRoot),
716
855
  },
717
856
  });
718
857
  // Extract text from ContentBlock[]
@@ -927,6 +1066,35 @@ export function startAcpServer() {
927
1066
  return result.outcome.optionId;
928
1067
  }
929
1068
  : undefined,
1069
+ // Per ACP spec, `fs/read_text_file` and `fs/write_text_file` are
1070
+ // CLIENT methods — only safe to call when the client advertised
1071
+ // the capability in `initialize`. Routing through the client
1072
+ // means the editor's dirty buffers + undo history stay correct
1073
+ // (otherwise an in-editor unsaved change would be invisible to
1074
+ // the agent, or worse, silently overwritten).
1075
+ fs: {
1076
+ readTextFile: clientSupportsFsRead
1077
+ ? async (absolutePath) => {
1078
+ const result = await transport.request('fs/read_text_file', {
1079
+ sessionId: params.sessionId,
1080
+ path: absolutePath,
1081
+ });
1082
+ if (!result || typeof result.content !== 'string') {
1083
+ throw new Error('fs/read_text_file returned no content');
1084
+ }
1085
+ return result.content;
1086
+ }
1087
+ : undefined,
1088
+ writeTextFile: clientSupportsFsWrite
1089
+ ? async (absolutePath, content) => {
1090
+ await transport.request('fs/write_text_file', {
1091
+ sessionId: params.sessionId,
1092
+ path: absolutePath,
1093
+ content,
1094
+ });
1095
+ }
1096
+ : undefined,
1097
+ },
930
1098
  onExecuteCommand: async (command, args, cwd) => {
931
1099
  // Per ACP spec, only call terminal/* if the client advertised the
932
1100
  // capability in initialize. Otherwise execute locally.
@@ -1,6 +1,7 @@
1
1
  import { PermissionOutcome } from '../utils/agent.js';
2
2
  import { ProjectContext } from '../utils/project.js';
3
3
  import { ToolCall } from '../utils/tools.js';
4
+ import type { FsCallbacks } from '../utils/toolExecution.js';
4
5
  export interface AgentSessionOptions {
5
6
  prompt: string;
6
7
  workspaceRoot: string;
@@ -15,6 +16,8 @@ export interface AgentSessionOptions {
15
16
  stderr: string;
16
17
  exitCode: number;
17
18
  }>;
19
+ /** Optional fs delegation when the ACP client advertises `fs` capability. */
20
+ fs?: FsCallbacks;
18
21
  }
19
22
  /**
20
23
  * Build a ProjectContext from a workspace root directory.
@@ -146,6 +146,11 @@ export async function runAgentSession(opts) {
146
146
  },
147
147
  onRequestPermission: opts.onRequestPermission,
148
148
  onExecuteCommand: opts.onExecuteCommand,
149
+ fs: opts.fs,
150
+ // Route MCP-prefixed tool calls through the per-session registry.
151
+ // `conversationId` is the ACP session id, which is what
152
+ // registerSessionServers keyed by in server.ts handleSessionNew.
153
+ mcpSessionId: opts.conversationId,
149
154
  });
150
155
  // result.finalResponse is already emitted via onChunk streaming above;
151
156
  // only emit it here if nothing was streamed (e.g. non-streaming fallback path)
package/dist/api/index.js CHANGED
@@ -7,6 +7,19 @@ import { logApiRequest, logApiResponse } from '../utils/logger.js';
7
7
  import { loadProjectIntelligence, generateContextFromIntelligence } from '../utils/projectIntelligence.js';
8
8
  import { loadProjectRules } from '../utils/agent.js';
9
9
  import { recordTokenUsage, extractOpenAIUsage, extractAnthropicUsage } from '../utils/tokenTracker.js';
10
+ /**
11
+ * OpenRouter returns the authoritative per-call USD in `usage.cost` when
12
+ * the request opts in via `usage: { include: true }`. Pull it here so
13
+ * every chat() / streamChat() / etc. path records the real cost instead
14
+ * of our local pricing estimate. Returns undefined for non-OpenRouter
15
+ * providers or when the field is missing (older OpenRouter API responses).
16
+ */
17
+ function openRouterReportedCost(providerId, data) {
18
+ if (providerId !== 'openrouter')
19
+ return undefined;
20
+ const cost = data?.usage?.cost;
21
+ return typeof cost === 'number' && Number.isFinite(cost) ? cost : undefined;
22
+ }
10
23
  import { getTaskContextPrompt } from '../utils/taskContext.js';
11
24
  // Error messages by language
12
25
  const ERROR_MESSAGES = {
@@ -331,6 +344,21 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
331
344
  else {
332
345
  headers['x-api-key'] = apiKey;
333
346
  }
347
+ // OpenRouter: branding headers + opt in to `usage.cost` so the
348
+ // chat path reports authoritative per-call cost just like agentChat
349
+ // does. Kept identical to the agentChat block so the two paths stay
350
+ // in lockstep.
351
+ if (providerId === 'openrouter') {
352
+ headers['HTTP-Referer'] = 'https://codeep.dev';
353
+ headers['X-Title'] = 'Codeep';
354
+ }
355
+ // Lazy-loaded preferences object — only attached for openrouter so we
356
+ // never send the field to providers that don't understand it.
357
+ let openRouterProvider = undefined;
358
+ if (providerId === 'openrouter') {
359
+ const { readOpenRouterPreferences } = await import('../utils/openrouterPrefs.js');
360
+ openRouterProvider = readOpenRouterPreferences() ?? undefined;
361
+ }
334
362
  const requestBody = JSON.stringify({
335
363
  model,
336
364
  messages,
@@ -338,6 +366,8 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
338
366
  ...(stream ? { stream_options: { include_usage: true } } : {}),
339
367
  ...(omitTemperature ? {} : { temperature }),
340
368
  ...(useCompletionTokens ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens }),
369
+ ...(providerId === 'openrouter' ? { usage: { include: true } } : {}),
370
+ ...(openRouterProvider ? { provider: openRouterProvider } : {}),
341
371
  });
342
372
  try {
343
373
  // Use node:http for Ollama — bypasses undici connection pooling (AggregateError in Node v24)
@@ -358,7 +388,7 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
358
388
  const parsed = JSON.parse(text);
359
389
  const usage = extractOpenAIUsage(parsed);
360
390
  if (usage)
361
- recordTokenUsage(usage, model, providerId);
391
+ recordTokenUsage(usage, model, providerId, openRouterReportedCost(providerId, parsed));
362
392
  return stripThinkTags(parsed.choices[0]?.message?.content || '');
363
393
  }
364
394
  }
@@ -373,13 +403,13 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
373
403
  throw new ApiError(`${getErrorMessage('apiError')}: ${parseApiError(response.status, body)}`, response.status);
374
404
  }
375
405
  if (stream && response.body) {
376
- return handleOpenAIStream(response.body, onChunk);
406
+ return handleOpenAIStream(response.body, onChunk, providerId, model);
377
407
  }
378
408
  else {
379
409
  const data = await response.json();
380
410
  const usage = extractOpenAIUsage(data);
381
411
  if (usage)
382
- recordTokenUsage(usage, model, config.get('provider'));
412
+ recordTokenUsage(usage, model, providerId, openRouterReportedCost(providerId, data));
383
413
  const content = data.choices[0]?.message?.content || '';
384
414
  return stripThinkTags(content);
385
415
  }
@@ -450,8 +480,9 @@ async function handleNodeStream(nodeStream, onChunk, model) {
450
480
  }
451
481
  if (parsed.usage) {
452
482
  const usage = extractOpenAIUsage(parsed);
483
+ const provider = config.get('provider');
453
484
  if (usage)
454
- recordTokenUsage(usage, parsed.model || model, config.get('provider'));
485
+ recordTokenUsage(usage, parsed.model || model, provider, openRouterReportedCost(provider, parsed));
455
486
  }
456
487
  }
457
488
  catch { /* ignore parse errors */ }
@@ -466,7 +497,7 @@ async function handleNodeStream(nodeStream, onChunk, model) {
466
497
  });
467
498
  });
468
499
  }
469
- async function handleOpenAIStream(body, onChunk) {
500
+ async function handleOpenAIStream(body, onChunk, providerId, modelOverride) {
470
501
  const reader = body.getReader();
471
502
  const decoder = new TextDecoder();
472
503
  const chunks = [];
@@ -494,8 +525,10 @@ async function handleOpenAIStream(body, onChunk) {
494
525
  // Capture usage from final chunk (stream_options: include_usage)
495
526
  if (parsed.usage) {
496
527
  const usage = extractOpenAIUsage(parsed);
528
+ const provider = providerId ?? config.get('provider');
529
+ const m = parsed.model || modelOverride || 'unknown';
497
530
  if (usage)
498
- recordTokenUsage(usage, parsed.model || 'unknown', config.get('provider'));
531
+ recordTokenUsage(usage, m, provider, openRouterReportedCost(provider, parsed));
499
532
  }
500
533
  }
501
534
  catch {
@@ -49,6 +49,14 @@ interface ConfigSchema {
49
49
  githubUsername: string;
50
50
  syncToken: string;
51
51
  deviceId: string;
52
+ /** OpenRouter provider-routing preferences (see utils/openrouterPrefs.ts). */
53
+ openrouterPreferences?: {
54
+ order?: string[];
55
+ allow_fallbacks?: boolean;
56
+ ignore?: string[];
57
+ data_collection?: 'allow' | 'deny';
58
+ require_parameters?: boolean;
59
+ };
52
60
  }
53
61
  export type { AgentMode };
54
62
  export type { LanguageCode };
@@ -105,6 +113,11 @@ export declare function getCurrentProvider(): {
105
113
  };
106
114
  export declare function setProvider(providerId: string): boolean;
107
115
  export declare function getModelsForCurrentProvider(): Record<string, string>;
116
+ export declare function fetchOpenRouterModels(apiKey?: string): Promise<{
117
+ id: string;
118
+ name: string;
119
+ description: string;
120
+ }[] | null>;
108
121
  export declare function fetchOllamaModels(baseUrl?: string): Promise<{
109
122
  id: string;
110
123
  name: string;
@@ -466,6 +466,51 @@ export function getModelsForCurrentProvider() {
466
466
  }
467
467
  return models;
468
468
  }
469
+ let openRouterCache = null;
470
+ const OPENROUTER_CACHE_TTL_MS = 5 * 60 * 1000;
471
+ export async function fetchOpenRouterModels(apiKey) {
472
+ if (openRouterCache && Date.now() - openRouterCache.fetchedAt < OPENROUTER_CACHE_TTL_MS) {
473
+ return openRouterCache.models;
474
+ }
475
+ try {
476
+ const headers = { 'Accept': 'application/json' };
477
+ if (apiKey)
478
+ headers['Authorization'] = `Bearer ${apiKey}`;
479
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
480
+ headers,
481
+ signal: AbortSignal.timeout(10_000),
482
+ });
483
+ if (!res.ok)
484
+ return null;
485
+ const data = await res.json();
486
+ if (!Array.isArray(data.data))
487
+ return null;
488
+ // Format pricing for the dropdown description so users can pick cheap
489
+ // models without leaving the picker. OpenRouter returns USD-per-token
490
+ // as strings like "0.000003" — multiply by 1M for the per-1M figure
491
+ // most users think in.
492
+ const models = data.data
493
+ .filter(m => typeof m.id === 'string')
494
+ .map(m => {
495
+ const inPrice = m.pricing?.prompt ? Number(m.pricing.prompt) * 1_000_000 : null;
496
+ const outPrice = m.pricing?.completion ? Number(m.pricing.completion) * 1_000_000 : null;
497
+ const priceStr = inPrice !== null && outPrice !== null
498
+ ? ` · $${inPrice.toFixed(2)} in / $${outPrice.toFixed(2)} out per 1M`
499
+ : '';
500
+ const ctxStr = m.context_length ? ` · ${Math.round(m.context_length / 1000)}K ctx` : '';
501
+ return {
502
+ id: m.id,
503
+ name: m.name ?? m.id,
504
+ description: (m.description?.slice(0, 80) ?? 'OpenRouter model') + priceStr + ctxStr,
505
+ };
506
+ });
507
+ openRouterCache = { fetchedAt: Date.now(), models };
508
+ return models;
509
+ }
510
+ catch {
511
+ return null;
512
+ }
513
+ }
469
514
  export async function fetchOllamaModels(baseUrl) {
470
515
  const url = (baseUrl || config.get('ollamaUrl') || 'http://localhost:11434').replace(/\/$/, '');
471
516
  try {