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.
- package/package.json +1 -1
- package/runtime/lib/harnesses.js +148 -29
- package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +6 -999
- package/runtime/scripts/amalgm-mcp/agent-bundles/export.js +383 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/graph.js +447 -0
- package/runtime/scripts/amalgm-mcp/agent-bundles/install.js +241 -0
- package/runtime/scripts/amalgm-mcp/apps/store.js +1 -0
- package/runtime/scripts/amalgm-mcp/apps/supervisor.js +22 -14
- package/runtime/scripts/amalgm-mcp/index.js +9 -3
- package/runtime/scripts/amalgm-mcp/lib/chat-payloads.js +57 -0
- package/runtime/scripts/amalgm-mcp/server/routes/health.js +34 -9
- package/runtime/scripts/amalgm-mcp/state/ports.js +98 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +3 -1
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -1
- package/runtime/scripts/amalgm-mcp/tests/apps-crash-loop.test.js +13 -2
- package/runtime/scripts/chat-core/adapters/cursor.js +14 -3
- package/runtime/scripts/chat-core/contract.js +102 -1
- package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +1 -1
- package/runtime/scripts/chat-core/normalizers/usage_contract.md +8 -0
- package/runtime/scripts/chat-core/tests/cursor.test.js +12 -1
|
@@ -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
|
|
32
|
-
//
|
|
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
|
|
42
|
-
const
|
|
43
|
-
|
|
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
|
|
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
|
|
412
|
-
if (app.status === 'degraded')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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: '
|
|
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]');
|