amalgm 0.1.43 → 0.1.44

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/README.md CHANGED
@@ -22,7 +22,19 @@ amalgm login --setup-code ABCD-EFGH-JKLM-NPQR
22
22
 
23
23
  `amalgm doctor` checks the installed runtime, login record, service supervisor, required local ports, daemon state, and whether an HMAC proxy token is available.
24
24
 
25
- `amalgm start` installs/starts the best available service supervisor for the machine, then the supervisor runs `amalgm run` and keeps the local essentials alive:
25
+ `amalgm run` is the foreground runtime supervisor. It reads the declared service manifest, starts each local service, health-checks them, and restarts unhealthy children with backoff. In a container you control, make this the container command/entrypoint, ideally behind a tiny init such as Docker `--init`, `tini`, or the provider's init wrapper:
26
+
27
+ ```sh
28
+ amalgm run
29
+ ```
30
+
31
+ `amalgm start` is the host attachment helper. It installs/starts the best available launcher for the machine, then that launcher runs `amalgm run`:
32
+
33
+ - macOS host: LaunchAgent
34
+ - Linux host with real user systemd: systemd user service
35
+ - random/container shell where no service manager is available: portable best-effort watchdog
36
+
37
+ The runtime supervisor keeps the declared local essentials alive:
26
38
 
27
39
  - port monitor on `8081`
28
40
  - filesystem/auth watcher on `8082`
@@ -31,7 +43,7 @@ amalgm login --setup-code ABCD-EFGH-JKLM-NPQR
31
43
  - events/previews/artifact tunnel to `wire.events.amalgm.ai`
32
44
  - chat tunnel to `amalgm-chat-gateway`
33
45
 
34
- The npm supervisor binds local services to `127.0.0.1` by default and connects them outward through the registered Amalgm tunnels. For development without cloud registration, use `amalgm start --local-only`. For direct foreground debugging, use `amalgm start --foreground`.
46
+ The npm supervisor binds local services to `127.0.0.1` by default and connects them outward through the registered Amalgm tunnels. For development without cloud registration, use `amalgm start --local-only`. For direct foreground debugging, use `amalgm run` or `amalgm start --foreground`.
35
47
 
36
48
  Useful commands:
37
49
 
package/lib/cli.js CHANGED
@@ -33,6 +33,12 @@ const {
33
33
  runtimeServiceProcesses,
34
34
  supervisorProcesses,
35
35
  } = require('./process-cleanup');
