amalgm 0.0.1 → 0.0.33

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 (34) hide show
  1. package/README.md +4 -0
  2. package/lib/cli.js +133 -1
  3. package/lib/supervisor.js +10 -1
  4. package/package.json +2 -1
  5. package/runtime/lib/chatInput.js +9 -0
  6. package/runtime/lib/local/amalgmStore.js +10 -2
  7. package/runtime/scripts/amalgm-mcp/agents/rest.js +60 -81
  8. package/runtime/scripts/amalgm-mcp/agents/store.js +589 -58
  9. package/runtime/scripts/amalgm-mcp/agents/talk.js +10 -4
  10. package/runtime/scripts/amalgm-mcp/agents/tools.js +12 -1
  11. package/runtime/scripts/amalgm-mcp/artifacts/store.js +19 -3
  12. package/runtime/scripts/amalgm-mcp/config.js +2 -0
  13. package/runtime/scripts/amalgm-mcp/events/store.js +16 -1
  14. package/runtime/scripts/amalgm-mcp/lib/prefs.js +85 -37
  15. package/runtime/scripts/amalgm-mcp/local/rest.js +7 -0
  16. package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +83 -0
  17. package/runtime/scripts/amalgm-mcp/server/core-tools.js +20 -0
  18. package/runtime/scripts/amalgm-mcp/server/http.js +42 -0
  19. package/runtime/scripts/amalgm-mcp/server/mcp.js +28 -17
  20. package/runtime/scripts/amalgm-mcp/state/db.js +194 -0
  21. package/runtime/scripts/amalgm-mcp/state/events.js +113 -0
  22. package/runtime/scripts/amalgm-mcp/state/rest.js +64 -0
  23. package/runtime/scripts/amalgm-mcp/state/snapshot.js +76 -0
  24. package/runtime/scripts/amalgm-mcp/tasks/store.js +16 -1
  25. package/runtime/scripts/amalgm-mcp/toolbox/rest.js +75 -0
  26. package/runtime/scripts/amalgm-mcp/toolbox/runner.js +257 -0
  27. package/runtime/scripts/amalgm-mcp/toolbox/store.js +933 -0
  28. package/runtime/scripts/amalgm-mcp/toolbox/tools.js +269 -0
  29. package/runtime/scripts/amalgm-mcp/workspace/rest.js +116 -8
  30. package/runtime/scripts/chat-core/adapters/claude.js +2 -0
  31. package/runtime/scripts/chat-core/contract.js +2 -0
  32. package/runtime/scripts/chat-core/engine.js +77 -19
  33. package/runtime/scripts/credential-adapter.js +4 -2
  34. package/runtime/scripts/local-gateway.js +2 -0
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+
3
+ const { textResult } = require('../lib/tool-result');
4
+ const {
5
+ deleteTool,
6
+ deleteToolAction,
7
+ getTool,
8
+ getToolAction,
9
+ readToolbox,
10
+ updateTool,
11
+ updateToolAction,
12
+ upsertTool,
13
+ upsertToolAction,
14
+ } = require('./store');
15
+
16
+ function compactTool(tool, actionCount) {
17
+ return {
18
+ id: tool.id,
19
+ name: tool.name,
20
+ type: tool.type,
21
+ owner: tool.owner,
22
+ origin: tool.origin,
23
+ status: tool.status,
24
+ discovery: tool.discovery,
25
+ actionCount,
26
+ source: tool.source,
27
+ guide: tool.guide,
28
+ display: tool.display,
29
+ policy: tool.policy,
30
+ };
31
+ }
32
+
33
+ function listPayload(args = {}) {
34
+ const toolbox = readToolbox();
35
+ const actionsByTool = new Map();
36
+ for (const action of Object.values(toolbox.toolActions || {})) {
37
+ const existing = actionsByTool.get(action.toolId) || [];
38
+ existing.push(action);
39
+ actionsByTool.set(action.toolId, existing);
40
+ }
41
+
42
+ const tools = Object.values(toolbox.tools || {})
43
+ .filter((tool) => !args.type || tool.type === args.type)
44
+ .filter((tool) => !args.owner || tool.owner === args.owner)
45
+ .filter((tool) => !args.origin || tool.origin === args.origin)
46
+ .map((tool) => compactTool(tool, actionsByTool.get(tool.id)?.length || 0));
47
+
48
+ const payload = {
49
+ version: toolbox.version,
50
+ tools,
51
+ ...(args.include_actions
52
+ ? { toolActions: toolbox.toolActions }
53
+ : {}),
54
+ };
55
+ return payload;
56
+ }
57
+
58
+ module.exports = [
59
+ {
60
+ name: 'toolbox_tools_list',
61
+ description: 'List registered Amalgm Toolbox connectors/tools. Use this to see persistent user tools available across sessions.',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ type: { type: 'string', enum: ['mcp', 'api', 'cli'], description: 'Optional tool type filter.' },
66
+ owner: { type: 'string', description: 'Optional owner filter, e.g. amalgm, claude, cursor, codex, or an agent harness id.' },
67
+ origin: { type: 'string', enum: ['system', 'user', 'catalog'], description: 'Optional origin filter.' },
68
+ include_actions: { type: 'boolean', description: 'Include full tool action records. Defaults false.' },
69
+ },
70
+ },
71
+ async handler(args) {
72
+ return textResult(JSON.stringify(listPayload(args || {}), null, 2));
73
+ },
74
+ },
75
+ {
76
+ name: 'toolbox_tool_get',
77
+ description: 'Read one registered Toolbox connector/tool by id, optionally including its actions.',
78
+ inputSchema: {
79
+ type: 'object',
80
+ properties: {
81
+ id: { type: 'string', description: 'Tool id to read.' },
82
+ include_actions: { type: 'boolean', description: 'Include actions for this tool. Defaults true.' },
83
+ },
84
+ required: ['id'],
85
+ },
86
+ async handler(args) {
87
+ const payload = getTool(args?.id, { includeActions: args?.include_actions !== false });
88
+ return textResult(JSON.stringify(payload, null, 2));
89
+ },
90
+ },
91
+ {
92
+ name: 'toolbox_tool_register',
93
+ description: [
94
+ 'Register or update a persistent Toolbox connector/tool. This is how agents save custom tools for future sessions.',
95
+ 'For CLI tools, provide type="cli" and source.command. A default <tool>.run action is created automatically unless actions are provided.',
96
+ 'Optional skillPath/guidePath attaches usage instructions to the tool without becoming a separate callable action.',
97
+ ].join('\n'),
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ id: { type: 'string', description: 'Stable tool id, e.g. acme.executor.' },
102
+ name: { type: 'string', description: 'Human-readable connector/tool name.' },
103
+ type: { type: 'string', enum: ['mcp', 'api', 'cli'] },
104
+ owner: { type: 'string', description: 'Tool owner. Defaults to amalgm. Use a harness or agent id for externally owned tools.' },
105
+ origin: { type: 'string', enum: ['user', 'catalog'], description: 'Defaults to user.' },
106
+ status: { type: 'string', enum: ['enabled', 'disabled', 'error'] },
107
+ source: {
108
+ type: 'object',
109
+ description: 'Type-specific source config. CLI: {command,args,cwd,inputMode,outputMode}. MCP: {transport,url|command}. API: {baseUrl,specUrl,specPath,endpoints}.',
110
+ additionalProperties: true,
111
+ },
112
+ command: { type: 'string', description: 'CLI shortcut for source.command.' },
113
+ args: { type: 'array', items: { type: 'string' }, description: 'CLI/MCP stdio args shortcut.' },
114
+ cwd: { type: 'string', description: 'Working directory shortcut.' },
115
+ inputMode: { type: 'string', enum: ['json-stdin', 'argv', 'terminal', 'none'] },
116
+ outputMode: { type: 'string', enum: ['json', 'text'] },
117
+ skillPath: { type: 'string', description: 'Optional local SKILL.md path with usage guidance.' },
118
+ guidePath: { type: 'string', description: 'Optional local guide path with usage guidance.' },
119
+ display: { type: 'object', additionalProperties: true },
120
+ policy: { type: 'object', additionalProperties: true },
121
+ actions: {
122
+ type: 'array',
123
+ description: 'Optional static action records to create/update with the tool.',
124
+ items: { type: 'object', additionalProperties: true },
125
+ },
126
+ },
127
+ required: ['id', 'name', 'type'],
128
+ },
129
+ async handler(args) {
130
+ const saved = upsertTool(args || {});
131
+ return textResult(JSON.stringify({ ok: true, ...saved }, null, 2));
132
+ },
133
+ },
134
+ {
135
+ name: 'toolbox_tool_update',
136
+ description: [
137
+ 'Partially update an existing Toolbox connector/tool without replacing unspecified fields.',
138
+ 'Use this for modifications like status, display metadata, policy, guide/skill path, owner, or source patches.',
139
+ 'System tools cannot be modified.',
140
+ ].join('\n'),
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ id: { type: 'string', description: 'Tool id to update.' },
145
+ name: { type: 'string', description: 'Optional new human-readable name.' },
146
+ type: { type: 'string', enum: ['mcp', 'api', 'cli'], description: 'Optional type update.' },
147
+ owner: { type: 'string', description: 'Optional owner update.' },
148
+ origin: { type: 'string', enum: ['user', 'catalog'], description: 'Optional origin update.' },
149
+ status: { type: 'string', enum: ['enabled', 'disabled', 'error'] },
150
+ source: {
151
+ type: 'object',
152
+ description: 'Partial source patch. CLI: {command,args,cwd,inputMode,outputMode}. MCP: {transport,url|command}. API: {baseUrl,specUrl,specPath,endpoints}.',
153
+ additionalProperties: true,
154
+ },
155
+ command: { type: 'string', description: 'CLI shortcut for source.command.' },
156
+ args: { type: 'array', items: { type: 'string' }, description: 'CLI/MCP stdio args shortcut.' },
157
+ cwd: { type: 'string', description: 'Working directory shortcut.' },
158
+ inputMode: { type: 'string', enum: ['json-stdin', 'argv', 'terminal', 'none'] },
159
+ outputMode: { type: 'string', enum: ['json', 'text'] },
160
+ discovery: { type: 'object', additionalProperties: true },
161
+ skillPath: { type: 'string', description: 'Optional local SKILL.md path with usage guidance.' },
162
+ guidePath: { type: 'string', description: 'Optional local guide path with usage guidance.' },
163
+ guide: { type: 'object', additionalProperties: true },
164
+ display: { type: 'object', additionalProperties: true },
165
+ policy: { type: 'object', additionalProperties: true },
166
+ },
167
+ required: ['id'],
168
+ },
169
+ async handler(args) {
170
+ const saved = updateTool(args || {});
171
+ return textResult(JSON.stringify({ ok: true, ...saved }, null, 2));
172
+ },
173
+ },
174
+ {
175
+ name: 'toolbox_action_get',
176
+ description: 'Read one registered Toolbox action by id.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ id: { type: 'string', description: 'Tool action id to read.' },
181
+ },
182
+ required: ['id'],
183
+ },
184
+ async handler(args) {
185
+ const payload = getToolAction(args?.id);
186
+ return textResult(JSON.stringify(payload, null, 2));
187
+ },
188
+ },
189
+ {
190
+ name: 'toolbox_action_register',
191
+ description: 'Register or update a persistent action under an existing Toolbox tool. Use this for richer CLI/API schemas beyond the default run action.',
192
+ inputSchema: {
193
+ type: 'object',
194
+ properties: {
195
+ id: { type: 'string', description: 'Stable action id, e.g. acme.executor.run_job.' },
196
+ toolId: { type: 'string', description: 'Parent tool id.' },
197
+ name: { type: 'string', description: 'Action name, e.g. run_job.' },
198
+ displayName: { type: 'string' },
199
+ description: { type: 'string' },
200
+ inputSchema: { type: 'object', additionalProperties: true },
201
+ outputSchema: { type: 'object', additionalProperties: true },
202
+ status: { type: 'string', enum: ['enabled', 'disabled', 'error'] },
203
+ sourceAction: { type: 'object', additionalProperties: true },
204
+ skillPath: { type: 'string', description: 'Optional local SKILL.md path with usage guidance.' },
205
+ guidePath: { type: 'string', description: 'Optional local guide path with usage guidance.' },
206
+ policy: { type: 'object', additionalProperties: true },
207
+ },
208
+ required: ['toolId', 'name'],
209
+ },
210
+ async handler(args) {
211
+ const action = upsertToolAction(args || {});
212
+ return textResult(JSON.stringify({ ok: true, action }, null, 2));
213
+ },
214
+ },
215
+ {
216
+ name: 'toolbox_action_update',
217
+ description: 'Partially update an existing Toolbox action without replacing unspecified fields. System tool actions cannot be modified.',
218
+ inputSchema: {
219
+ type: 'object',
220
+ properties: {
221
+ id: { type: 'string', description: 'Tool action id to update.' },
222
+ name: { type: 'string', description: 'Optional action name update.' },
223
+ displayName: { type: 'string' },
224
+ description: { type: 'string' },
225
+ inputSchema: { type: 'object', additionalProperties: true },
226
+ outputSchema: { type: 'object', additionalProperties: true },
227
+ status: { type: 'string', enum: ['enabled', 'disabled', 'error'] },
228
+ sourceAction: { type: 'object', additionalProperties: true },
229
+ skillPath: { type: 'string', description: 'Optional local SKILL.md path with usage guidance.' },
230
+ guidePath: { type: 'string', description: 'Optional local guide path with usage guidance.' },
231
+ guide: { type: 'object', additionalProperties: true },
232
+ policy: { type: 'object', additionalProperties: true },
233
+ },
234
+ required: ['id'],
235
+ },
236
+ async handler(args) {
237
+ const action = updateToolAction(args || {});
238
+ return textResult(JSON.stringify({ ok: true, action }, null, 2));
239
+ },
240
+ },
241
+ {
242
+ name: 'toolbox_tool_delete',
243
+ description: 'Delete a user/catalog Toolbox tool and its actions. System tools cannot be deleted.',
244
+ inputSchema: {
245
+ type: 'object',
246
+ properties: {
247
+ id: { type: 'string', description: 'Tool id to delete.' },
248
+ },
249
+ required: ['id'],
250
+ },
251
+ async handler(args) {
252
+ return textResult(JSON.stringify(deleteTool(args?.id), null, 2));
253
+ },
254
+ },
255
+ {
256
+ name: 'toolbox_action_delete',
257
+ description: 'Delete a user/catalog Toolbox action. System tool actions cannot be deleted.',
258
+ inputSchema: {
259
+ type: 'object',
260
+ properties: {
261
+ id: { type: 'string', description: 'Tool action id to delete.' },
262
+ },
263
+ required: ['id'],
264
+ },
265
+ async handler(args) {
266
+ return textResult(JSON.stringify(deleteToolAction(args?.id), null, 2));
267
+ },
268
+ },
269
+ ];
@@ -7,12 +7,55 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const { execFile } = require('child_process');
9
9
  const { promisify } = require('util');
