amalgm 0.1.41 → 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.41",
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
 
@@ -198,7 +198,7 @@ async function executeArtifactEvent(artifactOrTrigger, eventDef, payload, opts =
198
198
  if (hasSupabase()) {
199
199
  supabasePatch('sessions', 'id', codeSessionId, {
200
200
  last_message_at: new Date().toISOString(),
201
- status: 'completed',
201
+ status: 'complete',
202
202
  new_messages: true,
203
203
  }).catch(() => {});
204
204
  }
@@ -233,6 +233,14 @@ async function executeTask(task) {
233
233
  console.log(
234
234
  `[AmalgmMCP:Exec] Task ${task.id} ${status} in ${durationMs}ms (session: ${codeSessionId})`,
235
235
  );
236
+
237
+ if (hasSupabase()) {
238
+ supabasePatch('sessions', 'id', codeSessionId, {
239
+ last_message_at: new Date().toISOString(),
240
+ status: 'complete',
241
+ new_messages: true,
242
+ }).catch(() => {});
243
+ }
236
244
  } catch (err) {
237
245
  if (err.name === 'AbortError') {
238
246
  appendRunLog(task.id, {
@@ -86,12 +86,36 @@ const INTERNAL_SERVICE_PORTS = new Set([
86
86
  const COMMON_DEV_PORTS = new Set([3000, 3001, 3002, 3003, 4000, 4200, 4321, 5000, 5001, 5173, 5174, 6006, 7000, 8000, 8001, 8888]);
87
87
  const BLOCKED_PREVIEW_PROCESSES = new Set(['.opencode', 'controlce', 'figma_age', 'figma_agent', 'loom', 'opencode', 'rapportd']);
88
88
  const DEV_PREVIEW_PROCESS_RE = /^(node|bun|deno|npm|pnpm|yarn|vite|next|astro|nuxt|svelte|remix|webpack|rspack|parcel|serve|http-server|tsx|python|python3|uvicorn|gunicorn|flask|django|ruby|rails|puma|php|java|go|air|cargo|trunk|dotnet|nginx)$/i;
89
+ const INTERNAL_COMMAND_RE = /(?:^|\s)(?:node(?:-[^\s]+)?\s+)?[^\s]*(?:\/|\\)runtime(?:\/|\\)scripts(?:\/|\\)(?:port-monitor|fs-watcher|chat-server|local-gateway|amalgm-mcp(?:\/|\\)index)\.js(?:\s|$)/i;
90
+ const BLOCKED_COMMAND_RE = /(?:agent-browser|chromium|chrome)(?:\s|$)/i;
89
91
 
90
- function isPreviewablePort(port, processName) {
92
+ function readProcessCommand(pid) {
93
+ if (!Number.isInteger(pid) || pid <= 0) return '';
94
+ try {
95
+ return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
96
+ } catch {
97
+ return '';
98
+ }
99
+ }
100
+
101
+ function normalizeProcessName(processName) {
102
+ const name = String(processName || '').trim().toLowerCase();
103
+ if (/^node(?:$|[-_])/.test(name)) return 'node';
104
+ return name;
105
+ }
106
+
107
+ function publicProcessName(info) {
108
+ if (!info || typeof info !== 'object') return info || null;
109
+ return info.processName || null;
110
+ }
111
+
112
+ function isPreviewablePort(port, processInfo) {
91
113
  if (!Number.isInteger(port) || port < 3000 || port > 65535) return false;
92
114
  if (INTERNAL_SERVICE_PORTS.has(port)) return false;
93
115
  if (port >= 9100 && port <= 9199) return false;
94
- const name = String(processName || '').trim().toLowerCase();
116
+ const command = typeof processInfo === 'object' && processInfo ? String(processInfo.command || '') : '';
117
+ if (INTERNAL_COMMAND_RE.test(command) || BLOCKED_COMMAND_RE.test(command)) return false;
118
+ const name = normalizeProcessName(typeof processInfo === 'object' && processInfo ? processInfo.processName : processInfo);
95
119
  if (BLOCKED_PREVIEW_PROCESSES.has(name) || name.startsWith('figma')) return false;
96
120
  if (!name) return COMMON_DEV_PORTS.has(port);
97
121
  if (DEV_PREVIEW_PROCESS_RE.test(name)) return true;
@@ -721,7 +745,7 @@ function broadcastPortEvent(event) {
721
745
  }
722
746
 
723
747
  function getListeningPorts() {
724
- /** @type {Map<number, string|null>} */
748
+ /** @type {Map<number, { processName: string|null, pid: number|null, command: string }>} */
725
749
  const currentPorts = new Map();
726
750
 
727
751
  if (process.platform === 'darwin') {
@@ -731,9 +755,13 @@ function getListeningPorts() {
731
755
  for (const line of lines) {
732
756
  const trimmed = line.trim();
733
757
  if (!trimmed) continue;
734
- const match = trimmed.match(/^(\S+)\s+\d+\s+.+\sTCP\s+.+:(\d+)\s+\(LISTEN\)$/);
758
+ const match = trimmed.match(/^(\S+)\s+(\d+)\s+.+\sTCP\s+.+:(\d+)\s+\(LISTEN\)$/);
735
759
  if (!match) continue;
736
- currentPorts.set(parseInt(match[2], 10), match[1] || null);
760
+ currentPorts.set(parseInt(match[3], 10), {
761
+ processName: match[1] || null,
762
+ pid: parseInt(match[2], 10),
763
+ command: '',
764
+ });
737
765
  }
738
766
 
739
767
  return currentPorts;
@@ -749,8 +777,14 @@ function getListeningPorts() {
749
777
  let processName = null;
750
778
  const procMatch = line.match(/users:\(\("([^"]+)"/) || line.match(/\/([^\s/]+)\s*$/);
751
779
  if (procMatch) processName = procMatch[1];
780
+ const pidMatch = line.match(/pid=(\d+)/);
781
+ const pid = pidMatch ? parseInt(pidMatch[1], 10) : null;
752
782
 
753
- currentPorts.set(parseInt(portMatch[1], 10), processName);
783
+ currentPorts.set(parseInt(portMatch[1], 10), {
784
+ processName,
785
+ pid,
786
+ command: pid ? readProcessCommand(pid) : '',
787
+ });
754
788
  }
755
789
 
756
790
  return currentPorts;
@@ -760,16 +794,17 @@ function checkPorts() {
760
794
  try {
761
795
  const currentPorts = getListeningPorts();
762
796
 
763
- for (const [port, processName] of currentPorts) {
764
- if (!isPreviewablePort(port, processName)) {
797
+ for (const [port, processInfo] of currentPorts) {
798
+ if (!isPreviewablePort(port, processInfo)) {
765
799
  currentPorts.delete(port);
766
800
  }
767
801
  }
768
802
 
769
803
  // Detect newly opened ports
770
- for (const [port, processName] of currentPorts) {
804
+ for (const [port, processInfo] of currentPorts) {
771
805
  if (!knownPorts.has(port)) {
772
- knownPorts.set(port, processName);
806
+ knownPorts.set(port, processInfo);
807
+ const processName = publicProcessName(processInfo);
773
808
  console.log(`[FsWatcher:Ports] Port opened: ${port} (${processName || 'unknown'})`);
774
809
  broadcastPortEvent({ type: 'port', event: 'opened', port, processName, timestamp: Date.now() });
775
810
  }
@@ -31,10 +31,23 @@ const INTERNAL_SERVICE_PORTS = new Set([
31
31
  4096,
32
32
  ]);
33
33
  const COMMON_DEV_PORTS = new Set([3000, 3001, 3002, 3003, 4000, 4200, 4321, 5000, 5001, 5173, 5174, 6006, 7000, 8000, 8001, 8888]);
34
+ const BLOCKED_PREVIEW_PROCESSES = new Set(['.opencode', 'controlce', 'figma_age', 'figma_agent', 'loom', 'opencode', 'rapportd']);
35
+ const DEV_PREVIEW_PROCESS_RE = /^(node|bun|deno|npm|pnpm|yarn|vite|next|astro|nuxt|svelte|remix|webpack|rspack|parcel|serve|http-server|tsx|python|python3|uvicorn|gunicorn|flask|django|ruby|rails|puma|php|java|go|air|cargo|trunk|dotnet|nginx)$/i;
36
+ const INTERNAL_COMMAND_RE = /(?:^|\s)(?:node(?:-[^\s]+)?\s+)?[^\s]*(?:\/|\\)runtime(?:\/|\\)scripts(?:\/|\\)(?:port-monitor|fs-watcher|chat-server|local-gateway|amalgm-mcp(?:\/|\\)index)\.js(?:\s|$)/i;
37
+ const BLOCKED_COMMAND_RE = /(?:agent-browser|chromium|chrome)(?:\s|$)/i;
34
38
 
35
39
  const knownPorts = new Map();
36
40
  const subscribers = new Set();
37
41
 
42
+ function readProcessCommand(pid) {
43
+ if (!Number.isInteger(pid) || pid <= 0) return '';
44
+ try {
45
+ return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
46
+ } catch {
47
+ return '';
48
+ }
49
+ }
50
+
38
51
  function readProcTcpPorts(file) {
39
52
  try {
40
53
  const text = fs.readFileSync(file, 'utf8');
@@ -66,8 +79,14 @@ function getListeningPorts() {
66
79
  const output = execSync('lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null', { encoding: 'utf-8' });
67
80
  const ports = new Map();
68
81
  for (const line of output.split('\n').slice(1)) {
69
- const match = line.trim().match(/^(\S+)\s+\d+\s+.+\sTCP\s+.+:(\d+)\s+\(LISTEN\)$/);
70
- if (match) ports.set(parseInt(match[2], 10), match[1] || null);
82
+ const match = line.trim().match(/^(\S+)\s+(\d+)\s+.+\sTCP\s+.+:(\d+)\s+\(LISTEN\)$/);
83
+ if (match) {
84
+ ports.set(parseInt(match[3], 10), {
85
+ processName: match[1] || null,
86
+ pid: parseInt(match[2], 10),
87
+ command: '',
88
+ });
89
+ }
71
90
  }
72
91
  return ports;
73
92
  }
@@ -79,39 +98,58 @@ function getListeningPorts() {
79
98
  const portMatch = line.match(/:(\d+)\s+/);
80
99
  if (!portMatch) continue;
81
100
  const procMatch = line.match(/users:\(\("([^"]+)"/) || line.match(/\/([^\s/]+)\s*$/);
82
- ports.set(parseInt(portMatch[1], 10), procMatch ? procMatch[1] : null);
101
+ const pidMatch = line.match(/pid=(\d+)/);
102
+ const pid = pidMatch ? parseInt(pidMatch[1], 10) : null;
103
+ ports.set(parseInt(portMatch[1], 10), {
104
+ processName: procMatch ? procMatch[1] : null,
105
+ pid,
106
+ command: pid ? readProcessCommand(pid) : '',
107
+ });
83
108
  }
84
109
  if (ports.size > 0) return ports;
85
110
  } catch {
86
111
  // Minimal Docker images often omit ss and netstat.
87
112
  }
88
113
 
89
- return new Map(getListeningPortsFromProc().map((port) => [port, null]));
114
+ return new Map(getListeningPortsFromProc().map((port) => [port, { processName: null, pid: null, command: '' }]));
90
115
  }
91
116
 
92
- function isPreviewablePort(port, processName) {
117
+ function normalizeProcessName(processName) {
118
+ const name = String(processName || '').trim().toLowerCase();
119
+ if (/^node(?:$|[-_])/.test(name)) return 'node';
120
+ return name;
121
+ }
122
+
123
+ function publicProcessName(info) {
124
+ if (!info || typeof info !== 'object') return info || null;
125
+ return info.processName || null;
126
+ }
127
+
128
+ function isPreviewablePort(port, processInfo) {
93
129
  if (!Number.isInteger(port) || port < 3000 || port > 65535) return false;
94
130
  if (INTERNAL_SERVICE_PORTS.has(port)) return false;
95
131
  if (port >= 9100 && port <= 9199) return false;
96
- const name = String(processName || '').trim().toLowerCase();
97
- if (['.opencode', 'controlce', 'figma_age', 'figma_agent', 'loom', 'opencode', 'rapportd'].includes(name)) return false;
132
+ const command = typeof processInfo === 'object' && processInfo ? String(processInfo.command || '') : '';
133
+ if (INTERNAL_COMMAND_RE.test(command) || BLOCKED_COMMAND_RE.test(command)) return false;
134
+ const name = normalizeProcessName(typeof processInfo === 'object' && processInfo ? processInfo.processName : processInfo);
135
+ if (BLOCKED_PREVIEW_PROCESSES.has(name)) return false;
98
136
  if (name.startsWith('figma')) return false;
99
137
  if (!name) return COMMON_DEV_PORTS.has(port);
100
- if (/^(node|bun|deno|npm|pnpm|yarn|vite|next|astro|nuxt|svelte|remix|webpack|rspack|parcel|serve|http-server|tsx|python|python3|uvicorn|gunicorn|flask|django|ruby|rails|puma|php|java|go|air|cargo|trunk|dotnet|nginx)$/i.test(name)) return true;
138
+ if (DEV_PREVIEW_PROCESS_RE.test(name)) return true;
101
139
  return COMMON_DEV_PORTS.has(port) && name === 'unknown';
102
140
  }
103
141
 
104
142
  function checkPorts() {
105
143
  try {
106
144
  const ports = getListeningPorts();
107
- for (const [port, processName] of ports) {
108
- if (!isPreviewablePort(port, processName)) ports.delete(port);
145
+ for (const [port, processInfo] of ports) {
146
+ if (!isPreviewablePort(port, processInfo)) ports.delete(port);
109
147
  }
110
148
 
111
- for (const [port, processName] of ports) {
149
+ for (const [port, processInfo] of ports) {
112
150
  if (!knownPorts.has(port)) {
113
- knownPorts.set(port, processName);
114
- broadcast({ type: 'port_opened', port, processName, timestamp: Date.now() });
151
+ knownPorts.set(port, processInfo);
152
+ broadcast({ type: 'port_opened', port, processName: publicProcessName(processInfo), timestamp: Date.now() });
115
153
  }
116
154
  }
117
155
 
@@ -143,7 +181,7 @@ const server = http.createServer((req, res) => {
143
181
  res.writeHead(200, { 'Content-Type': 'application/json' });
144
182
  res.end(JSON.stringify({
145
183
  status: 'ok',
146
- activePorts: [...knownPorts.entries()].map(([port, processName]) => ({ port, processName })),
184
+ activePorts: [...knownPorts.entries()].map(([port, processInfo]) => ({ port, processName: publicProcessName(processInfo) })),
147
185
  }));
148
186
  return;
149
187
  }
@@ -154,7 +192,7 @@ const server = http.createServer((req, res) => {
154
192
  'Cache-Control': 'no-cache, no-transform',
155
193
  'Connection': 'keep-alive',
156
194
  });
157
- res.write(`event: init\ndata: ${JSON.stringify({ ports: [...knownPorts.entries()].map(([port, processName]) => ({ port, processName })) })}\n\n`);
195
+ res.write(`event: init\ndata: ${JSON.stringify({ ports: [...knownPorts.entries()].map(([port, processInfo]) => ({ port, processName: publicProcessName(processInfo) })) })}\n\n`);
158
196
  subscribers.add(res);
159
197
  req.on('close', () => subscribers.delete(res));
160
198
  return;
@@ -162,7 +200,7 @@ const server = http.createServer((req, res) => {
162
200
 
163
201
  if (url.pathname === '/api/ports') {
164
202
  res.writeHead(200, { 'Content-Type': 'application/json' });
165
- res.end(JSON.stringify({ ports: [...knownPorts.entries()].map(([port, processName]) => ({ port, processName })) }));
203
+ res.end(JSON.stringify({ ports: [...knownPorts.entries()].map(([port, processInfo]) => ({ port, processName: publicProcessName(processInfo) })) }));
166
204
  return;
167
205
  }
168
206