amalgm 0.1.40 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js CHANGED
@@ -28,6 +28,11 @@ const {
28
28
  stopService,
29
29
  uninstallService,
30
30
  } = require('./service');
31
+ const {
32
+ cleanupStaleRuntimeProcesses,
33
+ runtimeServiceProcesses,
34
+ supervisorProcesses,
35
+ } = require('./process-cleanup');
31
36
  const {
32
37
  deleteComputerAuth,
33
38
  loadComputerRecord,
@@ -846,6 +851,8 @@ async function start(options) {
846
851
  await stop();
847
852
  }
848
853
 
854
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
855
+
849
856
  const blockedPorts = await unavailableServicePorts();
850
857
  if (blockedPorts.length > 0) {
851
858
  const detail = blockedPorts.map(([name, port]) => `${name} (${port})`).join(', ');
@@ -928,6 +935,7 @@ async function stop() {
928
935
  } catch {
929
936
  // noop
930
937
  }
938
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
931
939
  console.log('Amalgm is not running.');
932
940
  return;
933
941
  }
@@ -951,6 +959,7 @@ async function stop() {
951
959
  } catch {
952
960
  // noop
953
961
  }
962
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
954
963
  console.log('Amalgm stopped.');
955
964
  }
956
965
 
@@ -1028,6 +1037,14 @@ async function status() {
1028
1037
  }
1029
1038
  console.log(`HMAC proxy: ${proxyTokenFromRecord(record) ? 'configured' : 'not configured'}`);
1030
1039
 
1040
+ const runtimeChildren = runtimeServiceProcesses();
1041
+ const runtimeSupervisors = supervisorProcesses();
1042
+ if (runtimeChildren.length > 5 || runtimeSupervisors.length > 1) {
1043
+ console.log(
1044
+ `Warning: multiple Amalgm runtime processes detected (${runtimeSupervisors.length} supervisor, ${runtimeChildren.length} services). Run \`amalgm stop && amalgm start\`.`,
1045
+ );
1046
+ }
1047
+
1031
1048
  for (const [name, port] of servicePorts()) {
1032
1049
  const ok = await checkHttp(port);
1033
1050
  console.log(`${name.padEnd(13)} ${ok ? 'up' : 'down'} http://127.0.0.1:${port}`);
@@ -1101,6 +1118,16 @@ async function doctor() {
1101
1118
  );
1102
1119
  }
1103
1120
  add('daemon', daemonRunning, daemonRunning ? `pid ${pid}` : 'stopped', daemonRunning ? 'ok' : 'warn');