10
- const { DEFAULT_CWD } = require('../config');
10
+ const { AMALGM_DIR, DEFAULT_CWD } = require('../config');
11
11
 
12
12
  const execFileAsync = promisify(execFile);
13
13
  const MAX_BUFFER = 10 * 1024 * 1024;
14
14
  const WORKTREE_SUFFIX_RE = /-amalgm-wt-[a-z0-9]+$/;
15
15
 
16
+ function isWithin(root, target) {
17
+ const relative = path.relative(root, target);
18
+ return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
19
+ }
20
+
21
+ function sanitizeProjectName(value) {
22
+ const fallback = 'project';
23
+ return String(value || fallback)
24
+ .trim()
25
+ .replace(/[\/\\:\0]/g, '-')
26
+ .replace(/^\.+$/, fallback)
27
+ .replace(/^\.+/, '')
28
+ .slice(0, 120) || fallback;
29
+ }
30
+
31
+ function getWorkspaceRoot() {
32
+ if (process.env.AMALGM_WORKSPACES_DIR) return path.resolve(process.env.AMALGM_WORKSPACES_DIR);
33
+ if (process.env.AMALGM_PROJECTS_DIR) return path.resolve(process.env.AMALGM_PROJECTS_DIR);
34
+ if (process.env.AMALGM_LOCAL_MODE === 'true') return path.join(AMALGM_DIR, 'workspaces');
35
+ return path.join(DEFAULT_CWD, 'workspaces');
36
+ }
37
+
38
+ async function ensureWorkspaceRoot() {
39
+ const workspaceRoot = getWorkspaceRoot();
40
+ await fs.promises.mkdir(workspaceRoot, { recursive: true });
41
+ return workspaceRoot;
42
+ }
43
+
44
+ async function uniqueProjectPath(workspaceRoot, requestedName) {
45
+ const name = sanitizeProjectName(requestedName);
46
+ let candidate = path.join(workspaceRoot, name);
47
+ let suffix = 2;
48
+ while (true) {
49
+ try {
50
+ await fs.promises.access(candidate);
51
+ candidate = path.join(workspaceRoot, `${name}-${suffix}`);
52
+ suffix += 1;
53
+ } catch {
54
+ return candidate;
55
+ }
56
+ }
57
+ }
58
+
16
59
  function isValidWorkspacePath(workspacePath) {
17
60
  return typeof workspacePath === 'string' && workspacePath.length > 0 && path.isAbsolute(workspacePath);
18
61
  }
