amalgm 0.1.51 → 0.1.52

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 (70) hide show
  1. package/lib/tunnel-events.js +48 -23
  2. package/package.json +2 -2
  3. package/runtime/lib/harnesses.js +12 -4
  4. package/runtime/scripts/amalgm-mcp/agents/store.js +5 -5
  5. package/runtime/scripts/amalgm-mcp/{artifacts → apps}/advertise.js +39 -24
  6. package/runtime/scripts/amalgm-mcp/apps/rest.js +144 -0
  7. package/runtime/scripts/amalgm-mcp/apps/store.js +171 -0
  8. package/runtime/scripts/amalgm-mcp/apps/supervisor.js +439 -0
  9. package/runtime/scripts/amalgm-mcp/apps/tools.js +176 -0
  10. package/runtime/scripts/amalgm-mcp/automations/cell-references.js +237 -0
  11. package/runtime/scripts/amalgm-mcp/automations/context.js +41 -0
  12. package/runtime/scripts/amalgm-mcp/automations/rest.js +148 -0
  13. package/runtime/scripts/amalgm-mcp/automations/runner.js +613 -0
  14. package/runtime/scripts/amalgm-mcp/automations/scheduler.js +90 -0
  15. package/runtime/scripts/amalgm-mcp/automations/store.js +1125 -0
  16. package/runtime/scripts/amalgm-mcp/automations/tool-actions.js +177 -0
  17. package/runtime/scripts/amalgm-mcp/automations/tools.js +418 -0
  18. package/runtime/scripts/amalgm-mcp/automations/validator.js +225 -0
  19. package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +505 -0
  20. package/runtime/scripts/amalgm-mcp/browser/electron-bridge.js +222 -0
  21. package/runtime/scripts/amalgm-mcp/browser/page.js +13 -631
  22. package/runtime/scripts/amalgm-mcp/browser/tools.js +9 -7
  23. package/runtime/scripts/amalgm-mcp/config.js +33 -48
  24. package/runtime/scripts/amalgm-mcp/deps.js +1 -31
  25. package/runtime/scripts/amalgm-mcp/events/ingress.js +50 -42
  26. package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +169 -0
  27. package/runtime/scripts/amalgm-mcp/events/matcher.js +45 -14
  28. package/runtime/scripts/amalgm-mcp/events/store.js +106 -57
  29. package/runtime/scripts/amalgm-mcp/index.js +12 -14
  30. package/runtime/scripts/amalgm-mcp/lib/prefs.js +229 -65
  31. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +13 -27
  32. package/runtime/scripts/amalgm-mcp/server/core-tools.js +2 -3
  33. package/runtime/scripts/amalgm-mcp/server/http.js +106 -56
  34. package/runtime/scripts/amalgm-mcp/slack/inbound.js +1 -1
  35. package/runtime/scripts/amalgm-mcp/state/db.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/state/snapshot.js +16 -3
  37. package/runtime/scripts/amalgm-mcp/tasks/executor.js +1 -1
  38. package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +348 -0
  39. package/runtime/scripts/amalgm-mcp/tests/events-matcher.test.js +23 -0
  40. package/runtime/scripts/amalgm-mcp/tests/workflows-store-runner.test.js +67 -0
  41. package/runtime/scripts/amalgm-mcp/toolbox/tools.js +16 -3
  42. package/runtime/scripts/amalgm-mcp/workflows/compiler.js +222 -0
  43. package/runtime/scripts/amalgm-mcp/workflows/runner.js +593 -0
  44. package/runtime/scripts/amalgm-mcp/workflows/store.js +237 -0
  45. package/runtime/scripts/chat-core/adapters/claude.js +2 -1
  46. package/runtime/scripts/chat-core/auth.js +82 -12
  47. package/runtime/scripts/chat-core/contract.js +5 -1
  48. package/runtime/scripts/chat-core/engine.js +103 -62
  49. package/runtime/scripts/chat-core/event-schema.js +8 -0
  50. package/runtime/scripts/chat-core/events.js +5 -0
  51. package/runtime/scripts/chat-core/normalizers/codex.js +13 -1
  52. package/runtime/scripts/chat-core/parts.js +21 -6
  53. package/runtime/scripts/chat-core/sse.js +3 -0
  54. package/runtime/scripts/chat-core/tests/auth.test.js +84 -6
  55. package/runtime/scripts/chat-core/tests/engine.test.js +312 -0
  56. package/runtime/scripts/chat-core/tests/native-config.test.js +23 -0
  57. package/runtime/scripts/chat-core/tool-shape.js +4 -4
  58. package/runtime/scripts/chat-core/tooling/active-memory.js +5 -4
  59. package/runtime/scripts/chat-core/tooling/native-config.js +34 -3
  60. package/runtime/scripts/local-gateway.js +34 -27
  61. package/runtime/scripts/platform-context.txt +76 -94
  62. package/runtime/scripts/amalgm-mcp/artifacts/rest.js +0 -103
  63. package/runtime/scripts/amalgm-mcp/artifacts/store.js +0 -157
  64. package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +0 -439
  65. package/runtime/scripts/amalgm-mcp/artifacts/tools.js +0 -176
  66. package/runtime/scripts/amalgm-mcp/events/executor.js +0 -258
  67. package/runtime/scripts/amalgm-mcp/events/rest.js +0 -214
  68. package/runtime/scripts/amalgm-mcp/events/tools.js +0 -323
  69. package/runtime/scripts/amalgm-mcp/tasks/rest.js +0 -110
  70. package/runtime/scripts/amalgm-mcp/tasks/tools.js +0 -416
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Workflow storage — ~/.amalgm/workflows.json.
5
+ *
6
+ * Workflows are the executable primitive. Event triggers match inbound events
7
+ * and point at one or more workflow ids; scheduled tasks remain a separate
8
+ * working path.
9
+ */
10
+
11
+ const crypto = require('crypto');
12
+ const { STORAGE_DIR, WORKFLOWS_FILE } = require('../config');
13
+ const { ensureDir, readJson, writeJsonAtomic } = require('../lib/storage');
14
+ const { appendStateEvent } = require('../state/events');
15
+ const { compileWorkflowText } = require('./compiler');
16
+
17
+ function nowIso() {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ function normalizeStringList(values) {
22
+ if (!Array.isArray(values)) return [];
23
+ return [...new Set(values
24
+ .filter((value) => typeof value === 'string')
25
+ .map((value) => value.trim())
26
+ .filter(Boolean))];
27
+ }
28
+
29
+ function normalizeWorkflowRecord(workflow) {
30
+ const workflowText = String(workflow.workflowText || workflow.workflow || '').trim();
31
+ let workflowIr = workflow.workflowIr && workflow.workflowIr.version === 1 ? workflow.workflowIr : null;
32
+ let compilerErrors = null;
33
+
34
+ if (workflowText) {
35
+ try {
36
+ workflowIr = compileWorkflowText(workflowText);
37
+ } catch (error) {
38
+ workflowIr = null;
39
+ compilerErrors = [error.message || String(error)];
40
+ }
41
+ }
42
+
43
+ return {
44
+ id: workflow.id || crypto.randomUUID(),
45
+ name: workflow.name || 'Untitled workflow',
46
+ description: workflow.description || '',
47
+ enabled: workflow.enabled !== false,
48
+ projectPath: workflow.projectPath || null,
49
+ workflowText,
50
+ workflowIr,
51
+ compilerErrors,
52
+ allowlist: workflow.allowlist && typeof workflow.allowlist === 'object' ? workflow.allowlist : {},
53
+ limits: workflow.limits && typeof workflow.limits === 'object' ? workflow.limits : {},
54
+ triggerIds: normalizeStringList(workflow.triggerIds),
55
+ internal: workflow.internal === true,
56
+ createdAt: workflow.createdAt || nowIso(),
57
+ updatedAt: workflow.updatedAt || null,
58
+ };
59
+ }
60
+
61
+ function ensureWorkflowDirs() {
62
+ ensureDir(STORAGE_DIR);
63
+ if (!readJson(WORKFLOWS_FILE, null)) {
64
+ writeJsonAtomic(WORKFLOWS_FILE, { version: 1, workflows: [] });
65
+ }
66
+ }
67
+
68
+ function migrateWorkflowsData(data) {
69
+ let changed = false;
70
+ const rawWorkflows = Array.isArray(data?.workflows) ? data.workflows : [];
71
+ const workflows = rawWorkflows
72
+ .filter((workflow) => workflow && (workflow.workflowText || workflow.workflow || workflow.workflowIr))
73
+ .map((workflow) => {
74
+ const normalized = normalizeWorkflowRecord(workflow);
75
+ if (JSON.stringify(normalized) !== JSON.stringify(workflow)) changed = true;
76
+ return normalized;
77
+ });
78
+
79
+ if (workflows.length !== rawWorkflows.length) changed = true;
80
+ if (data?.version !== 1) changed = true;
81
+
82
+ return {
83
+ changed,
84
+ data: {
85
+ version: 1,
86
+ workflows,
87
+ },
88
+ };
89
+ }
90
+
91
+ function loadWorkflows() {
92
+ ensureWorkflowDirs();
93
+ const raw = readJson(WORKFLOWS_FILE, { version: 1, workflows: [] });
94
+ const migrated = migrateWorkflowsData(raw);
95
+ if (migrated.changed) writeJsonAtomic(WORKFLOWS_FILE, migrated.data);
96
+ return migrated.data;
97
+ }
98
+
99
+ function publishWorkflowsChange(data, source = 'workflows') {
100
+ try {
101
+ appendStateEvent({
102
+ resource: 'workflows',
103
+ op: 'replace',
104
+ value: Array.isArray(data?.workflows) ? data.workflows : [],
105
+ source,
106
+ });
107
+ } catch (error) {
108
+ console.warn('[Workflows] Local Live Store publish failed:', error.message);
109
+ }
110
+ }
111
+
112
+ function saveWorkflows(data, options = {}) {
113
+ ensureWorkflowDirs();
114
+ const normalized = migrateWorkflowsData(data).data;
115
+ writeJsonAtomic(WORKFLOWS_FILE, normalized);
116
+ publishWorkflowsChange(normalized, options.source || 'workflows:save');
117
+ }
118
+
119
+ function getWorkflowById(workflowId) {
120
+ if (!workflowId) return null;
121
+ return loadWorkflows().workflows.find((workflow) => workflow.id === workflowId) || null;
122
+ }
123
+
124
+ function createWorkflow(input) {
125
+ const data = loadWorkflows();
126
+ const workflow = normalizeWorkflowRecord({
127
+ ...input,
128
+ id: input.id || crypto.randomUUID(),
129
+ createdAt: input.createdAt || nowIso(),
130
+ updatedAt: input.updatedAt || null,
131
+ });
132
+ if (!workflow.workflowText) throw new Error('workflowText is required.');
133
+ if (workflow.compilerErrors?.length) throw new Error(workflow.compilerErrors.join('\n'));
134
+ data.workflows.push(workflow);
135
+ saveWorkflows(data, { source: 'workflow:create' });
136
+ return workflow;
137
+ }
138
+
139
+ function updateWorkflow(workflowId, updates) {
140
+ const data = loadWorkflows();
141
+ const index = data.workflows.findIndex((workflow) => workflow.id === workflowId);
142
+ if (index === -1) throw new Error(`Workflow not found: ${workflowId}`);
143
+ const workflow = normalizeWorkflowRecord({
144
+ ...data.workflows[index],
145
+ ...updates,
146
+ id: workflowId,
147
+ updatedAt: nowIso(),
148
+ });
149
+ if (!workflow.workflowText) throw new Error('workflowText is required.');
150
+ if (workflow.compilerErrors?.length) throw new Error(workflow.compilerErrors.join('\n'));
151
+ data.workflows[index] = workflow;
152
+ saveWorkflows(data, { source: 'workflow:update' });
153
+ return workflow;
154
+ }
155
+
156
+ function deleteWorkflow(workflowId) {
157
+ const data = loadWorkflows();
158
+ const index = data.workflows.findIndex((workflow) => workflow.id === workflowId);
159
+ if (index === -1) throw new Error(`Workflow not found: ${workflowId}`);
160
+ const [deleted] = data.workflows.splice(index, 1);
161
+ saveWorkflows(data, { source: 'workflow:delete' });
162
+ return deleted;
163
+ }
164
+
165
+ function upsertWorkflowFromTrigger(trigger) {
166
+ if (!trigger?.workflowText && !trigger?.workflow && !trigger?.workflowIr) return null;
167
+ const data = loadWorkflows();
168
+ const workflowId = trigger.workflowId || `workflow:${trigger.id}`;
169
+ const index = data.workflows.findIndex((workflow) => workflow.id === workflowId);
170
+ const existing = index === -1 ? null : data.workflows[index];
171
+ const next = normalizeWorkflowRecord({
172
+ ...(existing || {}),
173
+ id: workflowId,
174
+ name: trigger.workflowName || trigger.name || 'Untitled workflow',
175
+ description: trigger.description || '',
176
+ enabled: trigger.enabled !== false,
177
+ projectPath: trigger.projectPath || null,
178
+ workflowText: trigger.workflowText || trigger.workflow || '',
179
+ workflowIr: trigger.workflowIr || null,
180
+ allowlist: trigger.allowlist || {},
181
+ limits: trigger.limits || {},
182
+ triggerIds: [trigger.id],
183
+ internal: trigger.internal === true,
184
+ createdAt: index === -1 ? (trigger.createdAt || nowIso()) : existing.createdAt,
185
+ updatedAt: index === -1 ? null : existing.updatedAt,
186
+ });
187
+
188
+ if (existing && JSON.stringify(existing) === JSON.stringify(next)) return existing;
189
+ if (existing) next.updatedAt = nowIso();
190
+ if (index === -1) data.workflows.push(next);
191
+ else data.workflows[index] = next;
192
+ saveWorkflows(data, { source: 'workflow:migrate-trigger' });
193
+ return next;
194
+ }
195
+
196
+ function syncWorkflowTriggerLinks(triggers) {
197
+ const data = loadWorkflows();
198
+ let changed = false;
199
+
200
+ const triggerIdsByWorkflow = new Map();
201
+ for (const trigger of Array.isArray(triggers) ? triggers : []) {
202
+ const triggerId = String(trigger?.id || '').trim();
203
+ if (!triggerId) continue;
204
+ const workflowIds = normalizeStringList(trigger.workflowIds);
205
+ for (const workflowId of workflowIds) {
206
+ if (!triggerIdsByWorkflow.has(workflowId)) triggerIdsByWorkflow.set(workflowId, []);
207
+ triggerIdsByWorkflow.get(workflowId).push(triggerId);
208
+ }
209
+ }
210
+
211
+ const workflows = data.workflows.map((workflow) => {
212
+ const nextTriggerIds = normalizeStringList(
213
+ triggerIdsByWorkflow.has(workflow.id) ? triggerIdsByWorkflow.get(workflow.id) : [],
214
+ );
215
+ if (JSON.stringify(nextTriggerIds) === JSON.stringify(workflow.triggerIds || [])) return workflow;
216
+ changed = true;
217
+ return {
218
+ ...workflow,
219
+ triggerIds: nextTriggerIds,
220
+ updatedAt: nowIso(),
221
+ };
222
+ });
223
+
224
+ if (changed) saveWorkflows({ ...data, workflows }, { source: 'workflow:sync-trigger-links' });
225
+ }
226
+
227
+ module.exports = {
228
+ createWorkflow,
229
+ deleteWorkflow,
230
+ getWorkflowById,
231
+ loadWorkflows,
232
+ normalizeWorkflowRecord,
233
+ saveWorkflows,
234
+ syncWorkflowTriggerLinks,
235
+ updateWorkflow,
236
+ upsertWorkflowFromTrigger,
237
+ };
@@ -8,7 +8,7 @@ const { normalizeClaudeMessage, usageRecordsFromClaudeResult, usageFromClaude }
8
8
  const { recordNativeEvent } = require('../recorder');
9
9
  const { toClaudeMcpServers } = require('../tooling/mcp-bundle');
10
10
  const { bundledClaudeBinary } = require('../tooling/native-binaries');
11
- const { claudeNativeHookSettings } = require('../tooling/native-config');
11
+ const { claudeNativeHookSettings, syncNativeHarnessConfig } = require('../tooling/native-config');
12
12
  const { importPackage } = require('../tooling/package-import');
13
13
  const { composeSystemPrompt } = require('../tooling/system-prompt');
14
14
 
@@ -32,6 +32,7 @@ class ClaudeAdapter {
32
32
  }
33
33
 
34
34
  options(contract, extra = {}) {
35
+ syncNativeHarnessConfig(contract);
35
36
  const systemPrompt = composeSystemPrompt(contract);
36
37
  const pathToClaudeCodeExecutable = process.env.CLAUDE_CODE_BINARY || bundledClaudeBinary();
37
38
  const settings = {
@@ -69,25 +69,80 @@ function coerceAuth(harness, requested) {
69
69
  return requested && supported.includes(requested) ? requested : supported[0];
70
70
  }
71
71
 
72
- function runtimeHome({ amalgmDir, sessionId, harness, authMethod, tokenFingerprint }) {
73
- // Claude Code provider auth is tied to the user's real home/keychain context.
74
- // Isolating HOME makes the bundled Claude binary look logged out on macOS.
75
- if (authMethod === 'provider_auth' && harness !== 'codex') return null;
76
- const suffix = tokenFingerprint || 'default';
77
- return path.join(amalgmDir, 'runtime', sessionId, harness, suffix);
72
+ function safePathSegment(value) {
73
+ const clean = String(value || '').trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
74
+ return clean || 'default';
78
75
  }
79
76
 
80
- function providerAuthFingerprint(harness) {
81
- if (harness !== 'codex') return null;
77
+ function findStableJsonIdentity(value, depth = 0) {
78
+ if (!value || typeof value !== 'object' || depth > 4) return null;
79
+ const keys = ['account_id', 'accountId', 'user_id', 'userId', 'email', 'login', 'username', 'sub'];
80
+ for (const key of keys) {
81
+ const candidate = value[key];
82
+ if (typeof candidate === 'string' && candidate.trim()) return `${key}:${candidate.trim()}`;
83
+ }
84
+ for (const child of Object.values(value)) {
85
+ const found = findStableJsonIdentity(child, depth + 1);
86
+ if (found) return found;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function readJsonIdentity(file) {
82
92
  try {
83
- const raw = fs.readFileSync(path.join(os.homedir(), '.codex', 'auth.json'), 'utf8');
84
- return fingerprint(raw);
93
+ const raw = fs.readFileSync(file, 'utf8');
94
+ return findStableJsonIdentity(JSON.parse(raw));
85
95
  } catch {
86
96
  return null;
87
97
  }
88
98
  }
89
99
 
90
- function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken, proxyBaseUrl, amalgmDir, credentialId, modelId }) {
100
+ function providerAuthIdentity(harness) {
101
+ if (harness === 'codex') {
102
+ return readJsonIdentity(path.join(os.homedir(), '.codex', 'auth.json')) || 'codex:provider-default';
103
+ }
104
+ if (harness === 'claude_code') {
105
+ return readJsonIdentity(path.join(os.homedir(), '.claude.json'))
106
+ || readJsonIdentity(path.join(os.homedir(), '.claude', 'auth.json'))
107
+ || 'claude_code:provider-default';
108
+ }
109
+ return `${harness || 'provider'}:provider-default`;
110
+ }
111
+
112
+ function deriveAuthProfileId({ method, harness, userId, tokenFingerprint }) {
113
+ if (method === 'amalgm') {
114
+ return `amalgm-${fingerprint(userId || process.env.AMALGM_USER_ID || 'local') || 'local'}`;
115
+ }
116
+ if (method === 'byok') {
117
+ return `byok-${tokenFingerprint || 'default'}`;
118
+ }
119
+ if (method === 'provider_auth') {
120
+ return `provider-${fingerprint(providerAuthIdentity(harness)) || 'default'}`;
121
+ }
122
+ return 'default';
123
+ }
124
+
125
+ function runtimeHome({ amalgmDir, harness, authProfileId }) {
126
+ return path.join(amalgmDir, 'cli-homes', safePathSegment(harness), safePathSegment(authProfileId));
127
+ }
128
+
129
+ function nativeProviderHome(harness) {
130
+ if (harness === 'claude_code') return os.homedir();
131
+ return null;
132
+ }
133
+
134
+ function pinnedRuntimeHome(amalgmDir, cliHomePath) {
135
+ if (typeof cliHomePath !== 'string' || !cliHomePath.trim()) return null;
136
+ const root = path.resolve(amalgmDir);
137
+ const resolved = path.resolve(cliHomePath.trim());
138
+ return resolved === root || resolved.startsWith(`${root}${path.sep}`) ? resolved : null;
139
+ }
140
+
141
+ function providerAuthFingerprint(harness) {
142
+ return fingerprint(providerAuthIdentity(harness));
143
+ }
144
+
145
+ function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken, proxyBaseUrl, amalgmDir, userId, credentialId, modelId, cliHomePath, authProfileId }) {
91
146
  const method = coerceAuth(harness, authMethod);
92
147
  const byok = readByokFile(amalgmDir);
93
148
  let baseUrl = null;
@@ -116,6 +171,7 @@ function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken
116
171
  } else if (method === 'provider_auth') {
117
172
  tokenFingerprint = providerAuthFingerprint(harness);
118
173
  }
174
+ const profileId = authProfileId || deriveAuthProfileId({ method, harness, userId, tokenFingerprint });
119
175
  return {
120
176
  method,
121
177
  baseUrl,
@@ -125,7 +181,11 @@ function authEnvelope({ harness, authMethod, sessionId, localBaseUrl, proxyToken
125
181
  forwardBaseUrl,
126
182
  tokenRef,
127
183
  tokenFingerprint,
128
- runtimeHome: runtimeHome({ amalgmDir, sessionId, harness, authMethod: method, tokenFingerprint }),
184
+ authProfileId: profileId,
185
+ runtimeHome:
186
+ nativeProviderHome(method === 'provider_auth' ? harness : null)
187
+ || pinnedRuntimeHome(amalgmDir, cliHomePath)
188
+ || runtimeHome({ amalgmDir, harness, authProfileId: profileId }),
129
189
  };
130
190
  }
131
191
 
@@ -142,11 +202,21 @@ function runtimeEnv(contract, baseEnv = process.env) {
142
202
  for (const key of ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'AI_GATEWAY_API_KEY', 'AI_GATEWAY_BASE_URL']) {
143
203
  delete env[key];
144
204
  }
205
+ if (contract.harness === 'claude_code' && contract.authMethod === 'provider_auth') {
206
+ env.HOME = os.homedir();
207
+ delete env.CLAUDE_CONFIG_DIR;
208
+ return {
209
+ ...env,
210
+ IS_SANDBOX: '1',
211
+ ...(baseEnv.AMALGM_RUNTIME_TOKEN ? { AMALGM_RUNTIME_TOKEN: baseEnv.AMALGM_RUNTIME_TOKEN } : {}),
212
+ };
213
+ }
145
214
  if (contract.auth.runtimeHome) {
146
215
  fs.mkdirSync(contract.auth.runtimeHome, { recursive: true });
147
216
  env.HOME = contract.auth.runtimeHome;
148
217
  env.CODEX_HOME = contract.auth.runtimeHome;
149
218
  env.CLAUDE_CONFIG_DIR = contract.auth.runtimeHome;
219
+ env.OPENCODE_HOME = contract.auth.runtimeHome;
150
220
  env.OPENCODE_CONFIG_DIR = contract.auth.runtimeHome;
151
221
  }
152
222
  env.IS_SANDBOX = '1';
@@ -326,6 +326,7 @@ function createContract(payload, options = {}) {
326
326
  const cwd = resolveCwd(payload.cwd, options);
327
327
  const localBaseUrl = options.localBaseUrl || `http://127.0.0.1:${options.port || runtimePort('chat-server')}`;
328
328
  const origin = normalizeOrigin({ ...payload, cwd });
329
+ const userId = payload.userId || options.userId || 'unknown-user';
329
330
  const auth = authEnvelope({
330
331
  harness,
331
332
  authMethod,
@@ -334,12 +335,15 @@ function createContract(payload, options = {}) {
334
335
  proxyToken: options.proxyToken || PROXY_TOKEN,
335
336
  proxyBaseUrl: options.proxyBaseUrl || PROXY_BASE_URL || 'https://amalgm-api-proxy-v2.fly.dev',
336
337
  amalgmDir: options.amalgmDir || AMALGM_DIR || path.join(process.env.HOME || '.', '.amalgm'),
338
+ userId,
337
339
  credentialId: payload.credentialId || payload.byokCredentialId || null,
338
340
  modelId,
341
+ cliHomePath: payload.cliHomePath || payload.cli_home_path || null,
342
+ authProfileId: payload.authProfileId || payload.auth_profile_id || null,
339
343
  });
340
344
  const contract = {
341
345
  sessionId,
342
- userId: payload.userId || options.userId || 'unknown-user',
346
+ userId,
343
347
  computerId: payload.computerId || process.env.AMALGM_CONTAINER_ID || 'local-computer',
344
348
  assistantMessageId,
345
349
  userMessageId: payload.userMessageId || null,
@@ -15,6 +15,13 @@ function warnIfDbNotSaved(result, label) {
15
15
  return true;
16
16
  }
17
17
 
18
+ function assertDbSaved(result, label) {
19
+ if (result === false || result === null) {
20
+ throw new Error(`${label} was not saved; keeping temp/raw stream for reconnect`);
21
+ }
22
+ return true;
23
+ }
24
+
18
25
  async function bestEffortDbSave(label, fn) {
19
26
  try {
20
27
  return warnIfDbNotSaved(await fn(), label);
@@ -43,6 +50,23 @@ async function withDeadline(label, promise, ms) {
43
50
  }
44
51
  }
45
52
 
53
+ function scheduleTitleGeneration({ db, userMessageSave, contract, input, turns, turnId, writeFrame }) {
54
+ if (typeof db.generateAndSaveTitle !== 'function' || !input.text) return null;
55
+ return Promise.resolve(userMessageSave)
56
+ .then((saved) => {
57
+ if (saved === false || saved === null) return null;
58
+ return db.generateAndSaveTitle(contract.sessionId, input.text, (title) => {
59
+ const frame = titleFrame({ sessionId: contract.sessionId, title });
60
+ const chunk = turns.addChunk(turnId, (index) => withSseId(frame, index));
61
+ if (writeFrame && chunk) writeFrame(chunk.data);
62
+ });
63
+ })
64
+ .catch((err) => {
65
+ console.warn('[ChatCore] Title generation failed:', err.message);
66
+ return null;
67
+ });
68
+ }
69
+
46
70
  class ChatCore {
47
71
  constructor({ db, runtime, turns, options = {} }) {
48
72
  this.db = db;
@@ -72,20 +96,31 @@ class ChatCore {
72
96
  };
73
97
  }
74
98
 
75
- async stop(sessionId) {
99
+ async finalizeActiveTurn(sessionId) {
76
100
  const entry = this.turns.active(sessionId);
77
101
  if (!entry) return false;
78
- this.turns.mark(entry.turnId, 'cancelling');
79
- await this.runtime.stop(sessionId);
102
+ if (!entry.streamEnded) {
103
+ this.turns.mark(entry.turnId, 'cancelling');
104
+ await this.runtime.stop(sessionId);
105
+ }
106
+ if (entry.finalized && typeof entry.finalized.then === 'function') {
107
+ await entry.finalized;
108
+ }
80
109
  return true;
81
110
  }
82
111
 
112
+ async stop(sessionId) {
113
+ return this.finalizeActiveTurn(sessionId);
114
+ }
115
+
83
116
  async destroy(sessionId) {
84
117
  this.contracts.delete(sessionId);
85
118
  return this.runtime.destroy(sessionId);
86
119
  }
87
120
 
88
121
  async runTurn(payload, writeFrame) {
122
+ const sessionId = payload.codeSessionId || payload.sessionId;
123
+ if (sessionId) await this.finalizeActiveTurn(sessionId);
89
124
  const contract = this.contractFor(payload);
90
125
  await this.beginTurn(contract);
91
126
  const input = normalizeInput(payload);
@@ -121,10 +156,6 @@ class ChatCore {
121
156
  console.warn('[ChatCore] Failed to load cold-resume conversation history:', err.message);
122
157
  }
123
158
  }
124
- const assistantPlaceholderSave = bestEffortDbSave(
125
- 'Assistant placeholder',
126
- () => this.db.ensureAssistantMessage(msgConfig),
127
- );
128
159
  this.db.mergeSessionMetadata(contract.sessionId, {
129
160
  contract: {
130
161
  userId: contract.userId,
@@ -148,6 +179,7 @@ class ChatCore {
148
179
  auth: {
149
180
  baseUrl: contract.auth.baseUrl,
150
181
  tokenFingerprint: contract.auth.tokenFingerprint,
182
+ authProfileId: contract.auth.authProfileId,
151
183
  runtimeHome: contract.auth.runtimeHome,
152
184
  },
153
185
  },
@@ -161,19 +193,23 @@ class ChatCore {
161
193
  });
162
194
  entry.persistence = {
163
195
  userMessageSaved: null,
164
- assistantPlaceholderSaved: null,
165
196
  assistantMessageSaved: null,
166
197
  };
198
+ entry.streamEnded = false;
199
+ let resolveFinalized = null;
200
+ entry.finalized = new Promise((resolve) => {
201
+ resolveFinalized = resolve;
202
+ });
167
203
 
168
- if (typeof this.db.generateAndSaveTitle === 'function' && input.text) {
169
- this.db.generateAndSaveTitle(contract.sessionId, input.text, (title) => {
170
- const frame = titleFrame({ sessionId: contract.sessionId, title });
171
- const chunk = this.turns.addChunk(turnId, (index) => withSseId(frame, index));
172
- if (writeFrame && chunk) writeFrame(chunk.data);
173
- }).catch((err) => {
174
- console.warn('[ChatCore] Title generation failed:', err.message);
175
- });
176
- }
204
+ scheduleTitleGeneration({
205
+ db: this.db,
206
+ userMessageSave,
207
+ contract,
208
+ input,
209
+ turns: this.turns,
210
+ turnId,
211
+ writeFrame,
212
+ });
177
213
 
178
214
  const parts = new PartAccumulator();
179
215
  let usage = null;
@@ -181,8 +217,10 @@ class ChatCore {
181
217
  let providerSessionId = contract.providerSessionId || null;
182
218
  let stopReason = 'end_turn';
183
219
  let sawDone = false;
220
+ let finalDoneEvent = null;
184
221
 
185
- const append = (e) => {
222
+ const append = (e, options = {}) => {
223
+ const emitFrame = options.emitFrame !== false;
186
224
  if (e.providerSessionId) providerSessionId = e.providerSessionId;
187
225
  if (e.type === 'usage.final') {
188
226
  usage = e.usage;
@@ -191,62 +229,65 @@ class ChatCore {
191
229
  if (e.type === 'done') {
192
230
  sawDone = true;
193
231
  stopReason = e.stopReason || stopReason;
232
+ finalDoneEvent = e;
194
233
  }
195
234
  parts.apply(e);
235
+ if (!emitFrame) return;
196
236
  const frame = frameFor(e, { sessionId: contract.sessionId, providerSessionId, usage });
197
237
  const chunk = this.turns.addChunk(turnId, (index) => withSseId(frame, index));
198
238
  if (writeFrame && chunk) writeFrame(chunk.data);
199
239
  };
200
240
 
201
241
  try {
202
- const stream = await this.runtime.prompt(contract, runtimeInput);
203
- for await (const e of stream) append(e);
204
- const current = this.turns.get(turnId);
205
- if (!sawDone) {
206
- append(done({
207
- providerSessionId,
208
- stopReason: current?.status === 'cancelling' ? 'cancelled' : stopReason,
209
- }));
242
+ try {
243
+ const stream = await this.runtime.prompt(contract, runtimeInput);
244
+ for await (const e of stream) append(e, { emitFrame: e.type !== 'done' });
245
+ const current = this.turns.get(turnId);
246
+ if (!sawDone) {
247
+ append(done({
248
+ providerSessionId,
249
+ stopReason: current?.status === 'cancelling' ? 'cancelled' : stopReason,
250
+ }), { emitFrame: false });
251
+ }
252
+ } catch (err) {
253
+ append(errorEvent(err.message, { providerSessionId }));
254
+ append(done({ providerSessionId, stopReason: 'error' }), { emitFrame: false });
255
+ stopReason = 'error';
210
256
  }
211
- } catch (err) {
212
- append(errorEvent(err.message, { providerSessionId }));
213
- append(done({ providerSessionId, stopReason: 'error' }));
214
- stopReason = 'error';
215
- }
257
+ entry.streamEnded = true;
216
258
 
217
- const savedParts = parts.finalize();
218
- this.turns.setParts(turnId, savedParts);
219
- this.turns.mark(turnId, 'persisting');
220
- const finalizePersistence = async () => {
221
- const [userMessageSaved, assistantPlaceholderSaved, assistantMessageSaved] = await Promise.all([
222
- userMessageSave,
223
- assistantPlaceholderSave,
224
- bestEffortDbSave('Assistant message', () => this.db.saveAssistantMessage(msgConfig, savedParts, providerSessionId, usage)),
225
- ]);
226
- if (entry.persistence) {
227
- entry.persistence.userMessageSaved = userMessageSaved;
228
- entry.persistence.assistantPlaceholderSaved = assistantPlaceholderSaved;
229
- entry.persistence.assistantMessageSaved = assistantMessageSaved;
230
- }
231
- await logTurnUsage({
232
- db: this.db,
233
- contract,
234
- messageId: contract.assistantMessageId,
235
- usage,
236
- usageRecords,
237
- });
238
- if (!assistantMessageSaved || !userMessageSaved || !assistantPlaceholderSaved) {
259
+ const savedParts = parts.finalize();
260
+ this.turns.setParts(turnId, savedParts);
261
+ const finalizePersistence = async () => {
262
+ const userMessageSaved = await userMessageSave;
263
+ assertDbSaved(userMessageSaved, 'User message');
264
+ const assistantMessageSaved = await this.db.saveAssistantMessage(msgConfig, savedParts, providerSessionId, usage);
265
+ if (entry.persistence) {
266
+ entry.persistence.userMessageSaved = userMessageSaved;
267
+ entry.persistence.assistantMessageSaved = assistantMessageSaved;
268
+ }
269
+ assertDbSaved(assistantMessageSaved, 'Assistant message');
270
+ await logTurnUsage({
271
+ db: this.db,
272
+ contract,
273
+ messageId: contract.assistantMessageId,
274
+ usage,
275
+ usageRecords,
276
+ });
277
+ };
278
+ try {
279
+ await finalizePersistence();
280
+ } catch (err) {
239
281
  this.turns.mark(turnId, 'save_failed');
240
- } else {
241
- this.turns.mark(turnId, stopReason === 'error' ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'complete');
242
- this.turns.clear(turnId);
282
+ throw err;
243
283
  }
244
- };
245
- finalizePersistence().catch((err) => {
246
- console.warn('[ChatCore] Persistence finalization failed:', err.message);
247
- this.turns.mark(turnId, 'save_failed');
248
- });
249
- return { providerSessionId, usage, parts: savedParts, stopReason };
284
+ append(finalDoneEvent || done({ providerSessionId, stopReason }));
285
+ this.turns.mark(turnId, stopReason === 'error' ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'complete');
286
+ this.turns.clear(turnId);
287
+ return { providerSessionId, usage, parts: savedParts, stopReason };
288
+ } finally {
289
+ if (resolveFinalized) resolveFinalized();
290
+ }
250
291
  }
251
292
  }
252
293
 
@@ -96,6 +96,13 @@ const textDeltaEvent = baseEvent.extend({
96
96
  streamKind: z.literal('text').optional(),
97
97
  }).strict();
98
98
 
99
+ const textBoundaryEvent = baseEvent.extend({
100
+ type: z.literal('text.boundary'),
101
+ streamKind: z.literal('text').optional(),
102
+ itemId: z.string().optional(),
103
+ reason: z.string().optional(),
104
+ }).strict();
105
+
99
106
  const reasoningDeltaEvent = baseEvent.extend({
100
107
  type: z.literal('reasoning.delta'),
101
108
  text: z.string(),
@@ -194,6 +201,7 @@ const compactionFinishedEvent = baseEvent.extend({
194
201
 
195
202
  const amalgmEventSchema = z.discriminatedUnion('type', [
196
203
  textDeltaEvent,
204
+ textBoundaryEvent,
197
205
  reasoningDeltaEvent,
198
206
  toolStartedEvent,
199
207
  toolUpdatedEvent,