amalgm 0.1.135 → 0.1.136

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 (37) hide show
  1. package/package.json +1 -1
  2. package/runtime/lib/harnesses.js +61 -162
  3. package/runtime/scripts/amalgm-mcp/agent-bundles/app-entries.js +124 -0
  4. package/runtime/scripts/amalgm-mcp/agent-bundles/app-files.js +160 -0
  5. package/runtime/scripts/amalgm-mcp/agent-bundles/automation-entries.js +186 -0
  6. package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +272 -32
  7. package/runtime/scripts/amalgm-mcp/agent-bundles/rest.js +25 -8
  8. package/runtime/scripts/amalgm-mcp/automations/rest.js +21 -11
  9. package/runtime/scripts/amalgm-mcp/automations/store.js +68 -1
  10. package/runtime/scripts/amalgm-mcp/automations/tools.js +13 -4
  11. package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +1 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-deeplinks.js +117 -0
  13. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +42 -19
  14. package/runtime/scripts/amalgm-mcp/lib/email-md.js +24 -17
  15. package/runtime/scripts/amalgm-mcp/notify/index.js +6 -2
  16. package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
  17. package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +8 -8
  18. package/runtime/scripts/amalgm-mcp/tests/bundle-entries.test.js +288 -0
  19. package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +37 -1
  20. package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +22 -0
  21. package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +15 -5
  22. package/runtime/scripts/amalgm-mcp/tests/email-embeds.test.js +35 -0
  23. package/runtime/scripts/amalgm-mcp/workspace/rest.js +1 -0
  24. package/runtime/scripts/chat-core/adapters/acp-client.js +156 -0
  25. package/runtime/scripts/chat-core/adapters/cursor.js +254 -0
  26. package/runtime/scripts/chat-core/adapters/pi.js +302 -0
  27. package/runtime/scripts/chat-core/auth.js +20 -3
  28. package/runtime/scripts/chat-core/chat-payload.js +2 -0
  29. package/runtime/scripts/chat-core/contract.js +56 -1
  30. package/runtime/scripts/chat-core/normalizers/cursor.js +174 -0
  31. package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +38 -0
  32. package/runtime/scripts/chat-core/normalizers/pi.js +143 -0
  33. package/runtime/scripts/chat-core/server.js +4 -0
  34. package/runtime/scripts/chat-core/tests/cursor.test.js +178 -0
  35. package/runtime/scripts/chat-core/tests/pi.test.js +182 -0
  36. package/runtime/scripts/chat-core/tooling/native-config.js +38 -0
  37. package/runtime/scripts/chat-core/usage.js +20 -0
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ compactionFinished,
5
+ compactionStarted,
6
+ errorEvent,
7
+ reasoningDelta,
8
+ textDelta,
9
+ toolCompleted,
10
+ toolStarted,
11
+ toolUpdated,
12
+ usageFinal,
13
+ warningEvent,
14
+ } = require('../events');
15
+
16
+ function contentToText(content) {
17
+ if (typeof content === 'string') return content;
18
+ if (!Array.isArray(content)) return '';
19
+ return content
20
+ .map((item) => (item && item.type === 'text' && typeof item.text === 'string' ? item.text : ''))
21
+ .filter(Boolean)
22
+ .join('\n');
23
+ }
24
+
25
+ // pi AgentMessage.usage ({input, output, cacheRead, cacheWrite, totalTokens,
26
+ // cost.total, contextSize?}) -> exact per-step usage. Pi is the only harness
27
+ // that reports native USD cost per message, so costUsd rides along.
28
+ function piMessageUsage(message, base = {}) {
29
+ const usage = message?.usage;
30
+ if (!usage || typeof usage !== 'object') return null;
31
+ const total = Number(usage.totalTokens);
32
+ const hasTokens = ['input', 'output', 'cacheRead', 'cacheWrite'].some((key) => Number(usage[key]) > 0);
33
+ if (!hasTokens && !(Number.isFinite(total) && total > 0)) return null;
34
+ const contextSize = Number(usage.contextSize);
35
+ return usageFinal({
36
+ inputTokens: usage.input,
37
+ outputTokens: usage.output,
38
+ cacheReadTokens: usage.cacheRead,
39
+ cacheWriteTokens: usage.cacheWrite,
40
+ ...(Number.isFinite(contextSize) && contextSize > 0 ? { inputContextSize: contextSize, contextUsedTokens: contextSize } : {}),
41
+ costUsd: Number.isFinite(Number(usage.cost?.total)) ? Number(usage.cost.total) : null,
42
+ operation: 'chat_step',
43
+ billable: true,
44
+ exact: true,
45
+ source: 'pi_rpc_message_usage',
46
+ }, base);
47
+ }
48
+
49
+ // get_session_stats data -> context-circle snapshot (never billed; the exact
50
+ // per-message records above are the billing source).
51
+ function piSessionStatsUsage(stats, base = {}) {
52
+ const contextUsage = stats?.contextUsage;
53
+ if (!contextUsage || !Number.isFinite(Number(contextUsage.tokens))) return null;
54
+ return usageFinal({
55
+ usedTokens: contextUsage.tokens,
56
+ maxTokens: contextUsage.contextWindow,
57
+ totalProcessedTokens: stats?.tokens?.total,
58
+ costUsd: Number.isFinite(Number(stats?.cost)) ? Number(stats.cost) : null,
59
+ operation: 'context_snapshot',
60
+ billable: false,
61
+ exact: false,
62
+ source: 'pi_rpc_session_stats',
63
+ }, base);
64
+ }
65
+
66
+ // One pi RPC event -> amalgm events. `state` is per-prompt: providerSessionId
67
+ // plus tool bookkeeping (tool_execution_start may race toolcall_end).
68
+ function normalizePiEvent(event, state) {
69
+ const base = { providerSessionId: state.providerSessionId };
70
+ const type = event?.type;
71
+ if (type === 'message_update') {
72
+ const delta = event.assistantMessageEvent || {};
73
+ if (delta.type === 'text_delta' && delta.delta) return [textDelta(delta.delta, base)];
74
+ if (delta.type === 'thinking_delta' && delta.delta) return [reasoningDelta(delta.delta, base)];
75
+ if (delta.type === 'error' && delta.reason !== 'aborted') {
76
+ return [errorEvent(event.message?.errorMessage || event.message?.error || 'Pi model error', base)];
77
+ }
78
+ return [];
79
+ }
80
+ if (type === 'message_end') {
81
+ const message = event.message || {};
82
+ if (message.role && message.role !== 'assistant') return [];
83
+ const usage = piMessageUsage(message, base);
84
+ return usage ? [usage] : [];
85
+ }
86
+ if (type === 'tool_execution_start') {
87
+ state.tools.add(event.toolCallId);
88
+ return [toolStarted(event.toolCallId, {
89
+ ...base,
90
+ toolName: event.toolName || 'tool',
91
+ input: event.args,
92
+ })];
93
+ }
94
+ if (type === 'tool_execution_update') {
95
+ const text = contentToText(event.partialResult?.content);
96
+ if (!text) return [];
97
+ return [toolUpdated(event.toolCallId, {
98
+ ...base,
99
+ toolName: event.toolName || 'tool',
100
+ output: text,
101
+ })];
102
+ }
103
+ if (type === 'tool_execution_end') {
104
+ state.tools.delete(event.toolCallId);
105
+ const output = contentToText(event.result?.content);
106
+ return [toolCompleted(event.toolCallId, {
107
+ ...base,
108
+ toolName: event.toolName || 'tool',
109
+ ...(output ? { output } : {}),
110
+ isError: event.isError === true,
111
+ ...(event.isError === true ? { error: output || 'Tool call failed' } : {}),
112
+ })];
113
+ }
114
+ if (type === 'compaction_start') {
115
+ return [compactionStarted({ ...base, trigger: event.reason === 'manual' ? 'manual' : 'auto' })];
116
+ }
117
+ if (type === 'compaction_end') {
118
+ return [compactionFinished({
119
+ ...base,
120
+ trigger: event.reason === 'manual' ? 'manual' : 'auto',
121
+ preTokens: event.result?.tokensBefore,
122
+ })];
123
+ }
124
+ if (type === 'auto_retry_start') {
125
+ return [warningEvent(`Pi is retrying after a transient error${event.error ? `: ${event.error}` : ''}`, base)];
126
+ }
127
+ if (type === 'extension_error') {
128
+ return [warningEvent(`Pi extension error: ${event.error || 'unknown'}`, base)];
129
+ }
130
+ if (type === 'error') {
131
+ return [errorEvent(event.message || event.error || 'Pi agent error', base)];
132
+ }
133
+ // agent_start/agent_end/turn_start/turn_end/message_start/queue_update and
134
+ // toolcall_* deltas are lifecycle noise here; the adapter drives `done` off
135
+ // agent_end so it can attach the final context snapshot first.
136
+ return [];
137
+ }
138
+
139
+ module.exports = {
140
+ normalizePiEvent,
141
+ piMessageUsage,
142
+ piSessionStatsUsage,
143
+ };
@@ -5,7 +5,9 @@ const { PORT, PROXY_BASE_URL, ensureFreshProxyToken } = require('../chat-server/
5
5
  const db = require('../chat-server/db');
6
6
  const { CodexAdapter } = require('./adapters/codex');
7
7
  const { ClaudeAdapter } = require('./adapters/claude');
8
+ const { CursorAdapter } = require('./adapters/cursor');
8
9
  const { OpenCodeAdapter } = require('./adapters/opencode');
10
+ const { PiAdapter } = require('./adapters/pi');
9
11
  const { ChatCore } = require('./engine');
10
12
  const { forwardEgress, parseEgressPath } = require('./egress');
11
13
  const { forwardMcpRelay, parseMcpRelayPath } = require('./tooling/mcp-relay');
@@ -60,7 +62,9 @@ function createCore(options = {}) {
60
62
  adapters: {
61
63
  claude_code: () => new ClaudeAdapter(),
62
64
  codex: () => new CodexAdapter(),
65
+ cursor: () => new CursorAdapter(),
63
66
  opencode: () => new OpenCodeAdapter(),
67
+ pi: () => new PiAdapter(),
64
68
  },
65
69
  });