@@ -94,7 +137,7 @@ function getLocalWorktreePath(workspacePath, shortId) {
94
137
 
95
138
  async function handleProjects(sendJson) {
96
139
  try {
97
- const rootDir = DEFAULT_CWD;
140
+ const rootDir = await ensureWorkspaceRoot();
98
141
  const dirents = await fs.promises.readdir(rootDir, { withFileTypes: true }).catch(() => []);
99
142
  const projects = [];
100
143
 
@@ -112,10 +155,73 @@ async function handleProjects(sendJson) {
112
155
  }
113
156
 
114
157
  projects.sort((a, b) => a.name.localeCompare(b.name));
115
- sendJson(200, { projects });
158
+ sendJson(200, { projects, workspaceRoot: rootDir });
159
+ } catch (error) {
160
+ sendJson(500, {
161
+ error: error instanceof Error ? error.message : 'Failed to list workspaces',
162
+ });
163
+ }
164
+ }
165
+
166
+ async function handleImport(body, sendJson) {
167
+ const sourcePath = body?.source_path;
168
+ const projectName = body?.project_name;
169
+
170
+ if (!sourcePath || typeof sourcePath !== 'string') {
171
+ sendJson(400, { error: 'source_path is required' });
172
+ return;
173
+ }
174
+
175
+ if (!path.isAbsolute(sourcePath)) {
176
+ sendJson(400, { error: 'source_path must be absolute' });
177
+ return;
178
+ }
179
+
180
+ try {
181
+ const sourceStats = await fs.promises.stat(sourcePath);
182
+ if (!sourceStats.isDirectory()) {
183
+ sendJson(400, { error: 'source_path must be a folder' });
184
+ return;
185
+ }
186
+
187
+ const workspaceRoot = await ensureWorkspaceRoot();
188
+ const [realSource, realWorkspaceRoot] = await Promise.all([
189
+ fs.promises.realpath(sourcePath),
190
+ fs.promises.realpath(workspaceRoot),
191
+ ]);
192
+
193
+ if (isWithin(realWorkspaceRoot, realSource)) {
194
+ sendJson(200, {
195
+ success: true,
196
+ path: realSource,
197
+ name: path.basename(realSource),
198
+ workspaceRoot,
199
+ alreadyInWorkspace: true,
200
+ });
201
+ return;
202
+ }
203
+
204
+ const targetPath = await uniqueProjectPath(
205
+ workspaceRoot,
206
+ projectName || path.basename(realSource),
207
+ );
208
+ await fs.promises.cp(realSource, targetPath, {
209
+ recursive: true,
210
+ force: false,
211
+ errorOnExist: true,
212
+ dereference: false,
213
+ });
214
+
215
+ sendJson(200, {
216
+ success: true,
217
+ path: targetPath,
218
+ name: path.basename(targetPath),
219
+ workspaceRoot,
220
+ alreadyInWorkspace: false,
221
+ });
116
222
  } catch (error) {
117
223
  sendJson(500, {
118
- error: error instanceof Error ? error.message : 'Failed to list projects',
224
+ error: error instanceof Error ? error.message : 'Failed to import project',
119
225
  });
120
226
  }
121
227
  }
@@ -135,12 +241,13 @@ async function handleClone(body, sendJson) {
135
241
  return;
136
242
  }
137
243
 
138
- const targetPath = path.join(DEFAULT_CWD, repoName);
244
+ const workspaceRoot = await ensureWorkspaceRoot();
245
+ const targetPath = path.join(workspaceRoot, sanitizeProjectName(repoName));
139
246
 
140
247
  try {
141
248
  await fs.promises.access(targetPath);
142
249
  if (await isGitRepo(targetPath)) {
143
- sendJson(200, { success: true, path: targetPath, alreadyExists: true });
250
+ sendJson(200, { success: true, path: targetPath, workspaceRoot, alreadyExists: true });
144
251
  return;
145
252
  }
146
253
  sendJson(409, {
@@ -158,7 +265,7 @@ async function handleClone(body, sendJson) {
158
265
  }
159
266
  cloneArgs.push('clone', cloneUrl, targetPath);
160
267
 
161
- const cloneResult = await runGit(cloneArgs, DEFAULT_CWD);
268
+ const cloneResult = await runGit(cloneArgs, workspaceRoot);
162
269
  if (cloneResult.exitCode !== 0) {
163
270
  sendJson(500, {
164
271
  error: cloneResult.stderr.trim() || cloneResult.stdout.trim() || 'Clone failed',
@@ -166,7 +273,7 @@ async function handleClone(body, sendJson) {
166
273
  return;
167
274
  }
168
275
 
169
- sendJson(200, { success: true, path: targetPath, alreadyExists: false });
276
+ sendJson(200, { success: true, path: targetPath, workspaceRoot, alreadyExists: false });
170
277
  }
171
278
 
172
279
  async function handleBranches(query, sendJson) {
@@ -379,6 +486,7 @@ async function handleWorktreeDelete(body, sendJson) {
379
486
 
380
487
  module.exports = {
381
488
  handleProjects,
489
+ handleImport,
382
490
  handleClone,
383
491
  handleBranches,
384
492
  handleCheckout,
@@ -36,6 +36,8 @@ class ClaudeAdapter {
36
36
  cwd: contract.cwd,
37
37
  env: runtimeEnv(contract),
38
38
  model: contract.cliModel,
39
+ ...(contract.reasoningEffort ? { effort: contract.reasoningEffort } : {}),
40
+ ...(contract.fastMode ? { settings: { fastMode: true, fastModePerSessionOptIn: true } } : {}),
39
41
  ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
40
42
  ...(systemPrompt ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: systemPrompt } } : {}),
41
43
  mcpServers: toClaudeMcpServers(contract),
@@ -266,6 +266,7 @@ function createContract(payload, options = {}) {
266
266
  const authMethod = coerceAuth(harness, payload.authMethod || 'amalgm');
267
267
  const modelId = canonicalModel(payload.modelId || payload.cliModel, harness);
268
268
  const reasoningEffort = payload.reasoningEffort || null;
269
+ const fastMode = payload.fastMode === true;
269
270
  const cliModel = cliModelFor({ harness, modelId, cliModel: payload.cliModel, reasoningEffort });
270
271
  const cwd = resolveCwd(payload.cwd, options);
271
272
  const localBaseUrl = options.localBaseUrl || `http://127.0.0.1:${options.port || process.env.CHAT_SERVER_PORT || 8084}`;
@@ -294,6 +295,7 @@ function createContract(payload, options = {}) {
294
295
  providerSessionId: nullableString(payload.providerSessionId || payload.provider_session_id),
295
296
  cliModel,
296
297
  reasoningEffort,
298
+ fastMode,
297
299
  contextWindow: positiveOrNull(payload.contextWindow),
298
300
  maxTokens: positiveOrNull(payload.maxTokens),
299
301
  cwd,
@@ -7,9 +7,39 @@ const { normalizeInput } = require('./input');
7
7
  const { frameFor, titleFrame } = require('./sse');
8
8
  const { beginProxyTurn, logTurnUsage } = require('./usage');
9
9
 
10
- function assertDbSaved(result, label) {
10
+ function warnIfDbNotSaved(result, label) {
11
11
  if (result === false || result === null) {
12
- throw new Error(`${label} was not saved; keeping temp/raw stream for reconnect`);
12
+ console.warn(`[ChatCore] ${label} was not saved; continuing turn with temp/raw stream for reconnect`);
13
+ return false;
14
+ }
15
+ return true;
16
+ }
17
+
18
+ async function bestEffortDbSave(label, fn) {
19
+ try {
20
+ return warnIfDbNotSaved(await fn(), label);
21
+ } catch (err) {
22
+ console.warn(`[ChatCore] ${label} save failed: ${err.message}; continuing turn with temp/raw stream for reconnect`);
23
+ return false;
24
+ }
25
+ }
26
+
27
+ async function withDeadline(label, promise, ms) {
28
+ if (!ms || ms <= 0) return promise;
29
+ let timeout = null;
30
+ try {
31
+ return await Promise.race([
32
+ promise,
33
+ new Promise((resolve) => {
34
+ timeout = setTimeout(() => {
35
+ console.warn(`[ChatCore] ${label} timed out after ${ms}ms; continuing without it`);
36
+ resolve(null);
37
+ }, ms);
38
+ timeout.unref?.();
39
+ }),
40
+ ]);
41
+ } finally {
42
+ if (timeout) clearTimeout(timeout);
13
43
  }
14
44
  }
15
45
 
@@ -20,6 +50,7 @@ class ChatCore {
20
50
  this.turns = turns;
21
51
  this.options = options;
22
52
  this.beginTurn = options.beginTurn || beginProxyTurn;
53
+ this.historyLoadTimeoutMs = Number(options.historyLoadTimeoutMs ?? process.env.AMALGM_HISTORY_LOAD_TIMEOUT_MS ?? 750);
23
54
  this.contracts = new Map();
24
55
  }
25
56
 
@@ -70,10 +101,14 @@ class ChatCore {
70
101
  assistantMessageId: contract.assistantMessageId,
71
102
  modelId: contract.usageModelId || contract.modelId,
72
103
  };
73
- assertDbSaved(await this.db.saveUserMessage(msgConfig), 'User message');
104
+ const userMessageSave = bestEffortDbSave('User message', () => this.db.saveUserMessage(msgConfig));
74
105
  if (isReturningToSession && !runtimeWasWarm && !contract.providerSessionId && typeof this.db.loadConversationHistory === 'function') {
75
106
  try {
76
- const history = await this.db.loadConversationHistory(contract.sessionId, contract.userMessageId);
107
+ const history = await withDeadline(
108
+ 'Cold-resume history load',
109
+ this.db.loadConversationHistory(contract.sessionId, contract.userMessageId),
110
+ this.historyLoadTimeoutMs,
111
+ );
77
112
  if (history) {
78
113
  const text = `${history}${input.text || ''}`.trim();
79
114
  runtimeInput = {
@@ -86,8 +121,11 @@ class ChatCore {
86
121
  console.warn('[ChatCore] Failed to load cold-resume conversation history:', err.message);
87
122
  }
88
123
  }
89
- assertDbSaved(await this.db.ensureAssistantMessage(msgConfig), 'Assistant placeholder');
90
- await this.db.mergeSessionMetadata(contract.sessionId, {
124
+ const assistantPlaceholderSave = bestEffortDbSave(
125
+ 'Assistant placeholder',
126
+ () => this.db.ensureAssistantMessage(msgConfig),
127
+ );
128
+ this.db.mergeSessionMetadata(contract.sessionId, {
91
129
  contract: {
92
130
  userId: contract.userId,
93
131
  computerId: contract.computerId,
@@ -121,6 +159,11 @@ class ChatCore {
121
159
  assistantMessageId: contract.assistantMessageId,
122
160
  userMessageId: contract.userMessageId,
123
161
  });
162
+ entry.persistence = {
163
+ userMessageSaved: null,
164
+ assistantPlaceholderSaved: null,
165
+ assistantMessageSaved: null,
166
+ };
124
167
 
125
168
  if (typeof this.db.generateAndSaveTitle === 'function' && input.text) {
126
169
  this.db.generateAndSaveTitle(contract.sessionId, input.text, (title) => {
@@ -173,20 +216,35 @@ class ChatCore {
173
216
 
174
217
  const savedParts = parts.finalize();
175
218
  this.turns.setParts(turnId, savedParts);
176
- try {
177
- assertDbSaved(await this.db.saveAssistantMessage(msgConfig, savedParts, providerSessionId, usage), 'Assistant message');
178
- } catch (saveErr) {
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) {
239
+ 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);
243
+ }
244
+ };
245
+ finalizePersistence().catch((err) => {
246
+ console.warn('[ChatCore] Persistence finalization failed:', err.message);
179
247
  this.turns.mark(turnId, 'save_failed');
180
- throw saveErr;
181
- }
182
- this.turns.mark(turnId, stopReason === 'error' ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'complete');
183
- this.turns.clear(turnId);
184
- await logTurnUsage({
185
- db: this.db,
186
- contract,
187
- messageId: contract.assistantMessageId,
188
- usage,
189
- usageRecords,
190
248
  });
191
249
  return { providerSessionId, usage, parts: savedParts, stopReason };
192
250
  }
@@ -13,12 +13,13 @@ const fs = require('fs');
13
13
  const os = require('os');
14
14
  const path = require('path');
15
15
 
16
- const VALID_HARNESS_IDS = ['claude_code', 'codex', 'opencode'];
16
+ const VALID_HARNESS_IDS = ['claude_code', 'codex', 'opencode', 'pi'];
17
17
  const VALID_AUTH_MODES = ['amalgm', 'provider_auth', 'byok'];
18
18
  const SUPPORTED_AUTH_BY_HARNESS = {
19
19
  claude_code: ['amalgm', 'byok', 'provider_auth'],
20
20
  codex: ['amalgm', 'byok', 'provider_auth'],
21
- opencode: ['amalgm'],
21
+ opencode: ['amalgm', 'byok'],
22
+ pi: ['amalgm', 'byok', 'provider_auth'],
22
23
  };
23
24
 
24
25
  const AMALGM_DIR = process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
@@ -30,6 +31,7 @@ function defaultAuthModes() {
30
31
  claude_code: 'amalgm',
31
32
  codex: 'amalgm',
32
33
  opencode: 'amalgm',
34
+ pi: 'amalgm',
33
35
  };
34
36
  }
35
37
 
@@ -106,6 +106,8 @@ const MCP_PREFIXES = [
106
106
  '/github',
107
107
  '/fs',
108
108
  '/mcp-connections',
109
+ '/toolbox',
110
+ '/state',
109
111
  '/local',
110
112
  '/credentials',
111
113
  '/email',