36
+ const {
37
+ runtimePortsForStatus,
38
+ runtimeServiceNames,
39
+ runtimeServiceScripts,
40
+ runtimeServices,
41
+ } = require('./runtime-manifest');
36
42
  const {
37
43
  deleteComputerAuth,
38
44
  loadComputerRecord,
@@ -89,6 +95,7 @@ function usage() {
89
95
  'Usage:',
90
96
  ' amalgm login [--app-url https://amalgm.ai] [--name "My Mac"] [--setup-code ABCD-EFGH-JKLM-NPQR] [--no-open] [--no-poll]',
91
97
  ' amalgm start [--foreground] [--local-only]',
98
+ ' amalgm run [--local-only]',
92
99
  ' amalgm stop',
93
100
  ' amalgm status',
94
101
  ' amalgm doctor',
@@ -144,36 +151,13 @@ function runtimeEntry(relativePath) {
144
151
  }
145
152
 
146
153
  function runtimeMissingFiles() {
147
- const required = [
148
- runtimeEntry('scripts/amalgm-mcp/index.js'),
149
- runtimeEntry('scripts/chat-server.js'),
150
- runtimeEntry('scripts/fs-watcher.js'),
151
- runtimeEntry('scripts/local-gateway.js'),
152
- runtimeEntry('scripts/port-monitor.js'),
153
- ];
154
+ const required = runtimeServiceScripts().map((script) => runtimeEntry(script));
154
155
  return required.filter((file) => !fs.existsSync(file));
155
156
  }
156
157
 
157
158
  function servicePorts() {
158
159
  const state = readJson(RUNTIME_STATE_FILE, null);
159
- const statePorts = state?.ports || {};
160
- if (Number.isInteger(state?.gateway_port)) {
161
- return [
162
- ['local-gateway', Number(state.gateway_port)],
163
- ['port-monitor', Number(statePorts.port_monitor || 0)],
164
- ['fs-watcher', Number(statePorts.fs_watcher || 0)],
165
- ['amalgm-mcp', Number(statePorts.amalgm_mcp || 0)],
166
- ['chat-server', Number(statePorts.chat_server || 0)],
167
- ].filter(([, port]) => Number.isInteger(port) && port > 0);
168
- }
169
-
170
- return [
171
- ['local-gateway', Number(process.env.AMALGM_GATEWAY_PORT || 28781)],
172
- ['port-monitor', Number(process.env.PORT_MONITOR_PORT || 8081)],
173
- ['fs-watcher', Number(process.env.FS_WATCHER_PORT || 8082)],
174
- ['amalgm-mcp', Number(process.env.AMALGM_MCP_PORT || 8083)],
175
- ['chat-server', Number(process.env.CHAT_SERVER_PORT || 8084)],
176
- ];
160
+ return runtimePortsForStatus(state, process.env);
177
161
  }
178
162
 
179
163
  function hasRequiredComputerFields(record) {
@@ -357,13 +341,7 @@ function isPortFree(port, host = '127.0.0.1') {
357
341
 
358
342
  async function unavailableServicePorts() {
359
343
  const results = [];
360
- const explicitEnvByService = new Map([
361
- ['local-gateway', 'AMALGM_GATEWAY_PORT'],
362
- ['port-monitor', 'PORT_MONITOR_PORT'],
363
- ['fs-watcher', 'FS_WATCHER_PORT'],
364
- ['amalgm-mcp', 'AMALGM_MCP_PORT'],
365
- ['chat-server', 'CHAT_SERVER_PORT'],
366
- ]);
344
+ const explicitEnvByService = new Map(runtimeServices().map((service) => [service.name, service.envName]));
367
345
  for (const [name, port] of servicePorts()) {
368
346
  const envName = explicitEnvByService.get(name);
369
347
  if (!envName || !process.env[envName]) continue;
@@ -963,7 +941,7 @@ async function stop() {
963
941
  console.log('Amalgm stopped.');
964
942
  }
965
943
 
966
- async function checkHttp(port, pathName = '/healthz') {
944
+ async function checkHttpOnce(port, pathName = '/healthz', timeoutMs = 1500) {
967
945
  return new Promise((resolve) => {
968
946
  const req = http.request(
969
947
  {
@@ -971,7 +949,7 @@ async function checkHttp(port, pathName = '/healthz') {
971
949
  port,
972
950
  path: pathName,
973
951
  method: 'GET',
974
- timeout: 1000,
952
+ timeout: timeoutMs,
975
953
  },
976
954
  (res) => {
977
955
  res.resume();
@@ -987,6 +965,16 @@ async function checkHttp(port, pathName = '/healthz') {
987
965
  });
988
966
  }
989
967
 
968
+ async function checkHttp(port, pathName = '/healthz', options = {}) {
969
+ const attempts = Math.max(1, Number(options.attempts || 3));
970
+ const timeoutMs = Math.max(500, Number(options.timeoutMs || 1500));
971
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
972
+ if (await checkHttpOnce(port, pathName, timeoutMs)) return true;
973
+ if (attempt < attempts - 1) await sleep(150);
974
+ }
975
+ return false;
976
+ }
977
+
990
978
  async function waitForServices(timeoutMs) {
991
979
  const deadline = Date.now() + timeoutMs;
992
980
  let latest = [];
@@ -1039,15 +1027,24 @@ async function status() {
1039
1027
 
1040
1028
  const runtimeChildren = runtimeServiceProcesses();
1041
1029
  const runtimeSupervisors = supervisorProcesses();
1042
- if (runtimeChildren.length > 5 || runtimeSupervisors.length > 1) {
1030
+ const expectedServiceCount = runtimeServiceNames().length;
1031
+ if (runtimeChildren.length > expectedServiceCount || runtimeSupervisors.length > 1) {
1043
1032
  console.log(
1044
1033
  `Warning: multiple Amalgm runtime processes detected (${runtimeSupervisors.length} supervisor, ${runtimeChildren.length} services). Run \`amalgm stop && amalgm start\`.`,
1045
1034
  );
1046
1035
  }
1047
1036
 
1037
+ const serviceStateByName = new Map(
1038
+ (Array.isArray(runtimeState?.services) ? runtimeState.services : [])
1039
+ .map((service) => [service.name, service]),
1040
+ );
1048
1041
  for (const [name, port] of servicePorts()) {
1049
1042
  const ok = await checkHttp(port);
1050
- console.log(`${name.padEnd(13)} ${ok ? 'up' : 'down'} http://127.0.0.1:${port}`);
1043
+ const serviceState = serviceStateByName.get(name);
1044
+ const detail = serviceState?.status && serviceState.status !== 'running'
1045
+ ? ` (${serviceState.status}${serviceState.reason ? `: ${serviceState.reason}` : ''})`
1046
+ : '';
1047
+ console.log(`${name.padEnd(13)} ${ok ? 'up' : 'down'} http://127.0.0.1:${port}${detail}`);
1051
1048
  }
1052
1049
  }
1053
1050
 
@@ -1120,7 +1117,7 @@ async function doctor() {
1120
1117
  add('daemon', daemonRunning, daemonRunning ? `pid ${pid}` : 'stopped', daemonRunning ? 'ok' : 'warn');
1121
1118
  const runtimeChildren = runtimeServiceProcesses();
1122
1119
  const runtimeSupervisors = supervisorProcesses();
1123
- const expectedServices = daemonRunning ? 5 : 0;
1120
+ const expectedServices = daemonRunning ? runtimeServiceNames().length : 0;
1124
1121
  const cleanProcessShape = runtimeSupervisors.length <= 1 && runtimeChildren.length <= expectedServices;
1125
1122
  add(
1126
1123
  'runtime processes',
@@ -1136,15 +1133,25 @@ async function doctor() {
1136
1133
  'warn',
1137
1134
  );
1138
1135
  }
1136
+ const runtimeServiceState = Array.isArray(readJson(RUNTIME_STATE_FILE, null)?.services)
1137
+ ? readJson(RUNTIME_STATE_FILE, null).services
1138
+ : [];
1139
+ const unhealthyState = runtimeServiceState.filter((service) => (
1140
+ service.status && !['running', 'starting'].includes(service.status)
1141
+ ));
1142
+ if (runtimeServiceState.length > 0) {
1143
+ add(
1144
+ 'service states',
1145
+ unhealthyState.length === 0,
1146
+ unhealthyState.length === 0
1147
+ ? `${runtimeServiceState.length} declared service(s) running/starting`
1148
+ : unhealthyState.map((service) => `${service.name}:${service.status}`).join(', '),
1149
+ unhealthyState.length === 0 ? 'ok' : 'warn',
1150
+ );
1151
+ }
1139
1152
 
1140
1153
  for (const [name, port] of servicePorts()) {
1141
- const envName = {
1142
- 'local-gateway': 'AMALGM_GATEWAY_PORT',
1143
- 'port-monitor': 'PORT_MONITOR_PORT',
1144
- 'fs-watcher': 'FS_WATCHER_PORT',
1145
- 'amalgm-mcp': 'AMALGM_MCP_PORT',
1146
- 'chat-server': 'CHAT_SERVER_PORT',
1147
- }[name];
1154
+ const envName = runtimeServices().find((service) => service.name === name)?.envName;
1148
1155
  const ok = daemonRunning ? await checkHttp(port) : (!process.env[envName] || await isPortFree(port));
1149
1156
  add(
1150
1157
  `${name} port`,
@@ -9,23 +9,17 @@ const {
9
9
  PACKAGE_ROOT,
10
10
  RUNTIME_DIR,
11
11
  } = require('./paths');
12
+ const {
13
+ runtimeServiceScripts,
14
+ } = require('./runtime-manifest');
12
15
 
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
- ];
16
+ const RUNTIME_SERVICE_MARKERS = runtimeServiceScripts()
17
+ .map((script) => path.join(RUNTIME_DIR, script).replace(/\\/g, '/'));
18
+ const RUNTIME_SERVICE_SUFFIXES = runtimeServiceScripts()
19
+ .map((script) => `runtime/${script}`.replace(/\\/g, '/'));
27
20
 
28
21
  const SUPERVISOR_MARKER = path.join(PACKAGE_ROOT, 'bin', 'amalgm.js').replace(/\\/g, '/');
22
+ const SUPERVISOR_SUFFIX = '/bin/amalgm.js';
29
23
 
30
24
  function isPidRunning(pid) {
31
25
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -67,6 +61,19 @@ function readProcCommand(pid) {
67
61
  }
68
62
  }
69
63
 
64
+ function readProcPpid(pid) {
65
+ try {
66
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
67
+ const lastParen = stat.lastIndexOf(')');
68
+ if (lastParen === -1) return null;
69
+ const parts = stat.slice(lastParen + 2).trim().split(/\s+/);
70
+ const ppid = Number(parts[1]);
71
+ return Number.isInteger(ppid) ? ppid : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
70
77
  function readProcEnv(pid) {
71
78
  const raw = readProcString(`/proc/${pid}/environ`);
72
79
  if (!raw) return null;
@@ -88,6 +95,7 @@ function listProcProcesses() {
88
95
  .filter((pid) => Number.isInteger(pid) && pid > 0)
89
96
  .map((pid) => ({
90
97
  pid,
98
+ ppid: readProcPpid(pid),
91
99
  command: readProcCommand(pid),
92
100
  env: readProcEnv(pid),
93
101
  }))
@@ -99,7 +107,7 @@ function listProcProcesses() {
99
107
 
100
108
  function listPsProcesses() {
101
109
  try {
102
- const result = spawnSync('ps', ['-eo', 'pid=,command='], {
110
+ const result = spawnSync('ps', ['-eo', 'pid=,ppid=,command='], {
103
111
  encoding: 'utf8',
104
112
  timeout: 2000,
105
113
  });
@@ -107,11 +115,12 @@ function listPsProcesses() {
107
115
  return String(result.stdout || '')
108
116
  .split('\n')
109
117
  .map((line) => {
110
- const match = line.trim().match(/^(\d+)\s+(.+)$/);
118
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
111
119
  if (!match) return null;
112
120
  return {
113
121
  pid: Number(match[1]),
114
- command: match[2],
122
+ ppid: Number(match[2]),
123
+ command: match[3],
115
124
  env: null,
116
125
  };
117
126
  })
@@ -122,7 +131,12 @@ function listPsProcesses() {
122
131
  }
123
132
 
124
133
  function listProcesses() {
125
- return listProcProcesses() || listPsProcesses();
134
+ const processes = listProcProcesses() || listPsProcesses();
135
+ const commandByPid = new Map(processes.map((item) => [item.pid, item.command]));
136
+ return processes.map((item) => ({
137
+ ...item,
138
+ parentCommand: commandByPid.get(item.ppid) || readProcCommand(item.ppid),
139
+ }));
126
140
  }
127
141
 
128
142
  function normalizedCommand(command) {
@@ -134,10 +148,14 @@ function envMatchesAmalgmDir(env) {
134
148
  return path.resolve(env.AMALGM_DIR) === path.resolve(AMALGM_DIR);
135
149
  }
136
150
 
137
- function commandLooksLikeRuntimeService(command) {
151
+ function commandLooksLikeRuntimeService(command, item = null, options = {}) {
138
152
  const normalized = normalizedCommand(command);
139
- return RUNTIME_SERVICE_MARKERS.some((marker) => normalized.includes(marker))
140
- || RUNTIME_SERVICE_SUFFIXES.some((marker) => normalized.includes(marker));
153
+ if (RUNTIME_SERVICE_MARKERS.some((marker) => normalized.includes(marker))) return true;
154
+ if (!RUNTIME_SERVICE_SUFFIXES.some((marker) => normalized.includes(marker))) return false;
155
+ if (item?.env && envMatchesAmalgmDir(item.env)) return true;
156
+ if (!options.includeOrphanedForeign) return false;
157
+ const parent = normalizedCommand(item?.parentCommand || '');
158
+ return !commandLooksLikeAnySupervisor(parent);
141
159
  }
142
160
 
143
161
  function commandLooksLikeSupervisor(command) {
@@ -145,11 +163,16 @@ function commandLooksLikeSupervisor(command) {
145
163
  return normalized.includes(SUPERVISOR_MARKER) && /\brun\b/.test(normalized);
146
164
  }
147
165
 
166
+ function commandLooksLikeAnySupervisor(command) {
167
+ const normalized = normalizedCommand(command);
168
+ return normalized.includes(SUPERVISOR_SUFFIX) && /\brun\b/.test(normalized);
169
+ }
170
+
148
171
  function runtimeServiceProcesses(options = {}) {
149
172
  const exclude = new Set([process.pid, ...(options.excludePids || [])].filter(Number.isInteger));
150
173
  return listProcesses()
151
174
  .filter((item) => !exclude.has(item.pid))
152
- .filter((item) => commandLooksLikeRuntimeService(item.command))
175
+ .filter((item) => commandLooksLikeRuntimeService(item.command, item, options))
153
176
  .filter((item) => envMatchesAmalgmDir(item.env));
154
177
  }
155
178
 
@@ -197,7 +220,10 @@ function terminateProcesses(processes, options = {}) {
197
220
  }
198
221
 
199
222
  function cleanupStaleRuntimeProcesses(options = {}) {
200
- const stale = runtimeServiceProcesses(options);
223
+ const stale = runtimeServiceProcesses({
224
+ includeOrphanedForeign: true,
225
+ ...options,
226
+ });
201
227
  const supervisors = options.includeSupervisors ? supervisorProcesses(options) : [];
202
228
  return terminateProcesses([...stale, ...supervisors], options);
203
229
  }
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ const RUNTIME_SERVICES = [
4
+ {
5
+ name: 'local-gateway',
6
+ script: 'scripts/local-gateway.js',
7
+ portKey: 'gateway',
8
+ envName: 'AMALGM_GATEWAY_PORT',
9
+ stateKey: 'gateway',
10
+ defaultPort: 28781,
11
+ launchOrder: 50,
12
+ },
13
+ {
14
+ name: 'port-monitor',
15
+ script: 'scripts/port-monitor.js',
16
+ portKey: 'portMonitor',
17
+ envName: 'PORT_MONITOR_PORT',
18
+ stateKey: 'port_monitor',
19
+ defaultPort: 8081,
20
+ launchOrder: 10,
21
+ },
22
+ {
23
+ name: 'fs-watcher',
24
+ script: 'scripts/fs-watcher.js',
25
+ portKey: 'fsWatcher',
26
+ envName: 'FS_WATCHER_PORT',
27
+ stateKey: 'fs_watcher',
28
+ defaultPort: 8082,
29
+ launchOrder: 20,
30
+ },
31
+ {
32
+ name: 'amalgm-mcp',
33
+ script: 'scripts/amalgm-mcp/index.js',
34
+ portKey: 'amalgmMcp',
35
+ envName: 'AMALGM_MCP_PORT',
36
+ stateKey: 'amalgm_mcp',
37
+ defaultPort: 8083,
38
+ launchOrder: 40,
39
+ },
40
+ {
41
+ name: 'chat-server',
42
+ script: 'scripts/chat-server.js',
43
+ portKey: 'chatServer',
44
+ envName: 'CHAT_SERVER_PORT',
45
+ stateKey: 'chat_server',
46
+ defaultPort: 8084,
47
+ launchOrder: 30,
48
+ },
49
+ ];
50
+
51
+ function runtimeServices() {
52
+ return RUNTIME_SERVICES.map((service) => ({ ...service }));
53
+ }
54
+
55
+ function runtimeLaunchServices() {
56
+ return runtimeServices().sort((left, right) => left.launchOrder - right.launchOrder);
57
+ }
58
+
59
+ function runtimeServiceNames() {
60
+ return RUNTIME_SERVICES.map((service) => service.name);
61
+ }
62
+
63
+ function runtimeServiceScripts() {
64
+ return RUNTIME_SERVICES.map((service) => service.script);
65
+ }
66
+
67
+ function runtimeServiceByName(name) {
68
+ return RUNTIME_SERVICES.find((service) => service.name === name) || null;
69
+ }
70
+
71
+ function runtimePortsFromState(state) {
72
+ const statePorts = state?.ports || {};
73
+ const values = {};
74
+ for (const service of RUNTIME_SERVICES) {
75
+ if (service.stateKey === 'gateway' && Number.isInteger(state?.gateway_port)) {
76
+ values[service.name] = Number(state.gateway_port);
77
+ continue;
78
+ }
79
+ const port = Number(statePorts[service.stateKey] || 0);
80
+ if (Number.isInteger(port) && port > 0) values[service.name] = port;
81
+ }
82
+ return values;
83
+ }
84
+
85
+ function runtimePortsFromEnv(env = process.env) {
86
+ const values = {};
87
+ for (const service of RUNTIME_SERVICES) {
88
+ const port = Number(env[service.envName] || service.defaultPort);
89
+ if (Number.isInteger(port) && port > 0) values[service.name] = port;
90
+ }
91
+ return values;
92
+ }
93
+
94
+ function runtimePortsForStatus(state, env = process.env) {
95
+ const fromState = runtimePortsFromState(state);
96
+ const fromEnv = runtimePortsFromEnv(env);
97
+ return RUNTIME_SERVICES
98
+ .map((service) => [service.name, fromState[service.name] || fromEnv[service.name]])
99
+ .filter(([, port]) => Number.isInteger(port) && port > 0);
100
+ }
101
+
102
+ function runtimePortsState(ports) {
103
+ const statePorts = {};
104
+ for (const service of RUNTIME_SERVICES) {
105
+ const port = Number(ports?.[service.portKey] || 0);
106
+ if (!Number.isInteger(port) || port <= 0) continue;
107
+ if (service.stateKey !== 'gateway') statePorts[service.stateKey] = port;
108
+ }
109
+ return {
110
+ gateway_port: Number(ports?.gateway || 0) || undefined,
111
+ gateway_url: ports?.gateway ? `http://127.0.0.1:${ports.gateway}` : undefined,
112
+ ports: statePorts,
113
+ services: RUNTIME_SERVICES.map((service) => ({
114
+ name: service.name,
115
+ port: Number(ports?.[service.portKey] || 0) || null,
116
+ health_path: '/healthz',
117
+ })),
118
+ };
119
+ }
120
+
121
+ module.exports = {
122
+ runtimeLaunchServices,
123
+ runtimePortsForStatus,
124
+ runtimePortsFromEnv,
125
+ runtimePortsFromState,
126
+ runtimePortsState,
127
+ runtimeServiceByName,
128
+ runtimeServiceNames,
129
+ runtimeServiceScripts,
130
+ runtimeServices,
131
+ };
package/lib/service.js CHANGED
@@ -292,6 +292,8 @@ function writePortableNodeScript(localOnly) {
292
292
  daemonLog: path.join(LOG_DIR, 'daemon.log'),
293
293
  serviceLog: SERVICE_LOG_FILE,
294
294
  restartDelayMs: Math.max(1000, Number(process.env.AMALGM_SERVICE_RESTART_DELAY || 5) * 1000),
295
+ maxRestartDelayMs: Math.max(5000, Number(process.env.AMALGM_SERVICE_MAX_RESTART_DELAY || 60) * 1000),
296
+ stableAfterMs: Math.max(10000, Number(process.env.AMALGM_SERVICE_STABLE_AFTER || 60) * 1000),
295
297
  env: buildServiceEnv(localOnly),
296
298
  };
297
299
  const source = `'use strict';
@@ -307,6 +309,8 @@ const config = ${JSON.stringify(config, null, 2)};
307
309
  let child = null;
308
310
  let stopping = false;
309
311
  let exiting = false;
312
+ let restartDelayMs = config.restartDelayMs;
313
+ let childStartedAt = 0;
310
314
 
311
315
  function ensureDir(dir) {
312
316
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -418,6 +422,7 @@ function launch() {
418
422
  });
419
423
  append(config.serviceLog, 'starting amalgm run');
420
424
  const fd = fs.openSync(config.daemonLog, 'a');
425
+ childStartedAt = Date.now();
421
426
  child = spawn(config.nodeBin, [config.amalgmBin, 'run'], {
422
427
  cwd: process.env.HOME || process.cwd(),
423
428
  env: { ...process.env, ...config.env },
@@ -426,10 +431,15 @@ function launch() {
426
431
  });
427
432
  try { fs.closeSync(fd); } catch {}
428
433
  child.on('exit', (code, signal) => {
434
+ const runtimeMs = Date.now() - childStartedAt;
429
435
  append(config.serviceLog, \`amalgm run exited code=\${code ?? ''} signal=\${signal ?? ''}\`);
430
436
  child = null;
431
437
  if (stopping || fs.existsSync(config.stopFile)) return cleanup(0);
432
- setTimeout(launch, config.restartDelayMs);
438
+ if (runtimeMs >= config.stableAfterMs) restartDelayMs = config.restartDelayMs;
439
+ const delay = restartDelayMs;
440
+ restartDelayMs = Math.min(restartDelayMs * 2, config.maxRestartDelayMs);
441
+ append(config.serviceLog, \`restarting amalgm run in \${delay}ms\`);
442
+ setTimeout(launch, delay);
433
443
  });
434
444
  }
435
445
 
package/lib/supervisor.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
+ const http = require('http');
5
6
  const os = require('os');
6
7
  const path = require('path');
7
8
  const { spawn, spawnSync } = require('child_process');
@@ -31,8 +32,28 @@ const {
31
32
  isPidRunning,
32
33
  supervisorProcesses,
33
34
  } = require('./process-cleanup');
35
+ const {
36
+ runtimeLaunchServices,
37
+ runtimePortsState,
38
+ runtimeServiceScripts,
39
+ } = require('./runtime-manifest');
34
40
  const PACKAGE_VERSION = require('../package.json').version;
35
41
 
42
+ const CHILD_RESTART_POLICY = {
43
+ initialDelayMs: 1000,
44
+ maxDelayMs: 30000,
45
+ stableAfterMs: 30000,
46
+ maxRestarts: 8,
47
+ windowMs: 60000,
48
+ };
49
+
50
+ const CHILD_HEALTH_POLICY = {
51
+ graceMs: 8000,
52
+ intervalMs: 10000,
53
+ timeoutMs: 2500,
54
+ maxFailures: 3,
55
+ };
56
+
36
57
  function ensureDir(dir, mode = 0o700) {
37
58
  fs.mkdirSync(dir, { recursive: true, mode });
38
59
  try {
@@ -103,13 +124,7 @@ function runtimeEntry(relativePath) {
103
124
  }
104
125
 
105
126
  function assertRuntimePresent() {
106
- const required = [
107
- runtimeEntry('scripts/amalgm-mcp/index.js'),
108
- runtimeEntry('scripts/chat-server.js'),
109
- runtimeEntry('scripts/fs-watcher.js'),
110
- runtimeEntry('scripts/local-gateway.js'),
111
- runtimeEntry('scripts/port-monitor.js'),
112
- ];
127
+ const required = runtimeServiceScripts().map((script) => runtimeEntry(script));
113
128
  const missing = required.filter((file) => !fs.existsSync(file));
114
129
  if (missing.length > 0) {
115
130
  throw new Error(
@@ -177,7 +192,7 @@ function ensureRuntimeToken() {
177
192
  return token;
178
193
  }
179
194
 
180
- function baseRuntimeEnv(record, ports) {
195
+ function baseRuntimeEnv(record, ports, options = {}) {
181
196
  const workspaceRoot =
182
197
  process.env.AMALGM_WORKSPACES_DIR ||
183
198
  process.env.AMALGM_PROJECTS_DIR ||
@@ -185,11 +200,13 @@ function baseRuntimeEnv(record, ports) {
185
200
  ensureDir(workspaceRoot);
186
201
  const defaultCwd = process.env.AMALGM_DEFAULT_CWD || workspaceRoot;
187
202
  const proxyToken = proxyTokenFromRecord(record);
203
+ const localOnly = !!options.localOnly || process.env.AMALGM_LOCAL_ONLY === 'true';
188
204
  const env = {
189
205
  ...safeBaseProcessEnv(),
190
206
  AMALGM_RUNTIME_SOURCE: 'npm',
191
207
  AMALGM_RUNTIME_TOKEN: record?.runtime_token || process.env.AMALGM_RUNTIME_TOKEN || '',
192
208
  AMALGM_LOCAL_MODE: 'true',
209
+ AMALGM_LOCAL_ONLY: localOnly ? 'true' : 'false',
193
210
  AMALGM_BIND_HOST: process.env.AMALGM_BIND_HOST || '127.0.0.1',
194
211
  AMALGM_AUTH_BACKUP_ENABLED: process.env.AMALGM_AUTH_BACKUP_ENABLED || 'false',
195
212
  AMALGM_CREATE_AUTH_WATCH_DIRS: process.env.AMALGM_CREATE_AUTH_WATCH_DIRS || 'false',
@@ -221,40 +238,16 @@ function baseRuntimeEnv(record, ports) {
221
238
  return env;
222
239
  }
223
240
 
224
- function serviceSpecs(record, ports) {
225
- const env = baseRuntimeEnv(record, ports);
226
- return [
227
- {
228
- name: 'port-monitor',
229
- command: process.execPath,
230
- args: [runtimeEntry('scripts/port-monitor.js')],
231
- env: { ...env, PORT: process.env.PORT || '0' },
232
- },
233
- {
234
- name: 'fs-watcher',
235
- command: process.execPath,
236
- args: [runtimeEntry('scripts/fs-watcher.js')],
237
- env,
238
- },
239
- {
240
- name: 'chat-server',
241
- command: process.execPath,
242
- args: [runtimeEntry('scripts/chat-server.js')],
243
- env,
244
- },
245
- {
246
- name: 'amalgm-mcp',
247
- command: process.execPath,
248
- args: [runtimeEntry('scripts/amalgm-mcp/index.js')],
249
- env,
250
- },
251
- {
252
- name: 'local-gateway',
253
- command: process.execPath,
254
- args: [runtimeEntry('scripts/local-gateway.js')],
255
- env,
256
- },
257
- ];
241
+ function serviceSpecs(record, ports, options = {}) {
242
+ const env = baseRuntimeEnv(record, ports, options);
243
+ return runtimeLaunchServices().map((service) => ({
244
+ name: service.name,
245
+ command: process.execPath,
246
+ args: [runtimeEntry(service.script)],
247
+ env: service.name === 'port-monitor' ? { ...env, PORT: process.env.PORT || '0' } : env,
248
+ port: ports[service.portKey],
249
+ healthPath: '/healthz',
250
+ }));
258
251
  }
259
252
 
260
253
  function isPortFree(port, host = '127.0.0.1') {
@@ -319,13 +312,11 @@ async function pickPort(name, envName, preferred, used) {
319
312
 
320
313
  async function resolveRuntimePorts() {
321
314
  const used = new Set();
322
- return {
323
- gateway: await pickPort('local-gateway', 'AMALGM_GATEWAY_PORT', 28781, used),
324
- portMonitor: await pickPort('port-monitor', 'PORT_MONITOR_PORT', 8081, used),
325
- fsWatcher: await pickPort('fs-watcher', 'FS_WATCHER_PORT', 8082, used),
326
- amalgmMcp: await pickPort('amalgm-mcp', 'AMALGM_MCP_PORT', 8083, used),
327
- chatServer: await pickPort('chat-server', 'CHAT_SERVER_PORT', 8084, used),
328
- };
315
+ const ports = {};
316
+ for (const service of runtimeLaunchServices()) {
317
+ ports[service.portKey] = await pickPort(service.name, service.envName, service.defaultPort, used);
318
+ }
319
+ return ports;
329
320
  }
330
321
 
331
322
  function openServiceLog(name) {
@@ -348,14 +339,150 @@ function writePrefixed(stream, name, chunk, consoleStream) {
348
339
  }
349
340
  }
350
341
 
342
+ function updateRuntimeState(update) {
343
+ const current = readJson(RUNTIME_STATE_FILE, {});
344
+ if (!current || (current.pid !== process.pid && current.supervisor_pid !== process.pid)) return;
345
+ writeJsonSecret(RUNTIME_STATE_FILE, {
346
+ ...current,
347
+ ...update,
348
+ updated_at: new Date().toISOString(),
349
+ });
350
+ }
351
+
352
+ function appendServiceState(name, update) {
353
+ const current = readJson(RUNTIME_STATE_FILE, {});
354
+ if (!current || (current.pid !== process.pid && current.supervisor_pid !== process.pid)) return;
355
+ const services = Array.isArray(current.services) ? current.services : [];
356
+ const next = services.map((service) => (
357
+ service.name === name ? { ...service, ...update } : service
358
+ ));
359
+ updateRuntimeState({ services: next });
360
+ }
361
+
362
+ function checkServiceHealth(spec, timeoutMs = CHILD_HEALTH_POLICY.timeoutMs) {
363
+ if (!spec.port) return Promise.resolve(true);
364
+ return new Promise((resolve) => {
365
+ const req = http.request(
366
+ {
367
+ host: '127.0.0.1',
368
+ port: spec.port,
369
+ path: spec.healthPath || '/healthz',
370
+ method: 'GET',
371
+ timeout: timeoutMs,
372
+ },
373
+ (res) => {
374
+ res.resume();
375
+ res.on('end', () => resolve(res.statusCode >= 200 && res.statusCode < 500));
376
+ },
377
+ );
378
+ req.on('timeout', () => {
379
+ req.destroy();
380
+ resolve(false);
381
+ });
382
+ req.on('error', () => resolve(false));
383
+ req.end();
384
+ });
385
+ }
386
+
351
387
  function createManagedProcess(spec, options) {
352
388
  const { stream } = openServiceLog(spec.name);
353
389
  let child = null;
354
390
  let stopped = false;
355
391
  let restartTimer = null;
392
+ let healthTimer = null;
393
+ let healthFailures = 0;
394
+ let startedAt = 0;
395
+ let restartDelayMs = CHILD_RESTART_POLICY.initialDelayMs;
396
+ let restartHistory = [];
397
+ let state = 'starting';
398
+
399
+ const setState = (nextState, extra = {}) => {
400
+ state = nextState;
401
+ appendServiceState(spec.name, {
402
+ status: state,
403
+ pid: child?.pid || null,
404
+ restart_count_window: restartHistory.length,
405
+ ...extra,
406
+ });
407
+ };
408
+
409
+ const clearHealthTimer = () => {
410
+ if (healthTimer) clearTimeout(healthTimer);
411
+ healthTimer = null;
412
+ };
413
+
414
+ const scheduleHealthCheck = () => {
415
+ clearHealthTimer();
416
+ if (stopped) return;
417
+ healthTimer = setTimeout(async () => {
418
+ if (stopped || !child) return;
419
+ if (Date.now() - startedAt < CHILD_HEALTH_POLICY.graceMs) {
420
+ scheduleHealthCheck();
421
+ return;
422
+ }
423
+ const ok = await checkServiceHealth(spec);
424
+ if (stopped || !child) return;
425
+ if (ok) {
426
+ healthFailures = 0;
427
+ if (state !== 'running') setState('running');
428
+ } else {
429
+ healthFailures += 1;
430
+ writePrefixed(
431
+ stream,
432
+ spec.name,
433
+ `health check failed (${healthFailures}/${CHILD_HEALTH_POLICY.maxFailures})\n`,
434
+ options.foreground ? process.stderr : null,
435
+ );
436
+ if (healthFailures >= CHILD_HEALTH_POLICY.maxFailures) {
437
+ setState('restarting', { reason: 'health_check_failed' });
438
+ try {
439
+ child.kill('SIGTERM');
440
+ } catch {
441
+ // noop
442
+ }
443
+ const pid = child.pid;
444
+ setTimeout(() => {
445
+ if (child && child.pid === pid) {
446
+ try {
447
+ child.kill('SIGKILL');
448
+ } catch {
449
+ // noop
450
+ }
451
+ }
452
+ }, 5000);
453
+ return;
454
+ }
455
+ }
456
+ scheduleHealthCheck();
457
+ }, CHILD_HEALTH_POLICY.intervalMs);
458
+ };
459
+
460
+ const canRestart = () => {
461
+ const now = Date.now();
462
+ restartHistory = restartHistory.filter((time) => now - time <= CHILD_RESTART_POLICY.windowMs);
463
+ if (restartHistory.length >= CHILD_RESTART_POLICY.maxRestarts) {
464
+ setState('degraded', {
465
+ reason: 'restart_limit_exceeded',
466
+ next_retry_at: null,
467
+ });
468
+ writePrefixed(
469
+ stream,
470
+ spec.name,
471
+ `entered degraded state after ${restartHistory.length} restarts in ${CHILD_RESTART_POLICY.windowMs}ms\n`,
472
+ options.foreground ? process.stderr : null,
473
+ );
474
+ return false;
475
+ }
476
+ restartHistory.push(now);
477
+ return true;
478
+ };
356
479
 
357
480
  const launch = () => {
358
481
  if (stopped) return;
482
+ clearHealthTimer();
483
+ healthFailures = 0;
484
+ startedAt = Date.now();
485
+ setState('starting');
359
486
  child = spawn(spec.command, spec.args, {
360
487
  cwd: RUNTIME_DIR,
361
488
  env: spec.env,
@@ -369,6 +496,8 @@ function createManagedProcess(spec, options) {
369
496
  `started pid=${child.pid} command=${[spec.command, ...spec.args].join(' ')}\n`,
370
497
  options.foreground ? process.stdout : null,
371
498
  );
499
+ setState('running', { started_at: new Date(startedAt).toISOString() });
500
+ scheduleHealthCheck();
372
501
 
373
502
  child.stdout.on('data', (chunk) => {
374
503
  writePrefixed(stream, spec.name, chunk, options.foreground ? process.stdout : null);
@@ -377,15 +506,35 @@ function createManagedProcess(spec, options) {
377
506
  writePrefixed(stream, spec.name, chunk, options.foreground ? process.stderr : null);
378
507
  });
379
508
  child.on('exit', (code, signal) => {
509
+ clearHealthTimer();
380
510
  writePrefixed(
381
511
  stream,
382
512
  spec.name,
383
513
  `exited code=${code ?? ''} signal=${signal ?? ''}\n`,
384
514
  options.foreground ? process.stderr : null,
385
515
  );
516
+ const runtimeMs = Date.now() - startedAt;
386
517
  child = null;
387
518
  if (!stopped) {
388
- restartTimer = setTimeout(launch, 2000);
519
+ if (runtimeMs >= CHILD_RESTART_POLICY.stableAfterMs) {
520
+ restartDelayMs = CHILD_RESTART_POLICY.initialDelayMs;
521
+ restartHistory = [];
522
+ }
523
+ if (!canRestart()) return;
524
+ const delay = restartDelayMs;
525
+ restartDelayMs = Math.min(restartDelayMs * 2, CHILD_RESTART_POLICY.maxDelayMs);
526
+ setState('restarting', {
527
+ exit_code: code ?? null,
528
+ exit_signal: signal || null,
529
+ next_retry_at: new Date(Date.now() + delay).toISOString(),
530
+ });
531
+ writePrefixed(
532
+ stream,
533
+ spec.name,
534
+ `restart scheduled in ${delay}ms\n`,
535
+ options.foreground ? process.stderr : null,
536
+ );
537
+ restartTimer = setTimeout(launch, delay);
389
538
  }
390
539
  });
391
540
  };
@@ -396,6 +545,8 @@ function createManagedProcess(spec, options) {
396
545
  stop() {
397
546
  stopped = true;
398
547
  if (restartTimer) clearTimeout(restartTimer);
548
+ clearHealthTimer();
549
+ setState('stopping');
399
550
  if (child && !child.killed) {
400
551
  try {
401
552
  child.kill('SIGTERM');
@@ -465,6 +616,7 @@ async function startSupervisor(options = {}) {
465
616
  device_id: record?.device_id || null,
466
617
  event_ref: record?.event_ref || null,
467
618
  local_only: !!options.localOnly,
619
+ services: [],
468
620
  started_at: new Date().toISOString(),
469
621
  });
470
622
  if (!storedRecord?.computer_id && options.foreground) {
@@ -479,7 +631,8 @@ async function startSupervisor(options = {}) {
479
631
  FS_WATCHER_PORT: String(ports.fsWatcher),
480
632
  PORT_MONITOR_PORT: String(ports.portMonitor),
481
633
  });
482
- const managed = serviceSpecs(record, ports).map((spec) => createManagedProcess(spec, options));
634
+ updateRuntimeState(runtimePortsState(ports));
635
+ const managed = serviceSpecs(record, ports, options).map((spec) => createManagedProcess(spec, options));
483
636
  const tunnels = [];
484
637
 
485
638
  if (!options.localOnly && record?.tunnel_url && record?.tunnel_token) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
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/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"
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/runtime-manifest.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"
@@ -293,11 +293,14 @@ function ensurePtySpawnHelperExecutable() {
293
293
 
294
294
  function writeRuntimeState(actualPort) {
295
295
  const computer = readJson(path.join(AMALGM_DIR, 'computer.json'), null);
296
+ const previous = readJson(STATE_FILE, {});
296
297
  writeJsonSecret(STATE_FILE, {
298
+ ...previous,
297
299
  schema_version: 1,
298
300
  owner: OWNER,
299
301
  source: OWNER,
300
302
  pid: process.pid,
303
+ gateway_pid: process.pid,
301
304
  supervisor_pid: process.ppid,
302
305
  version: VERSION,
303
306
  package_version: VERSION,
@@ -315,6 +318,7 @@ function writeRuntimeState(actualPort) {
315
318
  },
316
319
  computer_id: computer?.computer_id || process.env.AMALGM_COMPUTER_ID || '',
317
320
  user_id: computer?.user_id || process.env.AMALGM_USER_ID || '',
321
+ services: Array.isArray(previous.services) ? previous.services : [],
318
322
  updated_at: new Date().toISOString(),
319
323
  });
320
324
  }