66
70
  return new ChatCore({ db, runtime, turns, options: { ...options, port: PORT } });
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const test = require('node:test');
8
+ const { authEnvelope, coerceAuth, runtimeEnv } = require('../auth');
9
+ const { agentToHarness, canonicalModel, cliModelFor } = require('../contract');
10
+ const { cursorPromptUsage, cursorStopReason, normalizeCursorUpdate } = require('../normalizers/cursor');
11
+ const { __private: cursorPrivate } = require('../adapters/cursor');
12
+ const { syncCursorProviderAuth } = require('../tooling/native-config');
13
+
14
+ const { matchAcpModel, pickPermissionOption } = cursorPrivate;
15
+
16
+ test('cursor harness resolves through contract model naming', () => {
17
+ assert.equal(agentToHarness('cursor'), 'cursor');
18
+ assert.equal(canonicalModel('cursor/composer-2.5', 'cursor'), 'cursor/composer-2.5');
19
+ assert.equal(canonicalModel('anthropic/claude-opus-4.8', 'cursor'), 'anthropic/claude-opus-4.8');
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');
22
+ });
23
+
24
+ test('cursor is provider_auth-only with a stable per-agent CLI home', () => {
25
+ assert.equal(coerceAuth('cursor', 'amalgm'), 'provider_auth');
26
+ assert.equal(coerceAuth('cursor', 'byok'), 'provider_auth');
27
+ const envelope = authEnvelope({
28
+ harness: 'cursor',
29
+ authMethod: 'provider_auth',
30
+ sessionId: 'session-test',
31
+ amalgmDir: '/tmp/amalgm-test',
32
+ agentConfigId: 'qa-agent',
33
+ });
34
+ assert.equal(envelope.method, 'provider_auth');
35
+ assert.equal(envelope.runtimeHome, '/tmp/amalgm-test/cli-homes/cursor/agents/qa-agent/home');
36
+ const env = runtimeEnv({ harness: 'cursor', authMethod: 'provider_auth', auth: envelope }, { PATH: '/bin', HOME: '/Users/example' });
37
+ assert.equal(env.HOME, envelope.runtimeHome);
38
+ });
39
+
40
+ test('matchAcpModel resolves amalgm cliModels to exact ACP model ids', () => {
41
+ const availableModels = [
42
+ { modelId: 'default[]', name: 'Auto' },
43
+ { modelId: 'composer-2.5[fast=true]', name: 'composer-2.5' },
44
+ { modelId: 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]', name: 'claude-opus-4-8' },
45
+ { modelId: 'gpt-5.3-codex[reasoning=medium,fast=false]', name: 'gpt-5.3-codex' },
46
+ ];
47
+ assert.equal(matchAcpModel(availableModels, 'composer-2.5').modelId, 'composer-2.5[fast=true]');
48
+ assert.equal(matchAcpModel(availableModels, 'cursor/composer-2.5').modelId, 'composer-2.5[fast=true]');
49
+ assert.equal(matchAcpModel(availableModels, 'auto').modelId, 'default[]');
50
+ assert.equal(matchAcpModel(availableModels, 'claude-opus-4-8').modelId, 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]');
51
+ // Legacy effort/speed suffixes strip down to the ACP base model.
52
+ assert.equal(matchAcpModel(availableModels, 'gpt-5.3-codex-high').modelId, 'gpt-5.3-codex[reasoning=medium,fast=false]');
53
+ assert.equal(matchAcpModel(availableModels, 'claude-opus-4-8-thinking-high-fast').modelId, 'claude-opus-4-8[thinking=true,context=300k,effort=high,fast=false]');
54
+ assert.equal(matchAcpModel(availableModels, 'not-a-model'), null);
55
+ });
56
+
57
+ test('permission requests prefer allow_always then allow_once', () => {
58
+ assert.equal(pickPermissionOption([
59
+ { optionId: 'r', kind: 'reject_once' },
60
+ { optionId: 'a1', kind: 'allow_once' },
61
+ { optionId: 'aa', kind: 'allow_always' },
62
+ ]).optionId, 'aa');
63
+ assert.equal(pickPermissionOption([
64
+ { optionId: 'r', kind: 'reject_once' },
65
+ { optionId: 'a1', kind: 'allow_once' },
66
+ ]).optionId, 'a1');
67
+ assert.equal(pickPermissionOption([]), null);
68
+ });
69
+
70
+ test('cursor normalizer maps ACP session updates to amalgm events', () => {
71
+ const state = { providerSessionId: 'prov-1', tools: new Map() };
72
+
73
+ const text = normalizeCursorUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }, state);
74
+ assert.equal(text[0].type, 'text.delta');
75
+ assert.equal(text[0].text, 'hi');
76
+ assert.equal(text[0].providerSessionId, 'prov-1');
77
+
78
+ const thought = normalizeCursorUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'hm' } }, state);
79
+ assert.equal(thought[0].type, 'reasoning.delta');
80
+
81
+ const started = normalizeCursorUpdate({
82
+ sessionUpdate: 'tool_call',
83
+ toolCallId: 't1',
84
+ title: '`ls`',
85
+ kind: 'execute',
86
+ status: 'pending',
87
+ rawInput: { command: 'ls' },
88
+ }, state);
89
+ assert.equal(started[0].type, 'tool.started');
90
+ assert.equal(started[0].toolCallId, 't1');
91
+ assert.equal(started[0].kind, 'bash');
92
+ assert.deepEqual(started[0].input, { command: 'ls' });
93
+
94
+ const completed = normalizeCursorUpdate({
95
+ sessionUpdate: 'tool_call_update',
96
+ toolCallId: 't1',
97
+ status: 'completed',
98
+ rawOutput: { exitCode: 0, stdout: 'file.txt\n', stderr: '' },
99
+ }, state);
100
+ assert.equal(completed[0].type, 'tool.completed');
101
+ assert.equal(completed[0].isError, false);
102
+ assert.equal(completed[0].toolName, '`ls`');
103
+ assert.deepEqual(completed[0].output, { text: 'file.txt', exitCode: 0 });
104
+
105
+ const editStarted = normalizeCursorUpdate({
106
+ sessionUpdate: 'tool_call', toolCallId: 't2', title: 'Edit File', kind: 'edit', status: 'pending', rawInput: {},
107
+ }, state);
108
+ assert.equal(editStarted[0].kind, 'file_edit');
109
+ const editDone = normalizeCursorUpdate({
110
+ sessionUpdate: 'tool_call_update',
111
+ toolCallId: 't2',
112
+ status: 'completed',
113
+ content: [{ type: 'diff', path: '/tmp/a.txt', oldText: null, newText: 'hello' }],
114
+ }, state);
115
+ assert.equal(editDone[0].type, 'tool.completed');
116
+ assert.deepEqual(editDone[0].output, { path: '/tmp/a.txt', oldText: null, newText: 'hello' });
117
+
118
+ const failed = normalizeCursorUpdate({
119
+ sessionUpdate: 'tool_call', toolCallId: 't3', title: 'Read File', kind: 'read', status: 'pending', rawInput: {},
120
+ }, state) && normalizeCursorUpdate({
121
+ sessionUpdate: 'tool_call_update', toolCallId: 't3', status: 'failed',
122
+ }, state);
123
+ assert.equal(failed[0].type, 'tool.completed');
124
+ assert.equal(failed[0].isError, true);
125
+
126
+ assert.deepEqual(normalizeCursorUpdate({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'replay' } }, state), []);
127
+ assert.deepEqual(normalizeCursorUpdate({ sessionUpdate: 'session_info_update', title: 'x' }, state), []);
128
+ });
129
+
130
+ test('cursor usage mappings stay honest about exactness', () => {
131
+ const state = { providerSessionId: 'prov-1', tools: new Map() };
132
+ const snapshot = normalizeCursorUpdate({ sessionUpdate: 'usage_update', used: 1200, size: 200000 }, state);
133
+ assert.equal(snapshot[0].type, 'usage.final');
134
+ assert.equal(snapshot[0].usage.billable, false);
135
+ assert.equal(snapshot[0].usage.exact, false);
136
+ assert.equal(snapshot[0].usage.usedTokens, 1200);
137
+ assert.equal(snapshot[0].usage.maxTokens, 200000);
138
+
139
+ assert.equal(cursorPromptUsage(undefined), null);
140
+ assert.equal(cursorPromptUsage({}), null);
141
+ const exact = cursorPromptUsage({ input_tokens: 10, output_tokens: 20, cached_read_tokens: 5, total_tokens: 35 });
142
+ assert.equal(exact.usage.exact, true);
143
+ assert.equal(exact.usage.billable, true);
144
+ assert.equal(exact.usage.operation, 'chat_turn');
145
+ assert.equal(exact.usage.tokenList.total, 35);
146
+ });
147
+
148
+ test('cursor stop reasons map onto amalgm done reasons', () => {
149
+ assert.equal(cursorStopReason('end_turn'), 'end_turn');
150
+ assert.equal(cursorStopReason('cancelled'), 'cancelled');
151
+ assert.equal(cursorStopReason('refusal'), 'refusal');
152
+ assert.equal(cursorStopReason('max_tokens'), 'max_tokens');
153
+ assert.equal(cursorStopReason(undefined), 'end_turn');
154
+ });
155
+
156
+ test('cursor provider auth sync seeds the managed home without clobbering it', () => {
157
+ const nativeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-native-'));
158
+ const runtimeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-runtime-'));
159
+ const previousNative = process.env.AMALGM_NATIVE_HOME;
160
+ process.env.AMALGM_NATIVE_HOME = nativeHome;
161
+ try {
162
+ fs.mkdirSync(path.join(nativeHome, '.cursor'), { recursive: true });
163
+ fs.writeFileSync(path.join(nativeHome, '.cursor', 'cli-config.json'), '{"version":1}');
164
+
165
+ syncCursorProviderAuth(runtimeHome);
166
+ assert.equal(fs.readFileSync(path.join(runtimeHome, '.cursor', 'cli-config.json'), 'utf8'), '{"version":1}');
167
+
168
+ // The copy is seed-only: a config evolved inside the managed home wins.
169
+ fs.writeFileSync(path.join(runtimeHome, '.cursor', 'cli-config.json'), '{"version":2}');
170
+ syncCursorProviderAuth(runtimeHome);
171
+ assert.equal(fs.readFileSync(path.join(runtimeHome, '.cursor', 'cli-config.json'), 'utf8'), '{"version":2}');
172
+ } finally {
173
+ if (previousNative === undefined) delete process.env.AMALGM_NATIVE_HOME;
174
+ else process.env.AMALGM_NATIVE_HOME = previousNative;
175
+ fs.rmSync(nativeHome, { recursive: true, force: true });
176
+ fs.rmSync(runtimeHome, { recursive: true, force: true });
177
+ }
178
+ });
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const test = require('node:test');
8
+ const { authEnvelope, coerceAuth, runtimeEnv } = require('../auth');
9
+ const { agentToHarness, canonicalModel, cliModelFor } = require('../contract');
10
+ const { normalizePiEvent, piMessageUsage, piSessionStatsUsage } = require('../normalizers/pi');
11
+ const { finalizeTurnUsage } = require('../usage');
12
+ const { __private: piPrivate } = require('../adapters/pi');
13
+
14
+ test('pi harness resolves gateway model naming', () => {
15
+ assert.equal(agentToHarness('pi'), 'pi');
16
+ assert.equal(canonicalModel('pi-anthropic-claude-sonnet-4.6', 'pi'), 'anthropic/claude-sonnet-4.6');
17
+ assert.equal(canonicalModel('vercel/deepseek/deepseek-v4-pro', 'pi'), 'deepseek/deepseek-v4-pro');
18
+ assert.equal(canonicalModel('anthropic/claude-haiku-4.5', 'pi'), 'anthropic/claude-haiku-4.5');
19
+ assert.equal(canonicalModel('pi-prime-intellect-intellect-3', 'pi'), 'prime-intellect/intellect-3');
20
+ assert.equal(cliModelFor({ harness: 'pi', modelId: 'pi-anthropic-claude-sonnet-4.6' }), 'anthropic/claude-sonnet-4.6');
21
+ assert.equal(cliModelFor({ harness: 'pi', modelId: 'anthropic/claude-haiku-4.5', cliModel: 'vercel/anthropic/claude-haiku-4.5' }), 'anthropic/claude-haiku-4.5');
22
+ });
23
+
24
+ test('pi supports all auth methods with a stable per-agent home and gateway env', () => {
25
+ assert.equal(coerceAuth('pi', 'amalgm'), 'amalgm');
26
+ assert.equal(coerceAuth('pi', 'byok'), 'byok');
27
+ assert.equal(coerceAuth('pi', 'provider_auth'), 'provider_auth');
28
+ const envelope = authEnvelope({
29
+ harness: 'pi',
30
+ authMethod: 'amalgm',
31
+ sessionId: 'session-pi',
32
+ proxyToken: 'proxy-token',
33
+ localBaseUrl: 'http://127.0.0.1:8084',
34
+ proxyBaseUrl: 'https://proxy.example.test',
35
+ amalgmDir: '/tmp/amalgm-test',
36
+ userId: 'user-123',
37
+ });
38
+ assert.equal(envelope.runtimeHome, '/tmp/amalgm-test/cli-homes/pi/agents/pi/home');
39
+ // Pi appends /v1/messages itself, so egress terminates at /ai_gateway.
40
+ assert.equal(envelope.baseUrl, 'http://127.0.0.1:8084/egress/session-pi/ai_gateway');
41
+ assert.equal(envelope.forwardBaseUrl, 'https://proxy.example.test/ai_gateway');
42
+ const env = runtimeEnv({ harness: 'pi', authMethod: 'amalgm', auth: envelope }, { PATH: '/bin' });
43
+ assert.equal(env.AI_GATEWAY_API_KEY, envelope.tokenRef);
44
+ assert.equal(env.AI_GATEWAY_BASE_URL, undefined);
45
+ assert.equal(env.HOME, envelope.runtimeHome);
46
+ });
47
+
48
+ test('pi adapter args carry model, thinking, system prompt, resume, and egress extension', () => {
49
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-home-'));
50
+ try {
51
+ const contract = {
52
+ cliModel: 'anthropic/claude-haiku-4.5',
53
+ reasoningEffort: 'high',
54
+ providerSessionId: '/sessions/prior.jsonl',
55
+ authMethod: 'amalgm',
56
+ auth: { baseUrl: 'http://127.0.0.1:8084/egress/s/ai_gateway' },
57
+ systemPrompt: 'be helpful',
58
+ agentConfig: null,
59
+ };
60
+ const args = piPrivate.buildArgs(contract, home);
61
+ assert.deepEqual(args.slice(0, 6), ['--mode', 'rpc', '--provider', 'vercel-ai-gateway', '--model', 'anthropic/claude-haiku-4.5']);
62
+ assert.ok(args.includes('--thinking') && args[args.indexOf('--thinking') + 1] === 'high');
63
+ assert.ok(args.includes('--session') && args[args.indexOf('--session') + 1] === '/sessions/prior.jsonl');
64
+ const extension = args[args.indexOf('--extension') + 1];
65
+ assert.ok(extension.startsWith(home));
66
+ const source = fs.readFileSync(extension, 'utf8');
67
+ assert.match(source, /registerProvider\("vercel-ai-gateway"/);
68
+ assert.match(source, /AMALGM_PI_GATEWAY_BASE_URL/);
69
+ // provider_auth spawns without the egress extension.
70
+ const nativeArgs = piPrivate.buildArgs({ ...contract, authMethod: 'provider_auth', providerSessionId: null }, home);
71
+ assert.equal(nativeArgs.includes('--extension'), false);
72
+ assert.equal(nativeArgs.includes('--session'), false);
73
+ } finally {
74
+ fs.rmSync(home, { recursive: true, force: true });
75
+ }
76
+ });
77
+
78
+ test('pi normalizer maps rpc events to amalgm events', () => {
79
+ const state = { providerSessionId: 'sess.jsonl', tools: new Set() };
80
+
81
+ const text = normalizePiEvent({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hi' } }, state);
82
+ assert.equal(text[0].type, 'text.delta');
83
+ assert.equal(text[0].text, 'hi');
84
+
85
+ const thinking = normalizePiEvent({ type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: 'hm' } }, state);
86
+ assert.equal(thinking[0].type, 'reasoning.delta');
87
+
88
+ const started = normalizePiEvent({ type: 'tool_execution_start', toolCallId: 'c1', toolName: 'bash', args: { command: 'ls' } }, state);
89
+ assert.equal(started[0].type, 'tool.started');
90
+ assert.equal(started[0].kind, 'bash');
91
+
92
+ const updated = normalizePiEvent({
93
+ type: 'tool_execution_update',
94
+ toolCallId: 'c1',
95
+ toolName: 'bash',
96
+ partialResult: { content: [{ type: 'text', text: 'partial' }] },
97
+ }, state);
98
+ assert.equal(updated[0].type, 'tool.updated');
99
+ assert.equal(updated[0].output, 'partial');
100
+
101
+ const completed = normalizePiEvent({
102
+ type: 'tool_execution_end',
103
+ toolCallId: 'c1',
104
+ toolName: 'bash',
105
+ result: { content: [{ type: 'text', text: 'done output' }] },
106
+ isError: false,
107
+ }, state);
108
+ assert.equal(completed[0].type, 'tool.completed');
109
+ assert.equal(completed[0].output, 'done output');
110
+ assert.equal(completed[0].isError, false);
111
+
112
+ const err = normalizePiEvent({
113
+ type: 'message_update',
114
+ message: { errorMessage: 'boom' },
115
+ assistantMessageEvent: { type: 'error', reason: 'error' },
116
+ }, state);
117
+ assert.equal(err[0].type, 'error');
118
+ assert.equal(err[0].message, 'boom');
119
+ assert.deepEqual(normalizePiEvent({
120
+ type: 'message_update',
121
+ assistantMessageEvent: { type: 'error', reason: 'aborted' },
122
+ }, state), []);
123
+
124
+ const compaction = normalizePiEvent({ type: 'compaction_end', reason: 'threshold', result: { tokensBefore: 1000 } }, state);
125
+ assert.equal(compaction[0].type, 'compaction.finished');
126
+ assert.equal(compaction[0].preTokens, 1000);
127
+ });
128
+
129
+ test('pi usage is exact per assistant message with native cost', () => {
130
+ const usage = piMessageUsage({
131
+ role: 'assistant',
132
+ usage: {
133
+ input: 100,
134
+ output: 50,
135
+ cacheRead: 400,
136
+ cacheWrite: 20,
137
+ totalTokens: 570,
138
+ cost: { total: 0.0123 },
139
+ },
140
+ });
141
+ assert.equal(usage.usage.exact, true);
142
+ assert.equal(usage.usage.billable, true);
143
+ assert.equal(usage.usage.operation, 'chat_step');
144
+ assert.equal(usage.usage.costUsd, 0.0123);
145
+ assert.deepEqual(usage.usage.tokenList, { input: 100, output: 50, cacheRead: 400, cacheWrite: 20, thought: 0, total: 570 });
146
+ assert.equal(piMessageUsage({ role: 'assistant' }), null);
147
+ assert.equal(piMessageUsage({ role: 'assistant', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 } }), null);
148
+
149
+ const snapshot = piSessionStatsUsage({
150
+ tokens: { total: 105000 },
151
+ cost: 0.45,
152
+ contextUsage: { tokens: 60000, contextWindow: 200000, percent: 30 },
153
+ });
154
+ assert.equal(snapshot.usage.billable, false);
155
+ assert.equal(snapshot.usage.exact, false);
156
+ assert.equal(snapshot.usage.usedTokens, 60000);
157
+ assert.equal(snapshot.usage.maxTokens, 200000);
158
+ assert.equal(piSessionStatsUsage({ tokens: { total: 10 } }), null);
159
+ });
160
+
161
+ test('pi turn usage sums exact steps and keeps native gateway cost', () => {
162
+ const step = (input, output, cost) => piMessageUsage({
163
+ role: 'assistant',
164
+ usage: { input, output, cacheRead: 0, cacheWrite: 0, totalTokens: input + output, cost: { total: cost } },
165
+ }).usage;
166
+ const snapshot = piSessionStatsUsage({
167
+ tokens: { total: 400 },
168
+ cost: 0.03,
169
+ contextUsage: { tokens: 300, contextWindow: 200000 },
170
+ }).usage;
171
+ const turn = finalizeTurnUsage({
172
+ contract: { harness: 'pi' },
173
+ usageRecords: [step(100, 50, 0.01), snapshot, step(120, 30, 0.02)],
174
+ });
175
+ assert.equal(turn.operation, 'chat_turn');
176
+ assert.equal(turn.exact, true);
177
+ assert.deepEqual([turn.tokenList.input, turn.tokenList.output], [220, 80]);
178
+ assert.equal(turn.costUsd.toFixed(2), '0.03');
179
+ assert.equal(turn.marketCostUsd.toFixed(2), '0.03');
180
+ // Snapshot-only records (no exact steps) must not produce a billing row.
181
+ assert.equal(finalizeTurnUsage({ contract: { harness: 'pi' }, usageRecords: [snapshot] }), null);
182
+ });
@@ -332,6 +332,40 @@ function syncClaudeProviderAuth(runtimeHome) {
332
332
  : null;
333
333
  }
334
334
 
335
+ // Cursor stores its login token in the macOS Keychain (service "cursor-access-token"),
336
+ // not in a file, so warm auth in a managed home rides on the Keychains alias.
337
+ // cli-config.json is copied once so the CLI starts from the user's preferences.
338
+ function syncCursorProviderAuth(runtimeHome) {
339
+ if (!runtimeHome) return null;
340
+ const home = nativeHome();
341
+ const sourceDir = path.join(home, '.cursor');
342
+ fs.mkdirSync(runtimeHome, { recursive: true });
343
+ const keychain = ensureNativeKeychainAlias(runtimeHome);
344
+ const copiedAuth = copyFileIfPresent(
345
+ path.join(sourceDir, 'auth.json'),
346
+ path.join(runtimeHome, '.cursor', 'auth.json'),
347
+ );
348
+ const targetConfig = path.join(runtimeHome, '.cursor', 'cli-config.json');
349
+ const copiedConfig = !exists(targetConfig) && copyFileIfPresent(path.join(sourceDir, 'cli-config.json'), targetConfig);
350
+ return keychain || copiedAuth || copiedConfig
351
+ ? { sourceDir, runtimeHome, copied: copiedAuth || copiedConfig, keychain, authOnly: true }
352
+ : null;
353
+ }
354
+
355
+ // Pi keeps provider keys and custom-provider config under ~/.pi; auth.json is
356
+ // the auth material, models.json/settings.json carry provider definitions the
357
+ // managed home needs to resolve the same models.
358
+ function syncPiProviderAuth(runtimeHome) {
359
+ if (!runtimeHome) return null;
360
+ const home = nativeHome();
361
+ const sourceDir = path.join(home, '.pi');
362
+ fs.mkdirSync(runtimeHome, { recursive: true });
363
+ const copied = ['agent/auth.json', 'agent/models.json', 'agent/settings.json']
364
+ .map((file) => copyFileIfPresent(path.join(sourceDir, file), path.join(runtimeHome, '.pi', file)))
365
+ .some(Boolean);
366
+ return copied ? { sourceDir, runtimeHome, copied: true, authOnly: true } : null;
367
+ }
368
+
335
369
  function syncOpenCodeProviderAuth(runtimeHome) {
336
370
  if (!runtimeHome) return null;
337
371
  const home = nativeHome();
@@ -400,6 +434,8 @@ function syncNativeProviderAuth(contract) {
400
434
  if (contract.harness === 'codex') return syncCodexProviderAuth(runtimeHome);
401
435
  if (contract.harness === 'claude_code') return syncClaudeProviderAuth(runtimeHome);
402
436
  if (contract.harness === 'opencode') return syncOpenCodeProviderAuth(runtimeHome);
437
+ if (contract.harness === 'cursor') return syncCursorProviderAuth(runtimeHome);
438
+ if (contract.harness === 'pi') return syncPiProviderAuth(runtimeHome);
403
439
  return null;
404
440
  }
405
441
 
@@ -421,8 +457,10 @@ module.exports = {
421
457
  syncClaudeNativeConfig,
422
458
  syncCodexProviderAuth,
423
459
  syncCodexNativeConfig,
460
+ syncCursorProviderAuth,
424
461
  syncNativeHarnessConfig,
425
462
  syncNativeProviderAuth,
426
463
  syncOpenCodeProviderAuth,
427
464
  syncOpenCodeNativeConfig,
465
+ syncPiProviderAuth,
428
466
  };
@@ -267,6 +267,26 @@ function finalizeTurnUsage({ contract, usage, usageRecords } = {}) {
267
267
  ? [usage]
268
268
  : [];
269
269
 
270
+ if (contract?.harness === 'pi') {
271
+ // Pi reports exact per-message usage including native USD cost; a turn is
272
+ // the sum of its assistant messages, and the summed provider cost survives
273
+ // into the turn record instead of being re-derived from the catalog.
274
+ const exactRecords = records.filter((entry) => entry?.exact === true && entry.billable !== false);
275
+ const summed = summedTurnUsage(exactRecords, {
276
+ contextLimit: usage?.contextLimit,
277
+ inputContextSize: usage?.inputContextSize,
278
+ source: 'pi_rpc_step_sum',
279
+ });
280
+ if (summed) {
281
+ const costUsd = exactRecords.reduce((total, entry) => {
282
+ const cost = Number(entry.costUsd);
283
+ return Number.isFinite(cost) ? (total ?? 0) + cost : total;
284
+ }, null);
285
+ return { ...summed, costUsd, marketCostUsd: costUsd ?? summed.marketCostUsd };
286
+ }
287
+ return null;
288
+ }
289
+
270
290
  if (contract?.harness === 'codex') {
271
291
  const codexUsage = codexTurnUsage(records, {
272
292
  contextLimit: usage?.contextLimit,