1121
+ const runtimeChildren = runtimeServiceProcesses();
1122
+ const runtimeSupervisors = supervisorProcesses();
1123
+ const expectedServices = daemonRunning ? 5 : 0;
1124
+ const cleanProcessShape = runtimeSupervisors.length <= 1 && runtimeChildren.length <= expectedServices;
1125
+ add(
1126
+ 'runtime processes',
1127
+ cleanProcessShape,
1128
+ `${runtimeSupervisors.length} supervisor, ${runtimeChildren.length} services`,
1129
+ cleanProcessShape ? 'ok' : 'warn',
1130
+ );
1104
1131
  if (lastShutdown?.signal || lastShutdown?.reason) {
1105
1132
  add(
1106
1133
  'last shutdown',
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const {
8
+ AMALGM_DIR,
9
+ PACKAGE_ROOT,
10
+ RUNTIME_DIR,
11
+ } = require('./paths');
12
+
13
+ const RUNTIME_SERVICE_MARKERS = [
14
+ path.join(RUNTIME_DIR, 'scripts', 'port-monitor.js'),
15
+ path.join(RUNTIME_DIR, 'scripts', 'fs-watcher.js'),
16
+ path.join(RUNTIME_DIR, 'scripts', 'chat-server.js'),
17
+ path.join(RUNTIME_DIR, 'scripts', 'local-gateway.js'),
18
+ path.join(RUNTIME_DIR, 'scripts', 'amalgm-mcp', 'index.js'),
19
+ ].map((value) => value.replace(/\\/g, '/'));
20
+ const RUNTIME_SERVICE_SUFFIXES = [
21
+ 'runtime/scripts/port-monitor.js',
22
+ 'runtime/scripts/fs-watcher.js',
23
+ 'runtime/scripts/chat-server.js',
24
+ 'runtime/scripts/local-gateway.js',
25
+ 'runtime/scripts/amalgm-mcp/index.js',
26
+ ];
27
+
28
+ const SUPERVISOR_MARKER = path.join(PACKAGE_ROOT, 'bin', 'amalgm.js').replace(/\\/g, '/');
29
+
30
+ function isPidRunning(pid) {
31
+ if (!Number.isInteger(pid) || pid <= 0) return false;
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function waitForExit(pid, timeoutMs = 5000) {
41
+ const deadline = Date.now() + timeoutMs;
42
+ while (Date.now() < deadline && isPidRunning(pid)) {
43
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
44
+ }
45
+ return !isPidRunning(pid);
46
+ }
47
+
48
+ function readProcString(file) {
49
+ try {
50
+ return fs.readFileSync(file).toString('utf8');
51
+ } catch {
52
+ return '';
53
+ }
54
+ }
55
+
56
+ function readProcCommand(pid) {
57
+ const cmdline = readProcString(`/proc/${pid}/cmdline`).replace(/\0/g, ' ').trim();
58
+ if (cmdline) return cmdline;
59
+ try {
60
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], {
61
+ encoding: 'utf8',
62
+ timeout: 1000,
63
+ });
64
+ return String(result.stdout || '').trim();
65
+ } catch {
66
+ return '';
67
+ }
68
+ }
69
+
70
+ function readProcEnv(pid) {
71
+ const raw = readProcString(`/proc/${pid}/environ`);
72
+ if (!raw) return null;
73
+ const env = {};
74
+ for (const entry of raw.split('\0')) {
75
+ if (!entry) continue;
76
+ const index = entry.indexOf('=');
77
+ if (index <= 0) continue;
78
+ env[entry.slice(0, index)] = entry.slice(index + 1);
79
+ }
80
+ return env;
81
+ }
82
+
83
+ function listProcProcesses() {
84
+ try {
85
+ return fs.readdirSync('/proc')
86
+ .filter((entry) => /^\d+$/.test(entry))
87
+ .map((entry) => Number(entry))
88
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
89
+ .map((pid) => ({
90
+ pid,
91
+ command: readProcCommand(pid),
92
+ env: readProcEnv(pid),
93
+ }))
94
+ .filter((item) => item.command);
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function listPsProcesses() {
101
+ try {
102
+ const result = spawnSync('ps', ['-eo', 'pid=,command='], {
103
+ encoding: 'utf8',
104
+ timeout: 2000,
105
+ });
106
+ if (result.status !== 0) return [];
107
+ return String(result.stdout || '')
108
+ .split('\n')
109
+ .map((line) => {
110
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
111
+ if (!match) return null;
112
+ return {
113
+ pid: Number(match[1]),
114
+ command: match[2],
115
+ env: null,
116
+ };
117
+ })
118
+ .filter(Boolean);
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+
124
+ function listProcesses() {
125
+ return listProcProcesses() || listPsProcesses();
126
+ }
127
+
128
+ function normalizedCommand(command) {
129
+ return String(command || '').replace(/\\/g, '/');
130
+ }
131
+
132
+ function envMatchesAmalgmDir(env) {
133
+ if (!env || !env.AMALGM_DIR) return true;
134
+ return path.resolve(env.AMALGM_DIR) === path.resolve(AMALGM_DIR);
135
+ }
136
+
137
+ function commandLooksLikeRuntimeService(command) {
138
+ const normalized = normalizedCommand(command);
139
+ return RUNTIME_SERVICE_MARKERS.some((marker) => normalized.includes(marker))
140
+ || RUNTIME_SERVICE_SUFFIXES.some((marker) => normalized.includes(marker));
141
+ }
142
+
143
+ function commandLooksLikeSupervisor(command) {
144
+ const normalized = normalizedCommand(command);
145
+ return normalized.includes(SUPERVISOR_MARKER) && /\brun\b/.test(normalized);
146
+ }
147
+
148
+ function runtimeServiceProcesses(options = {}) {
149
+ const exclude = new Set([process.pid, ...(options.excludePids || [])].filter(Number.isInteger));
150
+ return listProcesses()
151
+ .filter((item) => !exclude.has(item.pid))
152
+ .filter((item) => commandLooksLikeRuntimeService(item.command))
153
+ .filter((item) => envMatchesAmalgmDir(item.env));
154
+ }
155
+
156
+ function supervisorProcesses(options = {}) {
157
+ const exclude = new Set([process.pid, ...(options.excludePids || [])].filter(Number.isInteger));
158
+ return listProcesses()
159
+ .filter((item) => !exclude.has(item.pid))
160
+ .filter((item) => commandLooksLikeSupervisor(item.command))
161
+ .filter((item) => envMatchesAmalgmDir(item.env));
162
+ }
163
+
164
+ function terminateProcesses(processes, options = {}) {
165
+ const logger = options.logger || null;
166
+ const unique = [...new Map(processes.map((item) => [item.pid, item])).values()]
167
+ .filter((item) => isPidRunning(item.pid));
168
+ if (unique.length === 0) return [];
169
+
170
+ for (const item of unique) {
171
+ try {
172
+ process.kill(item.pid, 'SIGTERM');
173
+ } catch {
174
+ // It may have exited between discovery and shutdown.
175
+ }
176
+ }
177
+
178
+ const survivors = [];
179
+ for (const item of unique) {
180
+ if (!waitForExit(item.pid, options.termTimeoutMs || 3000)) survivors.push(item);
181
+ }
182
+
183
+ for (const item of survivors) {
184
+ try {
185
+ process.kill(item.pid, 'SIGKILL');
186
+ } catch {
187
+ // noop
188
+ }
189
+ }
190
+ for (const item of survivors) waitForExit(item.pid, options.killTimeoutMs || 1500);
191
+
192
+ const stopped = unique.filter((item) => !isPidRunning(item.pid));
193
+ if (stopped.length > 0 && logger?.log) {
194
+ logger.log(`cleaned stale Amalgm runtime process(es): ${stopped.map((item) => item.pid).join(', ')}`);
195
+ }
196
+ return stopped;
197
+ }
198
+
199
+ function cleanupStaleRuntimeProcesses(options = {}) {
200
+ const stale = runtimeServiceProcesses(options);
201
+ const supervisors = options.includeSupervisors ? supervisorProcesses(options) : [];
202
+ return terminateProcesses([...stale, ...supervisors], options);
203
+ }
204
+
205
+ module.exports = {
206
+ cleanupStaleRuntimeProcesses,
207
+ commandLooksLikeRuntimeService,
208
+ commandLooksLikeSupervisor,
209
+ isPidRunning,
210
+ runtimeServiceProcesses,
211
+ supervisorProcesses,
212
+ terminateProcesses,
213
+ };
package/lib/service.js CHANGED
@@ -15,6 +15,9 @@ const {
15
15
  SERVICE_STATE_FILE,
16
16
  SERVICE_STOP_FILE,
17
17
  } = require('./paths');
18
+ const {
19
+ cleanupStaleRuntimeProcesses,
20
+ } = require('./process-cleanup');
18
21
 
19
22
  const PACKAGE_VERSION = require('../package.json').version;
20
23
  const SERVICE_LABEL = 'ai.amalgm.runtime';
@@ -250,6 +253,7 @@ function writePortableScript(localOnly) {
250
253
  `LOCK_DIR=${shellQuote(path.join(SERVICE_DIR, 'lock'))}`,
251
254
  `NODE_BIN=${shellQuote(process.execPath)}`,
252
255
  `AMALGM_BIN=${shellQuote(AMALGM_BIN)}`,
256
+ `NODE_WATCHDOG=${shellQuote(SERVICE_NODE_SCRIPT_FILE)}`,
253
257
  `DAEMON_LOG=${shellQuote(path.join(LOG_DIR, 'daemon.log'))}`,
254
258
  `SERVICE_LOG=${shellQuote(SERVICE_LOG_FILE)}`,
255
259
  `RESTART_DELAY=${shellQuote(String(process.env.AMALGM_SERVICE_RESTART_DELAY || '5'))}`,
@@ -261,42 +265,7 @@ function writePortableScript(localOnly) {
261
265
 
262
266
  lines.push(
263
267
  'mkdir -p "$AMALGM_DIR" "$LOG_DIR"',
264
- 'if ! mkdir "$LOCK_DIR" 2>/dev/null; then',
265
- ' existing="$(cat "$PID_FILE" 2>/dev/null | sed -n \'s/.*"pid"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p\')"',
266
- ' [ -n "$existing" ] || existing="$(cat "$PID_FILE" 2>/dev/null || true)"',
267
- ' if [ -n "$existing" ] && kill -0 "$existing" 2>/dev/null; then',
268
- ' exit 0',
269
- ' fi',
270
- ' rm -rf "$LOCK_DIR"',
271
- ' mkdir "$LOCK_DIR" || exit 1',
272
- 'fi',
273
- 'child_pid=""',
274
- 'cleanup() {',
275
- ' touch "$STOP_FILE" 2>/dev/null || true',
276
- ' if [ -n "$child_pid" ] && kill -0 "$child_pid" 2>/dev/null; then',
277
- ' kill "$child_pid" 2>/dev/null || true',
278
- ' wait "$child_pid" 2>/dev/null || true',
279
- ' fi',
280
- ' rm -f "$PID_FILE"',
281
- ' rm -rf "$LOCK_DIR"',
282
- ' exit 0',
283
- '}',
284
- 'trap cleanup TERM INT HUP',
285
- 'printf \'{"pid":%s,"started_at":"%s","backend":"portable"}\\n\' "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$PID_FILE"',
286
- 'rm -f "$STOP_FILE"',
287
- 'while [ ! -f "$STOP_FILE" ]; do',
288
- ' printf \'[%s] [service] starting amalgm run\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$SERVICE_LOG"',
289
- ' "$NODE_BIN" "$AMALGM_BIN" run >> "$DAEMON_LOG" 2>&1 &',
290
- ' child_pid=$!',
291
- ' wait "$child_pid"',
292
- ' code=$?',
293
- ' child_pid=""',
294
- ' printf \'[%s] [service] amalgm run exited code=%s\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$code" >> "$SERVICE_LOG"',
295
- ' [ -f "$STOP_FILE" ] && break',
296
- ' sleep "$RESTART_DELAY"',
297
- 'done',
298
- 'rm -f "$PID_FILE"',
299
- 'rm -rf "$LOCK_DIR"',
268
+ 'exec "$NODE_BIN" "$NODE_WATCHDOG"',
300
269
  );
301
270
 
302
271
  fs.writeFileSync(SERVICE_SCRIPT_FILE, `${lines.join('\n')}\n`, { mode: 0o700 });
@@ -330,6 +299,9 @@ function writePortableNodeScript(localOnly) {
330
299
  const fs = require('fs');
331
300
  const path = require('path');
332
301
  const { spawn } = require('child_process');
302
+ const {
303
+ cleanupStaleRuntimeProcesses,
304
+ } = require(path.join(path.dirname(${JSON.stringify(AMALGM_BIN)}), '..', 'lib', 'process-cleanup'));
333
305
 
334
306
  const config = ${JSON.stringify(config, null, 2)};
335
307
  let child = null;
@@ -411,19 +383,39 @@ function cleanup(exitCode = 0) {
411
383
  if (child && !child.killed) {
412
384
  try { child.kill('SIGKILL'); } catch {}
413
385
  }
386
+ cleanupStaleRuntimeProcesses({
387
+ excludePids: [process.pid],
388
+ includeSupervisors: true,
389
+ logger: { log: (message) => append(config.serviceLog, message) },
390
+ });
414
391
  finish(exitCode);
415
392
  }, 5000);
416
393
  child.once('exit', () => {
417
394
  clearTimeout(timer);
395
+ cleanupStaleRuntimeProcesses({
396
+ excludePids: [process.pid],
397
+ includeSupervisors: true,
398
+ logger: { log: (message) => append(config.serviceLog, message) },
399
+ });
418
400
  finish(exitCode);
419
401
  });
420
402
  return;
421
403
  }
404
+ cleanupStaleRuntimeProcesses({
405
+ excludePids: [process.pid],
406
+ includeSupervisors: true,
407
+ logger: { log: (message) => append(config.serviceLog, message) },
408
+ });
422
409
  finish(exitCode);
423
410
  }
424
411
 
425
412
  function launch() {
426
413
  if (stopping || fs.existsSync(config.stopFile)) return cleanup(0);
414
+ cleanupStaleRuntimeProcesses({
415
+ excludePids: [process.pid],
416
+ includeSupervisors: true,
417
+ logger: { log: (message) => append(config.serviceLog, message) },
418
+ });
427
419
  append(config.serviceLog, 'starting amalgm run');
428
420
  const fd = fs.openSync(config.daemonLog, 'a');
429
421
  child = spawn(config.nodeBin, [config.amalgmBin, 'run'], {
@@ -655,10 +647,8 @@ function startPortable() {
655
647
  removeFile(SERVICE_STOP_FILE);
656
648
  ensureDir(LOG_DIR, 0o700);
657
649
  const logFd = fs.openSync(SERVICE_LOG_FILE, 'a');
658
- const command = process.platform === 'win32' ? process.execPath : '/bin/sh';
659
- const args = process.platform === 'win32'
660
- ? [state?.files?.nodeScript || SERVICE_NODE_SCRIPT_FILE]
661
- : [state?.files?.script || SERVICE_SCRIPT_FILE];
650
+ const command = process.execPath;
651
+ const args = [state?.files?.nodeScript || SERVICE_NODE_SCRIPT_FILE];
662
652
  const child = spawn(command, args, {
663
653
  detached: true,
664
654
  env: {
@@ -682,6 +672,7 @@ function stopDaemonProcess() {
682
672
  const pid = readPidFile(PID_FILE);
683
673
  if (!isPidRunning(pid)) {
684
674
  removeFile(PID_FILE);
675
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
685
676
  return false;
686
677
  }
687
678
  try {
@@ -701,6 +692,7 @@ function stopDaemonProcess() {
701
692
  }
702
693
  }
703
694
  removeFile(PID_FILE);
695
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
704
696
  return true;
705
697
  }
706
698
 
@@ -720,6 +712,7 @@ function stopPortable() {
720
712
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
721
713
  }
722
714
  removeFile(SERVICE_PID_FILE);
715
+ cleanupStaleRuntimeProcesses({ includeSupervisors: true });
723
716
  }
724
717
 
725
718
  function startService(options = {}) {
package/lib/supervisor.js CHANGED
@@ -26,6 +26,11 @@ const {
26
26
  ensureAgentCommandShims,
27
27
  ensureNativeBinaries,
28
28
  } = require('../runtime/scripts/chat-core/tooling/native-binaries');
29
+ const {
30
+ cleanupStaleRuntimeProcesses,
31
+ isPidRunning,
32
+ supervisorProcesses,
33
+ } = require('./process-cleanup');
29
34
  const PACKAGE_VERSION = require('../package.json').version;
30
35
 
31
36
  function ensureDir(dir, mode = 0o700) {
@@ -408,6 +413,21 @@ async function startSupervisor(options = {}) {
408
413
  ensureDir(AMALGM_DIR, 0o700);
409
414
  ensureDir(LOG_DIR, 0o700);
410
415
 
416
+ const activeSupervisors = supervisorProcesses({ excludePids: [process.pid] });
417
+ if (activeSupervisors.length > 0) {
418
+ const pids = activeSupervisors
419
+ .filter((item) => isPidRunning(item.pid))
420
+ .map((item) => item.pid);
421
+ if (pids.length > 0) {
422
+ throw new Error(`Amalgm runtime is already running (pid ${pids.join(', ')}). Run \`amalgm stop\` first.`);
423
+ }
424
+ }
425
+
426
+ cleanupStaleRuntimeProcesses({
427
+ excludePids: [process.pid],
428
+ logger: options.foreground ? console : null,
429
+ });
430
+
411
431
  const nativeResult = ensureNativeBinaries({
412
432
  logger: console,
413
433
  quiet: !options.foreground,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.40",
3
+ "version": "0.1.43",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -17,7 +17,7 @@
17
17
  "sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
18
18
  "prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
19
19
  "pack:dry": "npm pack --dry-run",
20
- "check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/service.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/fs-watcher.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js"
20
+ "check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/service.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/fs-watcher.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20"
@@ -26,6 +26,13 @@ const stopping = new Set();
26
26
  const restartTimers = new Map();
27
27
 
28
28
  const ARTIFACT_ENV_ALLOWLIST = [
29
+ 'AMALGM_BIND_HOST',
30
+ 'AMALGM_DIR',
31
+ 'AMALGM_GATEWAY_PORT',
32
+ 'AMALGM_MCP_PORT',
33
+ 'AMALGM_RUNTIME_TOKEN',
34
+ 'AMALGM_WORKSPACES_DIR',
35
+ 'CHAT_SERVER_PORT',
29
36
  'PATH',
30
37
  'HOME',
31
38
  'USER',
@@ -81,6 +88,8 @@ function buildEnv(artifact) {
81
88
  AMALGM_ARTIFACT_ID: artifact.id,
82
89
  AMALGM_ARTIFACT_REF: artifact.artifactRef,
83
90
  AMALGM_ARTIFACT_URL: artifact.publicUrl,
91
+ AMALGM_CHAT_SERVER_URL: `http://127.0.0.1:${process.env.CHAT_SERVER_PORT || 8084}`,
92
+ AMALGM_MCP_URL: `http://127.0.0.1:${process.env.AMALGM_MCP_PORT || 8083}`,
84
93
  };
85
94
  }
86
95
 
@@ -26,6 +26,39 @@ function harnessToAgent(harness) {
26
26
  return map[harness] || 'claude';
27
27
  }
28
28
 
29
+ function normalizeEffort(value) {
30
+ if (typeof value !== 'string') return null;
31
+ const normalized = value.trim().toLowerCase().replace(/_/g, '-');
32
+ if (normalized === 'low') return 'low';
33
+ if (normalized === 'medium') return 'medium';
34
+ if (normalized === 'high') return 'high';
35
+ if (normalized === 'xhigh' || normalized === 'extra-high' || normalized === 'extra high') return 'xhigh';
36
+ if (normalized === 'max') return 'max';
37
+ return null;
38
+ }
39
+
40
+ function sanitizeRunModelSettings(settings, harness) {
41
+ if (!settings || typeof settings !== 'object') return {};
42
+ const effort = normalizeEffort(settings.effort || settings.reasoningEffort || settings.reasoning);
43
+ const allowedEfforts =
44
+ harness === 'claude_code'
45
+ ? new Set(['low', 'medium', 'high', 'xhigh', 'max'])
46
+ : harness === 'codex'
47
+ ? new Set(['low', 'medium', 'high', 'xhigh'])
48
+ : new Set();
49
+ return {
50
+ ...(effort && allowedEfforts.has(effort) ? { effort } : {}),
51
+ ...(harness === 'claude_code' && settings.fastMode === true ? { fastMode: true } : {}),
52
+ };
53
+ }
54
+
55
+ function modelIdForSettings(modelId, harness, modelSettings) {
56
+ if (harness !== 'codex' || !modelSettings.effort || typeof modelId !== 'string') return modelId;
57
+ return modelId
58
+ .replace(/(?::thinking-|-thinking-)(low|medium|high|xhigh)$/i, '')
59
+ .replace(/\/(low|medium|high|xhigh)$/i, '');
60
+ }
61
+
29
62
  function resolveEventRequest(eventDef, projectPath) {
30
63
  const harness =
31
64
  (eventDef.chatInput && eventDef.chatInput.agent && eventDef.chatInput.agent.harness)
@@ -58,6 +91,10 @@ function resolveEventRequest(eventDef, projectPath) {
58
91
  templateChatInput,
59
92
  authMethod,
60
93
  model,
94
+ modelSettings: sanitizeRunModelSettings(
95
+ eventDef.modelSettings || (eventDef.chatInput && eventDef.chatInput.modelSettings),
96
+ harness,
97
+ ),
61
98
  };
62
99
  }
63
100
 
@@ -75,7 +112,7 @@ async function executeArtifactEvent(artifactOrTrigger, eventDef, payload, opts =
75
112
  const userMessageId = crypto.randomUUID();
76
113
  const assistantMessageId = crypto.randomUUID();
77
114
  const startedAt = new Date().toISOString();
78
- const { harness, templateChatInput, authMethod, model } = resolveEventRequest(eventDef, projectPath);
115
+ const { harness, templateChatInput, authMethod, model, modelSettings } = resolveEventRequest(eventDef, projectPath);
79
116
  const payloadText = JSON.stringify(payload);
80
117
  const chatInput = withChatInputText(templateChatInput, (text) => text.replaceAll('{payload}', payloadText));
81
118
  const legacy = chatInputToLegacyFields(chatInput, {
@@ -87,8 +124,12 @@ async function executeArtifactEvent(artifactOrTrigger, eventDef, payload, opts =
87
124
  projectPath,
88
125
  });
89
126
  const cwd = legacy.cwd || DEFAULT_CWD;
90
- const modelSelection = resolveModelSelection(harness, legacy.modelId || model);
127
+ const modelSelection = resolveModelSelection(
128
+ harness,
129
+ modelIdForSettings(legacy.modelId || model, harness, modelSettings),
130
+ );
91
131
  const mcpServers = await buildLocalMcpServerConfigs(legacy.mcpAppIds || []);
132
+ const reasoningEffort = modelSettings.effort || modelSelection.reasoningEffort;
92
133
 
93
134
  console.log(
94
135
  `[AmalgmMCP:Event] Starting agent run for ${artifactOrTrigger.id}:${eventDef.name} (session: ${codeSessionId}, cwd: ${cwd})`,
@@ -135,7 +176,8 @@ async function executeArtifactEvent(artifactOrTrigger, eventDef, payload, opts =
135
176
  agentId: harnessToAgent(harness),
136
177
  modelId: modelSelection.modelId || legacy.modelId || null,
137
178
  cliModel: modelSelection.cliModel || null,
138
- ...(modelSelection.reasoningEffort ? { reasoningEffort: modelSelection.reasoningEffort } : {}),
179
+ ...(reasoningEffort ? { reasoningEffort } : {}),
180
+ ...(modelSettings.fastMode ? { fastMode: true } : {}),
139
181
  cwd,
140
182
  authMethod: legacy.authMethod || authMethod,
141
183
  mcpServers,
@@ -156,7 +198,7 @@ async function executeArtifactEvent(artifactOrTrigger, eventDef, payload, opts =
156
198
  if (hasSupabase()) {
157
199
  supabasePatch('sessions', 'id', codeSessionId, {
158
200
  last_message_at: new Date().toISOString(),
159
- status: 'completed',
201
+ status: 'complete',
160
202
  new_messages: true,
161
203
  }).catch(() => {});
162
204
  }
@@ -109,7 +109,7 @@ async function handleList(req, sendJson) {
109
109
  }
110
110
 
111
111
  async function handleCreate(body, sendJson) {
112
- const { name, description, source, event, agent_prompt, projectPath, harness, model, authMethod, chatInput } = body;
112
+ const { name, description, source, event, agent_prompt, projectPath, harness, model, modelSettings, authMethod, chatInput } = body;
113
113
  if (!name || !source || !event || (!agent_prompt && !chatInput)) {
114
114
  return sendJson(400, { error: 'name, source, event, and agent_prompt or chatInput are required' });
115
115
  }
@@ -128,6 +128,7 @@ async function handleCreate(body, sendJson) {
128
128
  projectPath: projectPath || null,
129
129
  harness: harness || null,
130
130
  model: model || null,
131
+ modelSettings: modelSettings || null,
131
132
  authMethod: authMethod || null,
132
133
  chatInput: chatInput || null,
133
134
  createdAt: new Date().toISOString(),
@@ -131,12 +131,13 @@ module.exports = [
131
131
  projectPath: { type: 'string', description: 'Working directory path for the agent run.' },
132
132
  harness: { type: 'string', description: 'Agent harness for the event run.' },
133
133
  model: { type: 'string', description: 'Model ID for the event run.' },
134
+ modelSettings: { type: 'object', description: 'Advanced model settings such as effort and fastMode.' },
134
135
  authMethod: { type: 'string', description: 'Auth method for the event run.' },
135
136
  chatInput: { type: 'object', description: 'Shared chat input shape for the event run.' },
136
137
  },
137
138
  required: ['name', 'source', 'event'],
138
139
  },
139
- async handler({ name, description, source, event, agent_prompt, projectPath, harness, model, authMethod, chatInput }) {
140
+ async handler({ name, description, source, event, agent_prompt, projectPath, harness, model, modelSettings, authMethod, chatInput }) {
140
141
  if (!name || (!agent_prompt && !chatInput)) return errorResult('name and agent_prompt or chatInput are required');
141
142
  await hydrateModelPreferences();
142
143
  const data = loadEventTriggers();
@@ -153,6 +154,7 @@ module.exports = [
153
154
  projectPath: projectPath || null,
154
155
  harness: harness || null,
155
156
  model: model || null,
157
+ modelSettings: modelSettings || null,
156
158
  authMethod: authMethod || null,
157
159
  chatInput: chatInput || null,
158
160
  createdAt: new Date().toISOString(),
@@ -226,12 +228,13 @@ module.exports = [
226
228
  projectPath: { type: 'string' },
227
229
  harness: { type: 'string' },
228
230
  model: { type: 'string' },
231
+ modelSettings: { type: 'object' },
229
232
  authMethod: { type: 'string' },
230
233
  chatInput: { type: 'object' },
231
234
  },
232
235
  required: ['trigger_id'],
233
236
  },
234
- async handler({ trigger_id, name, description, source, event, agent_prompt, enabled, projectPath, harness, model, authMethod, chatInput }) {
237
+ async handler({ trigger_id, name, description, source, event, agent_prompt, enabled, projectPath, harness, model, modelSettings, authMethod, chatInput }) {
235
238
  const data = loadEventTriggers();
236
239
  const trigger = data.triggers.find((t) => t.id === trigger_id);
237
240
  if (!trigger) return errorResult(`Trigger not found: ${trigger_id}`);
@@ -247,6 +250,7 @@ module.exports = [
247
250
  if (projectPath !== undefined) trigger.projectPath = projectPath || null;
248
251
  if (harness !== undefined) trigger.harness = harness || null;
249
252
  if (model !== undefined) trigger.model = model || null;
253
+ if (modelSettings !== undefined) trigger.modelSettings = modelSettings || null;
250
254
  if (authMethod !== undefined) trigger.authMethod = authMethod || null;
251
255
  if (chatInput !== undefined) trigger.chatInput = chatInput || null;
252
256
  trigger.updatedAt = new Date().toISOString();