amalgm 0.1.146 → 0.1.147

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.
@@ -28,23 +28,19 @@ const restartTimers = new Map();
28
28
  // Crash containment: an app that keeps dying must not keep the supervisor busy
29
29
  // forever. Restarts back off exponentially, and after maxCrashes unexpected
30
30
  // exits inside windowMs the app is parked as 'degraded' until someone starts
31
- // it explicitly (or the runtime reboots). Apps are allowed to fail; they are
32
- // not allowed to consume the runtime.
31
+ // it explicitly. The crash history lives ON THE APP RECORD (apps.json), not in
32
+ // this process: a degraded app stays parked across runtime restarts, otherwise
33
+ // a runtime-level restart loop hands every broken app a fresh crash budget.
33
34
  const APP_RESTART_POLICY = {
34
35
  initialDelayMs: 2000,
35
36
  maxDelayMs: 60_000,
36
37
  maxCrashes: 5,
37
38
  windowMs: 5 * 60_000,
38
39
  };
39
- const crashHistory = new Map();
40
40
 
41
- function recordCrash(appId) {
42
- const now = Date.now();
43
- const recent = (crashHistory.get(appId) || [])
44
- .filter((time) => now - time <= APP_RESTART_POLICY.windowMs);
45
- recent.push(now);
46
- crashHistory.set(appId, recent);
47
- return recent.length;
41
+ function recentCrashes(app, now = Date.now()) {
42
+ const history = Array.isArray(app?.crashHistory) ? app.crashHistory : [];
43
+ return history.filter((time) => Number.isFinite(time) && now - time <= APP_RESTART_POLICY.windowMs);
48
44
  }
49
45
 
50
46
  function restartDelayMs(crashCount) {
@@ -358,13 +354,16 @@ function startAppProcess(app) {
358
354
  if (!current) return;
359
355
 
360
356
  if (current.desiredState === 'running' && current.keepAlive !== false) {
361
- const crashes = recordCrash(app.id);
357
+ const history = recentCrashes(current);
358
+ history.push(Date.now());
359
+ const crashes = history.length;
362
360
  if (crashes >= APP_RESTART_POLICY.maxCrashes) {
363
361
  const windowMinutes = Math.round(APP_RESTART_POLICY.windowMs / 60_000);
364
362
  console.error(`[App:${app.id}] degraded: crashed ${crashes} times in ${windowMinutes}m; automatic restarts paused (logs: ${appLogPath(app.id)})`);
365
363
  updateAppMeta(app.id, {
366
364
  status: 'degraded',
367
365
  pid: null,
366
+ crashHistory: history,
368
367
  error: `Crashed ${crashes} times in ${windowMinutes} minutes; automatic restarts paused. Fix the app, then start it again. Logs: ${appLogPath(app.id)}`,
369
368
  });
370
369
  void syncRoutesBestEffort(`degraded:${app.id}`);
@@ -375,6 +374,7 @@ function startAppProcess(app) {
375
374
  updateAppMeta(app.id, {
376
375
  status: 'restarting',
377
376
  pid: null,
377
+ crashHistory: history,
378
378
  error: code === 0 ? null : `Process exited with code ${code}`,
379
379
  });
380
380
  void syncRoutesBestEffort(`exit:${app.id}`);
@@ -408,8 +408,8 @@ async function startApp(appId) {
408
408
  restartTimers.delete(appId);
409
409
 
410
410
  // A degraded app has no pending restart timer, so reaching here means an
411
- // explicit start (user/API/boot) — give it a fresh crash budget.
412
- if (app.status === 'degraded') crashHistory.delete(appId);
411
+ // explicit start (user/API) — give it a fresh crash budget.
412
+ if (app.status === 'degraded') updateAppMeta(appId, { crashHistory: [] });
413
413
 
414
414
  if (running.has(appId)) {
415
415
  const current = updateAppMeta(app.id, {
@@ -476,7 +476,6 @@ async function stopApp(appId, options = {}) {
476
476
  console.warn(`[Apps] Could not clean listeners for ${appId} on port ${app.port}: ${err.message}`);
477
477
  });
478
478
  running.delete(appId);
479
- crashHistory.delete(appId);
480
479
  closeAppLog(appId);
481
480
 
482
481
  setTimeout(() => stopping.delete(appId), 1000);
@@ -484,6 +483,7 @@ async function stopApp(appId, options = {}) {
484
483
  desiredState: options.desiredState || 'stopped',
485
484
  status: 'stopped',
486
485
  pid: null,
486
+ crashHistory: [],
487
487
  });
488
488
  await syncRoutesBestEffort(`stop:${appId}`);
489
489
  return current;
@@ -630,6 +630,14 @@ async function startRegisteredApps() {
630
630
  updateAppMeta(app.id, { pid: null });
631
631
  }
632
632
 
633
+ // A degraded app stays parked across runtime restarts — its crash budget
634
+ // is spent until a user/API start resets it. Auto-starting it here would
635
+ // let a runtime-level restart loop re-arm every broken app on each boot.
636
+ if (app.status === 'degraded') {
637
+ console.warn(`[App:${app.id}] skipping autostart: degraded (${app.error || 'no error recorded'})`);
638
+ return;
639
+ }
640
+
633
641
  if (app.autostart !== false && app.desiredState === 'running') {
634
642
  await startApp(app.id).catch((err) => {
635
643
  updateAppMeta(app.id, { status: 'error', error: err.message, pid: null });
@@ -87,6 +87,14 @@ async function ensureProxyToken() {
87
87
  }
88
88
 
89
89
  async function boot() {
90
+ const { listen } = require('./server/http');
91
+
92
+ // Bind the port and answer /healthz before any slow init (proxy token is a
93
+ // network call, store init hits disk). The parent supervisor kills children
94
+ // that don't answer within its cold-start window; a booting runtime must
95
+ // look alive first and warm up second.
96
+ await listen();
97
+
90
98
  await ensureProxyToken();
91
99
 
92
100
  const { ensureAutomationsStore } = require('./automations/store');
@@ -95,17 +103,15 @@ async function boot() {
95
103
  const { startAppRouteSyncLoop } = require('./apps/advertise');
96
104
  const { startRegisteredApps } = require('./apps/supervisor');
97
105
  const { startProjectContextService } = require('./project-context/store');
98
- const { listen } = require('./server/http');
99
106
 
100
- // Make sure every storage dir/file exists before we accept traffic.
101
107
  ensureAutomationsStore();
102
108
  ensureAgentsDirs();
103
109
  ensureAppsDirs();
104
110
  startProjectContextService();
105
111
 
106
- await listen();
107
112
  await startRegisteredApps();
108
113
  startAppRouteSyncLoop();
114
+ require('./state/ports').startPortsBridge();
109
115
  }
110
116
 
111
117
  boot().catch((err) => {
@@ -6,6 +6,14 @@ const { insertStateEvent, publishStateEvent } = require('../state/events');
6
6
  const CHAT_PAYLOADS_META_KEY = 'chat_payloads';
7
7
  const CHAT_PAYLOADS_RESOURCE = 'chat_payloads';
8
8
 
9
+ // Records are keyed by UI tab id and nothing deletes them when a tab closes, so
10
+ // the map grows forever without a sweep. GC of a live tab's record is safe:
11
+ // before every launch the client re-publishes unless its own confirmed-write
12
+ // memory is fresher than 10 minutes.
13
+ const CHAT_PAYLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
14
+ const CHAT_PAYLOAD_MAX_RECORDS = 300;
15
+ let chatPayloadSweepDone = false;
16
+
9
17
  function isPlainObject(value) {
10
18
  return Boolean(value && typeof value === 'object' && !Array.isArray(value));
11
19
  }
@@ -51,17 +59,66 @@ function normalizeRecord(input, existing = null) {
51
59
  };
52
60
  }
53
61
 
62
+ function recordTimestamp(record) {
63
+ const parsed = Date.parse(record?.updatedAt || '');
64
+ return Number.isFinite(parsed) ? parsed : 0;
65
+ }
66
+
67
+ function sweepChatPayloadsOnce() {
68
+ if (chatPayloadSweepDone) return;
69
+ chatPayloadSweepDone = true;
70
+ try {
71
+ const db = openLocalDb();
72
+ const event = db.transaction(() => {
73
+ const current = readPayloadMap();
74
+ const cutoff = Date.now() - CHAT_PAYLOAD_TTL_MS;
75
+ let kept = Object.values(current)
76
+ .filter((record) => isPlainObject(record) && cleanString(record.id))
77
+ .filter((record) => recordTimestamp(record) >= cutoff);
78
+ if (kept.length > CHAT_PAYLOAD_MAX_RECORDS) {
79
+ kept = kept
80
+ .sort((a, b) => recordTimestamp(b) - recordTimestamp(a))
81
+ .slice(0, CHAT_PAYLOAD_MAX_RECORDS);
82
+ }
83
+ if (kept.length === Object.keys(current).length) return null;
84
+
85
+ const next = {};
86
+ for (const record of kept) next[record.id] = record;
87
+ db.prepare(`
88
+ INSERT INTO local_meta (key, value, updated_at)
89
+ VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
90
+ ON CONFLICT(key) DO UPDATE SET
91
+ value = excluded.value,
92
+ updated_at = excluded.updated_at
93
+ `).run(CHAT_PAYLOADS_META_KEY, JSON.stringify(next));
94
+
95
+ return insertStateEvent(db, {
96
+ resource: CHAT_PAYLOADS_RESOURCE,
97
+ op: 'invalidate',
98
+ source: 'chat-payloads-gc',
99
+ });
100
+ })();
101
+
102
+ publishStateEvent(event);
103
+ } catch (error) {
104
+ console.warn('[chat-payloads] GC sweep failed:', error?.message || error);
105
+ }
106
+ }
107
+
54
108
  function listChatPayloads() {
109
+ sweepChatPayloadsOnce();
55
110
  return readPayloadMap();
56
111
  }
57
112
 
58
113
  function getChatPayload(id) {
114
+ sweepChatPayloadsOnce();
59
115
  const payloadId = cleanString(id);
60
116
  if (!payloadId) return null;
61
117
  return readPayloadMap()[payloadId] || null;
62
118
  }
63
119
 
64
120
  function writeChatPayload(input, options = {}) {
121
+ sweepChatPayloadsOnce();
65
122
  const db = openLocalDb();
66
123
  let record;
67
124
  const event = db.transaction(() => {
@@ -1,27 +1,52 @@
1
1
  'use strict';
2
2
 
3
- const { loadAgents } = require('../../agents/store');
4
- const { loadApps } = require('../../apps/store');
5
3
  const { activeAgentConversations } = require('../../agents/talk');
6
- const { listAutomations } = require('../../automations/store');
4
+
5
+ // /healthz is a liveness signal: the parent supervisor kills this process
6
+ // after three slow answers, so the handler must never touch disk. Counts are
7
+ // informational — serve a cached snapshot and refresh it off the request path.
8
+ const COUNTS_TTL_MS = 30_000;
9
+ let cachedCounts = { automations: 0, enabledAutomations: 0, apps: 0, customAgents: 0 };
10
+ let countsRefreshedAt = 0;
11
+ let countsRefreshing = false;
12
+
13
+ function refreshCountsSoon() {
14
+ if (countsRefreshing || Date.now() - countsRefreshedAt < COUNTS_TTL_MS) return;
15
+ countsRefreshing = true;
16
+ setImmediate(() => {
17
+ try {
18
+ const { loadAgents } = require('../../agents/store');
19
+ const { loadApps } = require('../../apps/store');
20
+ const { listAutomations } = require('../../automations/store');
21
+ const automations = listAutomations();
22
+ cachedCounts = {
23
+ automations: automations.length,
24
+ enabledAutomations: automations.filter((automation) => automation.enabled !== false).length,
25
+ apps: loadApps().apps.length,
26
+ customAgents: loadAgents().agents.length,
27
+ };
28
+ countsRefreshedAt = Date.now();
29
+ } catch {
30
+ // Counts stay stale; liveness must not depend on store reads succeeding.
31
+ } finally {
32
+ countsRefreshing = false;
33
+ }
34
+ });
35
+ }
7
36
 
8
37
  async function handleHealthRoutes(ctx) {
9
38
  if (ctx.pathname !== '/healthz') return false;
10
39
 
11
- const agents = loadAgents();
12
- const automations = listAutomations();
13
40
  ctx.sendJson(200, {
14
41
  status: 'ok',
15
42
  service: 'amalgm-mcp',
16
43
  runtime_label: process.env.AMALGM_RUNTIME_LABEL || 'main',
17
44
  runtime_instance_id: process.env.AMALGM_RUNTIME_INSTANCE_ID || '',
18
45
  pid: process.pid,
19
- automations: automations.length,
20
- enabledAutomations: automations.filter((automation) => automation.enabled !== false).length,
21
- apps: loadApps().apps.length,
22
- customAgents: agents.agents.length,
46
+ ...cachedCounts,
23
47
  activeAgentConversations: activeAgentConversations.size,
24
48
  });
49
+ refreshCountsSoon();
25
50
  return true;
26
51
  }
27
52
 
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Bridges the standalone port-monitor service into the Local Live Store.
5
+ *
6
+ * Browsers used to poll /api/ports over the network (per open client, per
7
+ * visible computer, every 2.5s). The poll now lives here instead: loopback
8
+ * only, one per runtime, and it publishes a `ports` state event only when the
9
+ * port set actually changes — connected clients get it over the existing
10
+ * stream for free.
11
+ */
12
+
13
+ const { appendStateEvent } = require('./events');
14
+
15
+ const PORTS_RESOURCE = 'ports';
16
+ const POLL_MS = 3000;
17
+
18
+ let timer = null;
19
+ let lastJson = null;
20
+ let currentPorts = [];
21
+
22
+ function portMonitorEndpoint() {
23
+ const { runtimePort } = require('../../../lib/runtime-manifest');
24
+ return `http://127.0.0.1:${runtimePort('port-monitor')}/api/ports`;
25
+ }
26
+
27
+ function runtimeAuthHeaders() {
28
+ const token = String(process.env.AMALGM_RUNTIME_TOKEN || '').trim();
29
+ return token ? { 'x-amalgm-runtime-token': token } : {};
30
+ }
31
+
32
+ function normalizePorts(ports) {
33
+ if (!Array.isArray(ports)) return [];
34
+ return ports
35
+ .filter((entry) => entry && Number.isInteger(entry.port))
36
+ .map((entry) => ({
37
+ port: entry.port,
38
+ processName: typeof entry.processName === 'string' ? entry.processName : null,
39
+ }))
40
+ .sort((a, b) => a.port - b.port);
41
+ }
42
+
43
+ function getCurrentPorts() {
44
+ return currentPorts;
45
+ }
46
+
47
+ async function pollOnce() {
48
+ let ports;
49
+ try {
50
+ const res = await fetch(portMonitorEndpoint(), { headers: runtimeAuthHeaders() });
51
+ if (!res.ok) return;
52
+ const data = await res.json().catch(() => null);
53
+ ports = normalizePorts(data?.ports);
54
+ } catch {
55
+ // port-monitor may not be up yet (it boots independently); keep the last
56
+ // known set and try again next tick.
57
+ return;
58
+ }
59
+
60
+ const json = JSON.stringify(ports);
61
+ if (json === lastJson) return;
62
+ lastJson = json;
63
+ currentPorts = ports;
64
+
65
+ try {
66
+ appendStateEvent({
67
+ resource: PORTS_RESOURCE,
68
+ op: 'replace',
69
+ value: ports,
70
+ source: 'ports-bridge',
71
+ });
72
+ } catch (error) {
73
+ console.warn('[PortsBridge] Failed to publish ports event:', error?.message || error);
74
+ }
75
+ }
76
+
77
+ function startPortsBridge() {
78
+ if (timer) return;
79
+ void pollOnce();
80
+ timer = setInterval(() => {
81
+ void pollOnce();
82
+ }, POLL_MS);
83
+ if (typeof timer.unref === 'function') timer.unref();
84
+ }
85
+
86
+ function stopPortsBridge() {
87
+ if (timer) {
88
+ clearInterval(timer);
89
+ timer = null;
90
+ }
91
+ }
92
+
93
+ module.exports = {
94
+ PORTS_RESOURCE,
95
+ getCurrentPorts,
96
+ startPortsBridge,
97
+ stopPortsBridge,
98
+ };
@@ -68,8 +68,10 @@ function handleStream(req, res, query) {
68
68
  sendSseEvent(res, event);
69
69
  }
70
70
 
71
+ // A named event (not an SSE comment) so EventSource clients can observe it
72
+ // and detect a silently dead stream, e.g. tunnel frame drops.
71
73
  const heartbeat = setInterval(() => {
72
- res.write(': heartbeat\n\n');
74
+ sendSseControl(res, 'ping', { t: Date.now() });
73
75
  }, 25_000);
74
76
 
75
77
  req.on('close', () => {
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { currentSeq } = require('./events');
4
4
 
5
- const DEFAULT_RESOURCES = ['automations', 'triggers', 'workflows', 'automation_runs', 'workflow_cell_runs', 'agents', 'agent_configs', 'skills', 'apps', 'toolbox', 'tools', 'tool_actions', 'hooks', 'projects', 'workspaces', 'project_context', 'user_preferences', 'chat_payloads', 'mcp_connections', 'browser_profiles', 'browser_auth_bundles', 'browser_login_sessions'];
5
+ const DEFAULT_RESOURCES = ['automations', 'triggers', 'workflows', 'automation_runs', 'workflow_cell_runs', 'agents', 'agent_configs', 'skills', 'apps', 'ports', 'toolbox', 'tools', 'tool_actions', 'hooks', 'projects', 'workspaces', 'project_context', 'user_preferences', 'chat_payloads', 'mcp_connections', 'browser_profiles', 'browser_auth_bundles', 'browser_login_sessions'];
6
6
 
7
7
  function normalizeResources(resources) {
8
8
  const values = resources ? String(resources).split(',') : DEFAULT_RESOURCES;
@@ -45,6 +45,8 @@ function readResource(resource, cache) {
45
45
  return require('../skills/store').listSkills();
46
46
  case 'apps':
47
47
  return require('../apps/store').loadApps().apps;
48
+ case 'ports':
49
+ return require('./ports').getCurrentPorts();
48
50
  case 'toolbox':
49
51
  cache.toolbox ||= require('../toolbox/service').defaultToolboxService.readCatalog();
50
52
  return cache.toolbox;
@@ -13,8 +13,8 @@ process.env.AMALGM_RUNTIME_LABEL = 'test';
13
13
  process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
14
14
 
15
15
  const { getApp, saveApps } = require('../apps/store');
16
- const { APP_RESTART_POLICY, startApp, stopApp } = require('../apps/supervisor');
17
- const { APPS_DIR } = require('../config');
16
+ const { APP_RESTART_POLICY, startApp, startRegisteredApps, stopApp } = require('../apps/supervisor');
17
+ const { APPS_DIR, APPS_FILE } = require('../config');
18
18
 
19
19
  function waitFor(predicate, label, timeoutMs = 10_000) {
20
20
  const deadline = Date.now() + timeoutMs;
@@ -73,6 +73,17 @@ test('a crash-looping app is parked as degraded instead of restarting forever',
73
73
  await new Promise((resolve) => setTimeout(resolve, 400));
74
74
  assert.equal(getApp('app-crash-loop').status, 'degraded');
75
75
 
76
+ // The crash budget is written on the app record itself, so a fresh runtime
77
+ // process would see it too — not lost with this process's memory.
78
+ const persisted = JSON.parse(fs.readFileSync(APPS_FILE, 'utf8'))
79
+ .apps.find((item) => item.id === 'app-crash-loop');
80
+ assert.equal(persisted.status, 'degraded');
81
+ assert.equal(persisted.crashHistory.length >= APP_RESTART_POLICY.maxCrashes, true);
82
+
83
+ // Boot (a "new runtime") must leave the parked app alone.
84
+ await startRegisteredApps();
85
+ assert.equal(getApp('app-crash-loop').status, 'degraded');
86
+
76
87
  // App output landed in the app's own log file, not the runtime log.
77
88
  const logFile = path.join(APPS_DIR, 'logs', 'app-crash-loop.log');
78
89
  await waitFor(() => fs.existsSync(logFile), 'app log file');
@@ -49,13 +49,24 @@ function matchAcpModel(availableModels, requested) {
49
49
  if (clean === 'auto' || clean === 'default') clean = 'default';
50
50
  const findBase = (base) => models.find((m) => baseOf(m.modelId) === base)
51
51
  || models.find((m) => String(m.name || '').toLowerCase() === base.toLowerCase());
52
+ const acpBaseAliases = (base) => {
53
+ const aliases = [];
54
+ const claudeVersionFirst = String(base || '').match(/^claude-(4(?:\.\d+)?)-(opus|sonnet)(?:-.+)?$/);
55
+ if (claudeVersionFirst) {
56
+ const [, version, family] = claudeVersionFirst;
57
+ aliases.push(`claude-${family}-${version.replace('.', '-')}`);
58
+ }
59
+ return aliases;
60
+ };
52
61
  let candidate = clean;
53
62
  // Legacy amalgm cursor cliModels carry effort/speed suffixes ("gpt-5.3-codex-high",
54
63
  // "composer-2.5-fast") that ACP model ids do not; strip them until a base matches.
55
64
  for (let i = 0; i < 6; i += 1) {
56
- const match = findBase(candidate);
57
- if (match) return match;
58
- const stripped = candidate.replace(/-(?:fast|thinking|low|medium|high|xhigh|max)$/, '');
65
+ for (const base of [candidate, ...acpBaseAliases(candidate)]) {
66
+ const match = findBase(base);
67
+ if (match) return match;
68
+ }
69
+ const stripped = candidate.replace(/-(?:extra-high|fast|thinking|none|low|medium|high|xhigh|max)$/, '');
59
70
  if (stripped === candidate) return null;
60
71
  candidate = stripped;
61
72
  }
@@ -60,6 +60,107 @@ function gatewayModelId(raw) {
60
60
  return bare;
61
61
  }
62
62
 
63
+ const CURSOR_CLI_ALIASES = {
64
+ 'composer-2': 'composer-2.5',
65
+ 'composer-2-fast': 'composer-2.5-fast',
66
+ 'claude-fable-5': 'claude-fable-5-high',
67
+ 'claude-fable-5-thinking': 'claude-fable-5-thinking-high',
68
+ 'claude-opus-4-8': 'claude-opus-4-8-high',
69
+ 'claude-opus-4-8-thinking': 'claude-opus-4-8-thinking-high',
70
+ 'claude-opus-4-7': 'claude-opus-4-7-xhigh',
71
+ 'claude-opus-4-7-thinking': 'claude-opus-4-7-thinking-xhigh',
72
+ 'claude-opus-4-6': 'claude-4.6-opus-high',
73
+ 'claude-opus-4-6-thinking': 'claude-4.6-opus-high-thinking',
74
+ 'claude-opus-4-5': 'claude-4.5-opus-high',
75
+ 'claude-opus-4-5-thinking': 'claude-4.5-opus-high-thinking',
76
+ 'claude-sonnet-5': 'claude-sonnet-5-high',
77
+ 'claude-sonnet-5-thinking': 'claude-sonnet-5-thinking-high',
78
+ 'claude-sonnet-4-6': 'claude-4.6-sonnet-medium',
79
+ 'claude-sonnet-4-6-thinking': 'claude-4.6-sonnet-medium-thinking',
80
+ 'gpt-5.5': 'gpt-5.5-medium',
81
+ 'gpt-5.4': 'gpt-5.4-medium',
82
+ 'gpt-5.4-mini': 'gpt-5.4-mini-medium',
83
+ 'gpt-5.4-nano': 'gpt-5.4-nano-medium',
84
+ 'grok-4.5': 'grok-4.5-xhigh',
85
+ 'glm-5.2': 'glm-5.2-high',
86
+ };
87
+
88
+ const CURSOR_CANONICAL_MODEL_ALIASES = {
89
+ 'cursor/composer-2': 'cursor/composer-2.5',
90
+ 'cursor/composer-2-fast': 'cursor/composer-2.5-fast',
91
+ 'anthropic/claude-fable-5': 'anthropic/claude-fable-5-high',
92
+ 'anthropic/claude-fable-5-thinking': 'anthropic/claude-fable-5-thinking-high',
93
+ 'anthropic/claude-opus-4.8': 'anthropic/claude-opus-4.8-high',
94
+ 'anthropic/claude-opus-4.8-thinking': 'anthropic/claude-opus-4.8-thinking-high',
95
+ 'anthropic/claude-opus-4.7': 'anthropic/claude-opus-4.7-xhigh',
96
+ 'anthropic/claude-opus-4.7-thinking': 'anthropic/claude-opus-4.7-thinking-xhigh',
97
+ 'anthropic/claude-opus-4.6': 'anthropic/claude-opus-4.6-high',
98
+ 'anthropic/claude-opus-4.6-thinking': 'anthropic/claude-opus-4.6-high-thinking',
99
+ 'anthropic/claude-opus-4.5': 'anthropic/claude-opus-4.5-high',
100
+ 'anthropic/claude-opus-4.5-thinking': 'anthropic/claude-opus-4.5-high-thinking',
101
+ 'anthropic/claude-sonnet-5': 'anthropic/claude-sonnet-5-high',
102
+ 'anthropic/claude-sonnet-5-thinking': 'anthropic/claude-sonnet-5-thinking-high',
103
+ 'anthropic/claude-sonnet-4.6': 'anthropic/claude-sonnet-4.6-medium',
104
+ 'anthropic/claude-sonnet-4.6-thinking': 'anthropic/claude-sonnet-4.6-medium-thinking',
105
+ 'openai/gpt-5.5': 'openai/gpt-5.5-medium',
106
+ 'openai/gpt-5.4': 'openai/gpt-5.4-medium',
107
+ 'openai/gpt-5.4-mini': 'openai/gpt-5.4-mini-medium',
108
+ 'openai/gpt-5.4-nano': 'openai/gpt-5.4-nano-medium',
109
+ 'google/gemini-3.1-pro-preview': 'google/gemini-3.1-pro',
110
+ 'xai/grok-4.5': 'xai/grok-4.5-xhigh',
111
+ 'zai/glm-5.2': 'zai/glm-5.2-high',
112
+ };
113
+
114
+ function cursorCliAlias(cliModel) {
115
+ return CURSOR_CLI_ALIASES[cliModel] || cliModel;
116
+ }
117
+
118
+ function cursorAnthropicCliModel(model) {
119
+ const versioned = model.match(/^claude-(opus|sonnet)-(4(?:\.\d+)?)(?:-(.+))?$/);
120
+ if (!versioned) {
121
+ return model.replace(/-(\d+)\.(\d+)(?=$|-)/, '-$1-$2');
122
+ }
123
+ const [, family, version, rawSuffix] = versioned;
124
+ const suffix = rawSuffix || '';
125
+ if (family === 'sonnet' && version === '4.6') {
126
+ if (!suffix) return 'claude-4.6-sonnet-medium';
127
+ if (suffix === 'thinking') return 'claude-4.6-sonnet-medium-thinking';
128
+ return `claude-4.6-sonnet-${suffix}`;
129
+ }
130
+ if (family === 'sonnet' && (version === '4.5' || version === '4')) {
131
+ return `claude-${version}-sonnet${suffix ? `-${suffix}` : ''}`;
132
+ }
133
+ if (family === 'opus' && version === '4.6') {
134
+ if (!suffix) return 'claude-4.6-opus-high';
135
+ if (suffix === 'thinking') return 'claude-4.6-opus-high-thinking';
136
+ return `claude-4.6-opus-${suffix}`;
137
+ }
138
+ if (family === 'opus' && version === '4.5') {
139
+ if (!suffix) return 'claude-4.5-opus-high';
140
+ if (suffix === 'thinking') return 'claude-4.5-opus-high-thinking';
141
+ return `claude-4.5-opus-${suffix}`;
142
+ }
143
+ return model.replace(/-(\d+)\.(\d+)(?=$|-)/, '-$1-$2');
144
+ }
145
+
146
+ function cursorCliModelFor(raw) {
147
+ const clean = String(raw || '').trim().replace(/^vercel\//i, '').toLowerCase();
148
+ const canonical = CURSOR_CANONICAL_MODEL_ALIASES[clean] || clean;
149
+ if (canonical.startsWith('cursor/')) return cursorCliAlias(canonical.slice('cursor/'.length));
150
+ if (canonical.startsWith('cursor-')) return cursorCliAlias(canonical.slice('cursor-'.length));
151
+ if (canonical.startsWith('openai/')) return canonical.slice('openai/'.length);
152
+ if (canonical.startsWith('google/')) return canonical.slice('google/'.length);
153
+ if (canonical.startsWith('moonshotai/')) return canonical.slice('moonshotai/'.length);
154
+ if (canonical.startsWith('zai/')) return canonical.slice('zai/'.length);
155
+ if (canonical.startsWith('xai/')) {
156
+ return canonical.slice('xai/'.length).replace(/^grok-4\.20/, 'grok-4-20');
157
+ }
158
+ if (canonical.startsWith('anthropic/')) {
159
+ return cursorAnthropicCliModel(canonical.slice('anthropic/'.length));
160
+ }
161
+ return cursorCliAlias(canonical);
162
+ }
163
+
63
164
  function canonicalModel(modelId, harness) {
64
165
  const raw = String(modelId || '').trim();
65
166
  if (!raw) {
@@ -197,7 +298,7 @@ function cliModelFor({ harness, modelId, cliModel, reasoningEffort }) {
197
298
  }
198
299
  if (harness === 'cursor') {
199
300
  // The adapter matches this against cursor-agent's ACP availableModels.
200
- return clean.replace(/^cursor\//, '');
301
+ return cursorCliModelFor(clean);
201
302
  }
202
303
  if (harness === 'pi') {
203
304
  // Pi's vercel-ai-gateway provider takes bare '<provider>/<model>' ids.
@@ -35,7 +35,7 @@ Cursor rides the generic ACP client (`../adapters/acp-client.js`) + `cursor.js`
35
35
  | `usage_update` / `PromptResponse.usage` | `usage.final` | ⚠️ mapped but **cursor emits neither today** — no token usage exists for this harness; snapshots are `billable:false, exact:false`, prompt usage would be `exact:true chat_turn` the day Cursor ships the ACP session-usage extension |
36
36
  | `session_info_update`, `available_commands_update`, `plan`, `current_mode_update`, `user_message_chunk` (session/load replay) | dropped | ❌ intentional |
37
37
 
38
- Sessions: one `cursor-agent acp` process per amalgm session; resume = `session/load` (full history replay is discarded — nothing subscribes during `create`). Model selection: `session/set_model` accepts ONLY exact ids from `availableModels` (base + bracket params); `matchAcpModel` resolves amalgm cliModels (bare base names, legacy effort/speed suffixes stripped) to those ids. Auth: provider_auth only — the login token lives in the macOS Keychain, so the managed home gets a `Library/Keychains` alias (`syncCursorProviderAuth`).
38
+ Sessions: one `cursor-agent acp` process per amalgm session; resume = `session/load` (full history replay is discarded — nothing subscribes during `create`). Model selection: `session/set_model` accepts ONLY ids Cursor advertises over ACP. Amalgm keeps central ids separate (`anthropic/claude-sonnet-5-high`, `openai/gpt-5.5-medium`, `cursor/composer-2.5`) and `cliModelFor` translates them to current Cursor CLI-style ids (`claude-sonnet-5-high`, `claude-sonnet-5-thinking-high`, `claude-4.6-sonnet-medium`, `composer-2.5-fast`). `matchAcpModel` then resolves those cliModels to ACP `availableModels` ids; ACP uses base names plus bracket params, so version-first CLI names can map to family-first ACP bases (`claude-4.6-sonnet-medium` -> `claude-sonnet-4-6[...]`). Raw provider ids cannot be sent straight to Cursor; stale ids like `composer-2-fast` are aliases only. Auth: provider_auth only — the login token lives in the macOS Keychain, so the managed home gets a `Library/Keychains` alias (`syncCursorProviderAuth`).
39
39
 
40
40
  ---
41
41
 
@@ -264,6 +264,14 @@ We intentionally keep separate identities:
264
264
  `Opus` and `Opus 1M` can share the same billing model while using different
265
265
  context profiles.
266
266
 
267
+ Cursor follows the same split but is not a billing surface for us: display /
268
+ central ids may be provider-shaped (`anthropic/claude-sonnet-5-high`), while
269
+ the runtime cliModel is a Cursor CLI-style token (`claude-sonnet-5-high`,
270
+ `claude-4.6-sonnet-medium`, `composer-2.5-fast`). The ACP adapter may translate
271
+ that again to a Cursor-advertised ACP id (`claude-sonnet-4-6[...]`). Cursor's
272
+ `gatewayModelId` is for grouping/analytics only until Cursor emits real token
273
+ usage.
274
+
267
275
  ## Provider mapping targets
268
276
 
269
277
  ### Claude Code
@@ -18,7 +18,11 @@ test('cursor harness resolves through contract model naming', () => {
18
18
  assert.equal(canonicalModel('cursor/composer-2.5', 'cursor'), 'cursor/composer-2.5');
19
19
  assert.equal(canonicalModel('anthropic/claude-opus-4.8', 'cursor'), 'anthropic/claude-opus-4.8');
20
20
  assert.equal(cliModelFor({ harness: 'cursor', modelId: 'cursor/composer-2.5' }), 'composer-2.5');
21
- assert.equal(cliModelFor({ harness: 'cursor', modelId: 'anthropic/claude-opus-4.8', cliModel: 'claude-opus-4-8' }), 'claude-opus-4-8');
21
+ assert.equal(cliModelFor({ harness: 'cursor', modelId: 'cursor/composer-2-fast' }), 'composer-2.5-fast');
22
+ assert.equal(cliModelFor({ harness: 'cursor', modelId: 'anthropic/claude-sonnet-5' }), 'claude-sonnet-5-high');
23
+ assert.equal(cliModelFor({ harness: 'cursor', modelId: 'anthropic/claude-sonnet-5-thinking' }), 'claude-sonnet-5-thinking-high');
24
+ assert.equal(cliModelFor({ harness: 'cursor', modelId: 'anthropic/claude-sonnet-4.6' }), 'claude-4.6-sonnet-medium');
25
+ assert.equal(cliModelFor({ harness: 'cursor', modelId: 'anthropic/claude-opus-4.8', cliModel: 'claude-opus-4-8' }), 'claude-opus-4-8-high');
22
26
  });
23
27
 
24
28
  test('cursor is provider_auth-only with a stable per-agent CLI home', () => {
@@ -41,12 +45,19 @@ test('matchAcpModel resolves amalgm cliModels to exact ACP model ids', () => {
41
45
  const availableModels = [
42
46
  { modelId: 'default[]', name: 'Auto' },
43
47
  { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
48
+ { modelId: 'claude-sonnet-5[thinking=true,context=300k,effort=high]', name: 'claude-sonnet-5' },
49
+ { modelId: 'claude-sonnet-4-6[thinking=true,context=200k,effort=medium]', name: 'claude-sonnet-4-6' },
44
50
  { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]', name: 'claude-opus-4-8' },
45
51
  { modelId: 'gpt-5.3-codex[reasoning=medium,fast=false]', name: 'gpt-5.3-codex' },
52
+ { modelId: 'gpt-5.5[context=272k,reasoning=medium,fast=false]', name: 'gpt-5.5' },
46
53
  ];
47
54
  assert.equal(matchAcpModel(availableModels, 'composer-2.5').modelId, 'composer-2.5[fast=true]');
48
55
  assert.equal(matchAcpModel(availableModels, 'cursor/composer-2.5').modelId, 'composer-2.5[fast=true]');
56
+ assert.equal(matchAcpModel(availableModels, 'composer-2.5-fast').modelId, 'composer-2.5[fast=true]');
49
57
  assert.equal(matchAcpModel(availableModels, 'auto').modelId, 'default[]');
58
+ assert.equal(matchAcpModel(availableModels, 'claude-sonnet-5-high').modelId, 'claude-sonnet-5[thinking=true,context=300k,effort=high]');
59
+ assert.equal(matchAcpModel(availableModels, 'claude-4.6-sonnet-medium').modelId, 'claude-sonnet-4-6[thinking=true,context=200k,effort=medium]');
60
+ assert.equal(matchAcpModel(availableModels, 'gpt-5.5-extra-high').modelId, 'gpt-5.5[context=272k,reasoning=medium,fast=false]');
50
61
  assert.equal(matchAcpModel(availableModels, 'claude-opus-4-8').modelId, 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]');
51
62
  // Legacy effort/speed suffixes strip down to the ACP base model.
52
63
  assert.equal(matchAcpModel(availableModels, 'gpt-5.3-codex-high').modelId, 'gpt-5.3-codex[reasoning=medium,fast=false]');