amalgm 0.1.138 → 0.1.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/amalgm.js +73 -0
  2. package/lib/auth-store.js +16 -2
  3. package/lib/cli.js +48 -7
  4. package/lib/identity-adopt.js +43 -0
  5. package/lib/layout.js +17 -10
  6. package/lib/migrate-layout.js +1 -1
  7. package/lib/state-migration.js +23 -1
  8. package/package.json +2 -2
  9. package/runtime/scripts/amalgm-mcp/agents/store.js +8 -2
  10. package/runtime/scripts/amalgm-mcp/apps/supervisor.js +116 -11
  11. package/runtime/scripts/amalgm-mcp/browser/backend.js +5 -2
  12. package/runtime/scripts/amalgm-mcp/browser/cli.js +2 -1
  13. package/runtime/scripts/amalgm-mcp/config.js +26 -5
  14. package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +119 -53
  15. package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +2 -2
  16. package/runtime/scripts/amalgm-mcp/events/npm-release-runner.js +4 -2
  17. package/runtime/scripts/amalgm-mcp/events/pr-check-runner.js +4 -2
  18. package/runtime/scripts/amalgm-mcp/fs/rest.js +20 -1
  19. package/runtime/scripts/amalgm-mcp/lib/layout.js +17 -10
  20. package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +2 -2
  21. package/runtime/scripts/amalgm-mcp/project-context/store.js +27 -5
  22. package/runtime/scripts/amalgm-mcp/tests/agents-source-of-truth.test.js +13 -0
  23. package/runtime/scripts/amalgm-mcp/tests/apps-supervisor.test.js +190 -0
  24. package/runtime/scripts/amalgm-mcp/tests/browser-routing.test.js +2 -3
  25. package/runtime/scripts/amalgm-mcp/tests/config-migration.test.js +37 -1
  26. package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +51 -0
  27. package/runtime/scripts/amalgm-mcp/tests/files-resource.test.js +25 -0
  28. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +59 -0
  29. package/runtime/scripts/amalgm-mcp/toolbox/runner.js +6 -1
  30. package/runtime/scripts/chat-core/contract.js +2 -2
  31. package/runtime/scripts/chat-core/credentials/store.js +2 -1
  32. package/runtime/scripts/chat-core/recorder.js +2 -1
  33. package/runtime/scripts/chat-core/tooling/native-binaries.js +2 -1
  34. package/runtime/scripts/chat-server/config.js +2 -2
  35. package/runtime/scripts/credential-adapter.js +2 -2
  36. package/runtime/scripts/lib/runtime-paths.js +61 -1
  37. package/runtime/scripts/local-gateway.js +2 -1
  38. package/runtime/scripts/proxy-token-store.js +7 -4
  39. package/runtime/scripts/proxy-token-store.test.js +112 -0
package/bin/amalgm.js CHANGED
@@ -22,6 +22,79 @@ if (label && !process.env.AMALGM_RUNTIME_LABEL) process.env.AMALGM_RUNTIME_LABEL
22
22
  const userScope = takeFlag(process.argv, ['--user', '--user-id', '--runtime-user']);
23
23
  if (userScope && !process.env.AMALGM_RUNTIME_USER_ID) process.env.AMALGM_RUNTIME_USER_ID = userScope;
24
24
 
25
+ function readJson(file, fallback = null) {
26
+ try {
27
+ return JSON.parse(require('fs').readFileSync(file, 'utf8'));
28
+ } catch {
29
+ return fallback;
30
+ }
31
+ }
32
+
33
+ function cleanString(value) {
34
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
35
+ }
36
+
37
+ function userEmail(record) {
38
+ return cleanString(record?.user_email || record?.userEmail || record?.email).toLowerCase();
39
+ }
40
+
41
+ function identityFromDir(dir) {
42
+ const path = require('path');
43
+ const record = readJson(path.join(dir, 'computer.json'), null) || {};
44
+ const auth = readJson(path.join(dir, 'auth.json'), null) || {};
45
+ const userId = cleanString(record.user_id) || cleanString(auth.user_id);
46
+ const email = userEmail(record) || userEmail(auth);
47
+ return userId && email ? { userId, email } : null;
48
+ }
49
+
50
+ function preflightCanonicalLayout() {
51
+ if (process.env.AMALGM_HOME || process.env.AMALGM_DIR) return;
52
+ const fs = require('fs');
53
+ const os = require('os');
54
+ const path = require('path');
55
+ const layout = require('../lib/layout');
56
+
57
+ try {
58
+ require('../lib/migrate-layout').migrateLayout({ home: os.homedir() });
59
+ } catch (error) {
60
+ const message = error && error.message ? error.message : String(error);
61
+ console.warn(`[layout] Could not migrate Amalgm root automatically: ${message}`);
62
+ }
63
+
64
+ const homeDir = layout.resolveAmalgmHome(process.env);
65
+ const identities = [];
66
+ const rootIdentity = identityFromDir(homeDir);
67
+ if (rootIdentity) identities.push(rootIdentity);
68
+
69
+ for (const entry of layout.listUserEntries(homeDir)) {
70
+ const entryIdentity = identityFromDir(entry.dir);
71
+ if (entryIdentity) identities.push(entryIdentity);
72
+ const runtimesDir = path.join(entry.dir, 'runtimes');
73
+ let runtimeNames = [];
74
+ try {
75
+ runtimeNames = fs.readdirSync(runtimesDir);
76
+ } catch {}
77
+ for (const runtimeName of runtimeNames) {
78
+ const runtimeIdentity = identityFromDir(path.join(runtimesDir, runtimeName));
79
+ if (runtimeIdentity) identities.push(runtimeIdentity);
80
+ }
81
+ }
82
+
83
+ for (const identity of identities) {
84
+ try {
85
+ layout.ensureUserDir(homeDir, {
86
+ userId: identity.userId,
87
+ email: identity.email,
88
+ });
89
+ } catch (error) {
90
+ const message = error && error.message ? error.message : String(error);
91
+ console.warn(`[layout] Could not adopt email folder for ${identity.userId}: ${message}`);
92
+ }
93
+ }
94
+ }
95
+
96
+ preflightCanonicalLayout();
97
+
25
98
  require('../lib/cli').main(process.argv).catch((error) => {
26
99
  const message = error && error.message ? error.message : String(error);
27
100
  console.error(`Error: ${message}`);
package/lib/auth-store.js CHANGED
@@ -67,6 +67,14 @@ function cleanString(value) {
67
67
  return typeof value === 'string' && value.trim() ? value.trim() : '';
68
68
  }
69
69
 
70
+ function normalizeUserEmail(value) {
71
+ return cleanString(value).toLowerCase();
72
+ }
73
+
74
+ function userEmailFromRecord(record) {
75
+ return normalizeUserEmail(record?.user_email || record?.userEmail || record?.email);
76
+ }
77
+
70
78
  function hasComputerSecrets(record) {
71
79
  if (!record || typeof record !== 'object') return false;
72
80
  return Array.from(SECRET_COMPUTER_KEYS).some((key) => cleanString(record[key]));
@@ -77,6 +85,8 @@ function publicComputerRecord(record, authFile = AUTH_FILE) {
77
85
  ...(record || {}),
78
86
  auth_file: path.basename(authFile),
79
87
  };
88
+ const email = userEmailFromRecord(out);
89
+ if (email) out.user_email = email;
80
90
  for (const key of SECRET_COMPUTER_KEYS) delete out[key];
81
91
  return out;
82
92
  }
@@ -89,8 +99,10 @@ function authFromRecord(record, existing = null) {
89
99
  updated_at: now,
90
100
  };
91
101
 
92
- for (const key of ['computer_id', 'device_id', 'user_id', 'app_url']) {
93
- const value = cleanString(record?.[key]);
102
+ for (const key of ['computer_id', 'device_id', 'user_id', 'user_email', 'app_url']) {
103
+ const value = key === 'user_email'
104
+ ? userEmailFromRecord(record)
105
+ : cleanString(record?.[key]);
94
106
  if (value) auth[key] = value;
95
107
  }
96
108
 
@@ -171,6 +183,7 @@ function mergeRecordAuth(record, auth) {
171
183
  merged.proxy_token_refreshed_at = cleanString(auth.proxy?.refreshed_at);
172
184
  }
173
185
  if (!merged.user_id) merged.user_id = cleanString(auth.user_id);
186
+ if (!merged.user_email) merged.user_email = normalizeUserEmail(auth.user_email);
174
187
  if (!merged.app_url) merged.app_url = cleanString(auth.app_url);
175
188
  return merged;
176
189
  }
@@ -310,4 +323,5 @@ module.exports = {
310
323
  RUNTIME_AUTH_FILE,
311
324
  RUNTIME_COMPUTER_FILE,
312
325
  saveComputerRecord,
326
+ userEmailFromRecord,
313
327
  };
package/lib/cli.js CHANGED
@@ -58,6 +58,9 @@ const {
58
58
  proxyTokenNeedsRefresh,
59
59
  saveComputerRecord,
60
60
  } = require('./auth-store');
61
+ const {
62
+ adoptIdentityFromRecord,
63
+ } = require('./identity-adopt');
61
64
  const {
62
65
  agentCommandShimStatus,
63
66
  binaryStatus,
@@ -72,6 +75,7 @@ const {
72
75
 
73
76
  const PACKAGE_VERSION = require('../package.json').version;
74
77
  const SETUP_CODE_LENGTH = 16;
78
+ const EXPLICIT_AMALGM_DIR = Boolean(process.env.AMALGM_DIR);
75
79
 
76
80
  function parseArgs(argv) {
77
81
  const args = argv.slice(2);
@@ -126,7 +130,7 @@ function usage() {
126
130
  ' AMALGM_AUTO_UPDATE Auto-update before start unless set to 0/false/off',
127
131
  ' AMALGM_DISABLE_AUTO_UPDATE Set to 1 to disable startup/background auto-updates',
128
132
  ' AMALGM_DIR Explicit runtime state dir override',
129
- ' AMALGM_HOME Runtime state root (default ~/.amalgm)',
133
+ ' AMALGM_HOME Runtime state root (default ~/amalgm)',
130
134
  ' AMALGM_RUNTIME_LABEL Runtime label: main, preview, or local',
131
135
  ' AMALGM_RUNTIME_USER_ID Runtime user scope',
132
136
  ' AMALGM_PROXY_TOKEN Optional short-lived proxy token override',
@@ -486,15 +490,16 @@ async function registerComputer(appUrl, registrationToken, device, name) {
486
490
  app_version: PACKAGE_VERSION,
487
491
  registered_at: new Date().toISOString(),
488
492
  };
489
- saveComputerRecord(record);
493
+ const saved = saveComputerRecord(record);
494
+ adoptIdentityFromRecord(saved, { logger: console });
490
495
 
491
- console.log(`Registered computer: ${record.computer_id}`);
492
- console.log(`Events URL: ${record.webhook_url}`);
493
- if (!proxyTokenFromRecord(record)) {
496
+ console.log(`Registered computer: ${saved.computer_id}`);
497
+ console.log(`Events URL: ${saved.webhook_url}`);
498
+ if (!proxyTokenFromRecord(saved)) {
494
499
  console.log('HMAC proxy: not minted yet; Amalgm-managed egress will refresh after registration is complete.');
495
500
  }
496
501
  console.log('Run `amalgm start` to connect this machine.');
497
- return record;
502
+ return saved;
498
503
  }
499
504
 
500
505
  async function refreshStoredProxyToken(record) {
@@ -515,12 +520,15 @@ async function refreshStoredProxyToken(record) {
515
520
 
516
521
  const next = {
517
522
  ...record,
523
+ user_email: refreshed.user_email || record.user_email,
518
524
  proxy_token: refreshed.proxy_token || record.proxy_token,
519
525
  proxy_token_expires_in: refreshed.proxy_token_expires_in || record.proxy_token_expires_in,
520
526
  proxy_token_expires_at: refreshed.proxy_token_expires_at || record.proxy_token_expires_at,
521
527
  proxy_token_refreshed_at: new Date().toISOString(),
522
528
  };
523
- return saveComputerRecord(next);
529
+ const saved = saveComputerRecord(next);
530
+ adoptIdentityFromRecord(saved, { logger: console });
531
+ return saved;
524
532
  }
525
533
 
526
534
  async function loginWithSetupCode(appUrl, setupCode, device, name) {
@@ -811,6 +819,31 @@ function relaunchCurrentCommandAfterUpdate() {
811
819
  return Number.isInteger(result.status) ? result.status : 0;
812
820
  }
813
821
 
822
+ function relaunchCurrentCommandAfterLayoutChange() {
823
+ const result = spawnSync(process.execPath, process.argv.slice(1), {
824
+ stdio: 'inherit',
825
+ env: {
826
+ ...process.env,
827
+ AMALGM_LAYOUT_RELAUNCHED: '1',
828
+ },
829
+ });
830
+ if (result.error) throw result.error;
831
+ if (result.signal) {
832
+ process.kill(process.pid, result.signal);
833
+ return 1;
834
+ }
835
+ return Number.isInteger(result.status) ? result.status : 0;
836
+ }
837
+
838
+ function shouldRelaunchForAdoptedDir(adoption) {
839
+ return Boolean(
840
+ adoption?.dir
841
+ && !EXPLICIT_AMALGM_DIR
842
+ && process.env.AMALGM_LAYOUT_RELAUNCHED !== '1'
843
+ && path.resolve(adoption.dir) !== path.resolve(AMALGM_DIR)
844
+ );
845
+ }
846
+
814
847
  async function start(options) {
815
848
  ensureBaseDirs();
816
849
 
@@ -822,6 +855,7 @@ async function start(options) {
822
855
  assertRuntimePresent();
823
856
 
824
857
  let record = loadComputerRecord({ migrate: true });
858
+ let adoption = adoptIdentityFromRecord(record, { logger: console });
825
859
  const localOnly = !!options['local-only'];
826
860
  if (!localOnly && !hasRequiredComputerFields(record)) {
827
861
  throw new Error('This machine is not logged in. Run `amalgm login` to authorize it in your browser, or use `amalgm start --local-only` for local-only development.');
@@ -829,11 +863,18 @@ async function start(options) {
829
863
  if (!localOnly) {
830
864
  try {
831
865
  record = await refreshStoredProxyToken(record);
866
+ adoption = adoptIdentityFromRecord(record, { logger: console });
832
867
  } catch (error) {
833
868
  console.warn(`Could not refresh proxy token before start: ${error.message}`);
834
869
  }
835
870
  }
836
871
 
872
+ if (shouldRelaunchForAdoptedDir(adoption)) {
873
+ console.log(`Adopted Amalgm user folder ${adoption.dir}; restarting start command with canonical paths.`);
874
+ process.exitCode = relaunchCurrentCommandAfterLayoutChange();
875
+ return;
876
+ }
877
+
837
878
  ensureAgentRuntimeCommands({ logger: console });
838
879
 
839
880
  const pid = readPid();
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ AMALGM_HOME,
5
+ } = require('./paths');
6
+ const layout = require('./layout');
7
+
8
+ function cleanString(value) {
9
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
10
+ }
11
+
12
+ function normalizeUserEmail(value) {
13
+ return cleanString(value).toLowerCase();
14
+ }
15
+
16
+ function userEmailFromRecord(record) {
17
+ return normalizeUserEmail(record?.user_email || record?.userEmail || record?.email);
18
+ }
19
+
20
+ function adoptIdentityFromRecord(record, options = {}) {
21
+ const userId = cleanString(record?.user_id);
22
+ const email = userEmailFromRecord(record);
23
+ if (!userId || !email) {
24
+ return { dir: null, adopted: false, reason: !userId ? 'missing-user-id' : 'missing-email' };
25
+ }
26
+
27
+ const homeDir = options.homeDir || AMALGM_HOME;
28
+ const logger = options.logger || console;
29
+ try {
30
+ const dir = layout.ensureUserDir(homeDir, { userId, email });
31
+ return { dir, adopted: true };
32
+ } catch (error) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ logger.warn?.(`[identity] email folder adoption deferred: ${message}`);
35
+ return { dir: null, adopted: false, reason: 'error', error: message };
36
+ }
37
+ }
38
+
39
+ module.exports = {
40
+ adoptIdentityFromRecord,
41
+ normalizeUserEmail,
42
+ userEmailFromRecord,
43
+ };
package/lib/layout.js CHANGED
@@ -16,9 +16,10 @@
16
16
  * Rules:
17
17
  * - The folder name is presentation; `.amalgm-user.json` is identity. If a
18
18
  * folder is renamed, resolution recovers by scanning user manifests.
19
- * - Legacy layouts (hidden ~/.amalgm, uuid-named user folders) keep working:
20
- * resolution falls back to them, and migrate-layout leaves compat symlinks
21
- * at the old locations.
19
+ * - Hidden-root layouts are migrated to the visible root by startup preflight;
20
+ * resolution itself does not silently stay on ~/.amalgm.
21
+ * - UUID-named user folders are compatibility aliases only after email
22
+ * adoption. The runtime identity remains the userId in the manifest.
22
23
  * - Explicit env (AMALGM_HOME / AMALGM_DIR) always wins — isolated runtimes
23
24
  * and tests depend on that.
24
25
  */
@@ -101,16 +102,14 @@ function isAmalgmRoot(dir) {
101
102
  /**
102
103
  * Where Amalgm state lives on this machine.
103
104
  *
104
- * Order: explicit AMALGM_HOME → initialized visible root (~/amalgm with a
105
- * root manifest) migrated hidden alias (~/.amalgm symlink its target)
106
- * legacy hidden root (~/.amalgm directory) visible root for fresh installs.
105
+ * Order: explicit AMALGM_HOME → visible root. A migrated ~/.amalgm symlink is
106
+ * allowed only as a pointer back to the visible root; an old hidden directory
107
+ * is intentionally ignored so new process trees don't keep depending on it.
107
108
  */
108
109
  function resolveAmalgmHome(env = process.env) {
109
110
  if (env.AMALGM_HOME) return path.resolve(env.AMALGM_HOME);
110
111
 
111
112
  const visible = visibleRootPath();
112
- if (isAmalgmRoot(visible)) return visible;
113
-
114
113
  const hidden = hiddenRootPath();
115
114
  const hiddenStat = lstatOrNull(hidden);
116
115
  if (hiddenStat?.isSymbolicLink()) {
@@ -121,7 +120,6 @@ function resolveAmalgmHome(env = process.env) {
121
120
  return visible;
122
121
  }
123
122
  }
124
- if (hiddenStat) return hidden;
125
123
  return visible;
126
124
  }
127
125
 
@@ -186,6 +184,14 @@ function writeUserManifest(userDir, { userId, email }) {
186
184
  return manifest;
187
185
  }
188
186
 
187
+ function createCompatLink(linkPath, targetPath) {
188
+ if (process.platform === 'win32') {
189
+ fs.symlinkSync(targetPath, linkPath, 'junction');
190
+ return;
191
+ }
192
+ fs.symlinkSync(path.basename(targetPath), linkPath);
193
+ }
194
+
189
195
  /** All user folders under <homeDir>/users. Real directories first, compat
190
196
  * symlinks after, both sorted — so scans prefer canonical folders. */
191
197
  function listUserEntries(homeDir) {
@@ -299,7 +305,7 @@ function adoptUserEmail(homeDir, { userId, email }) {
299
305
  }
300
306
 
301
307
  fs.renameSync(current, target);
302
- fs.symlinkSync(folderName, current); // relative sibling link, relocatable
308
+ createCompatLink(current, target);
303
309
  indexUserFolder(homeDir, folderName, { userId: cleanId, email });
304
310
  return { dir: target, renamed: true, from: current };
305
311
  }
@@ -321,6 +327,7 @@ module.exports = {
321
327
  emailFolderName,
322
328
  ensureRootManifest,
323
329
  ensureUserDir,
330
+ createCompatLink,
324
331
  findUserDirByUserId,
325
332
  hiddenRootPath,
326
333
  isAmalgmRoot,
@@ -101,7 +101,7 @@ function migrateRoot({ hiddenRoot, visibleRoot, dryRun, actions }) {
101
101
  actions.push(`symlink ${hiddenRoot} -> ${path.basename(visibleRoot)}`);
102
102
  if (!dryRun) {
103
103
  fs.renameSync(hiddenRoot, visibleRoot);
104
- fs.symlinkSync(path.basename(visibleRoot), hiddenRoot); // relative sibling link
104
+ layout.createCompatLink(hiddenRoot, visibleRoot);
105
105
  }
106
106
  return { moved: true };
107
107
  }
@@ -11,6 +11,8 @@ const {
11
11
 
12
12
  const MARKER_FILE = path.join(AMALGM_DIR, '.user-state-migrated.json');
13
13
  const CLI_HOMES_BACKFILL_ID = 'cli-homes-merge-v1';
14
+ const WORKSPACES_BACKFILL_ID = 'workspaces-merge-v1';
15
+ const CONTEXT_BACKFILL_ID = 'context-merge-v1';
14
16
 
15
17
  const DATA_FILES = [
16
18
  'amalgm.db',
@@ -30,6 +32,7 @@ const DATA_DIRS = [
30
32
  'apps',
31
33
  'browser',
32
34
  'browser-sessions',
35
+ 'context',
33
36
  'contexts',
34
37
  'harness-configs',
35
38
  'memories',
@@ -37,6 +40,7 @@ const DATA_DIRS = [
37
40
  'task-runs',
38
41
  'toolbox',
39
42
  'uploads',
43
+ 'workspaces',
40
44
  ];
41
45
 
42
46
  function readJson(file, fallback = null) {
@@ -132,6 +136,8 @@ function migrateLegacyUserState() {
132
136
  const hadMarker = Boolean(marker);
133
137
  const completedMigrations = markerCompletedMigrations(marker);
134
138
  const needsCliHomesBackfill = !completedMigrations.has(CLI_HOMES_BACKFILL_ID);
139
+ const needsWorkspacesBackfill = !completedMigrations.has(WORKSPACES_BACKFILL_ID);
140
+ const needsContextBackfill = !completedMigrations.has(CONTEXT_BACKFILL_ID);
135
141
 
136
142
  fs.mkdirSync(AMALGM_DIR, { recursive: true, mode: 0o700 });
137
143
 
@@ -157,7 +163,23 @@ function migrateLegacyUserState() {
157
163
  completedMigrations.add(CLI_HOMES_BACKFILL_ID);
158
164
  }
159
165
 
160
- if (!hadMarker || copied.length > 0 || needsCliHomesBackfill) {
166
+ if (needsWorkspacesBackfill) {
167
+ const workspacesSource = path.join(AMALGM_HOME, 'workspaces');
168
+ const workspacesTarget = path.join(AMALGM_DIR, 'workspaces');
169
+ if (mergeDirContents(workspacesSource, workspacesTarget)) copied.push('workspaces/');
170
+ completedMigrations.add(WORKSPACES_BACKFILL_ID);
171
+ }
172
+
173
+ if (needsContextBackfill) {
174
+ // System context (user-authored system-instructions.md) lives in the
175
+ // singular context/ dir; earlier migrations only covered plural contexts/.
176
+ const contextSource = path.join(AMALGM_HOME, 'context');
177
+ const contextTarget = path.join(AMALGM_DIR, 'context');
178
+ if (mergeDirContents(contextSource, contextTarget)) copied.push('context/');
179
+ completedMigrations.add(CONTEXT_BACKFILL_ID);
180
+ }
181
+
182
+ if (!hadMarker || copied.length > 0 || needsCliHomesBackfill || needsWorkspacesBackfill || needsContextBackfill) {
161
183
  writeJson(MARKER_FILE, {
162
184
  migrated_at: marker?.migrated_at || new Date().toISOString(),
163
185
  source: AMALGM_HOME,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.138",
3
+ "version": "0.1.139",
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/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/layout.js && node --check lib/migrate-layout.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.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/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/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/amalgm-mcp/events/pr-check-runner.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
20
+ "check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/identity-adopt.js && node --check lib/layout.js && node --check lib/migrate-layout.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.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/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/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/amalgm-mcp/events/pr-check-runner.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20"
@@ -86,13 +86,18 @@ const BUILTIN_AGENT_BLUEPRINTS = [
86
86
  },
87
87
  ];
88
88
 
89
+ // Every builtin agent starts with the core platform surface. `memories` must
90
+ // be here: project context injected into the prompt names the memory tools,
91
+ // so they have to actually be callable.
92
+ const DEFAULT_BUILTIN_TOOL_IDS = ['agents', 'apps', 'automations', 'browser', 'memories', 'notifications'];
93
+
89
94
  const BUILTIN_AGENTS = BUILTIN_AGENT_BLUEPRINTS.map((agent) => ({
90
95
  ...agent,
91
96
  systemPrompt: '',
92
97
  files: [],
93
98
  skills: [],
94
99
  mcpAppIds: [],
95
- loadout: { toolIds: [] },
100
+ loadout: { toolIds: [...DEFAULT_BUILTIN_TOOL_IDS] },
96
101
  mcp: { inheritAll: false, customServers: [], appIds: [] },
97
102
  builtin: false,
98
103
  deletable: true,
@@ -462,7 +467,7 @@ function seedBuiltinAgents(options = {}) {
462
467
  files: existing?.files || [],
463
468
  skills: existing?.skills || [],
464
469
  mcpAppIds: existing?.mcpAppIds || [],
465
- loadout: existing?.loadout || existing?.tools || { toolIds: [] },
470
+ loadout: existing?.loadout || existing?.tools || { toolIds: [...DEFAULT_BUILTIN_TOOL_IDS] },
466
471
  mcp: {
467
472
  inheritAll: false,
468
473
  customServers: [],
@@ -755,6 +760,7 @@ function readAgentConvoLog(sessionId, limit = 50) {
755
760
  module.exports = {
756
761
  BUILTIN_AGENTS,
757
762
  BUILTIN_AGENT_IDS,
763
+ DEFAULT_BUILTIN_TOOL_IDS,
758
764
  ensureAgentsDirs,
759
765
  loadAgents,
760
766
  saveAgents,
@@ -7,7 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const { spawn } = require('child_process');
10
+ const { spawn, spawnSync } = require('child_process');
11
11
  const { DEFAULT_CWD } = require('../config');
12
12
  const { runtimePort } = require('../../../lib/runtime-manifest');
13
13
  const { syncAppRoutesToGateway } = require('./advertise');
@@ -53,6 +53,26 @@ const APP_ENV_ALLOWLIST = [
53
53
  'PLAYWRIGHT_BROWSERS_PATH',
54
54
  ];
55
55
 
56
+ function runtimeEnvFingerprint() {
57
+ // The part of the inherited env that must match for a saved PID to be
58
+ // trusted: where the platform stores state. If this moved, the process
59
+ // predates a layout migration and must be respawned.
60
+ return process.env.AMALGM_DIR || '';
61
+ }
62
+
63
+ async function killProcessTree(pid) {
64
+ try { process.kill(-pid, 'SIGTERM'); } catch {}
65
+ try { process.kill(pid, 'SIGTERM'); } catch {}
66
+ const deadline = Date.now() + 3000;
67
+ while (isProcessAlive(pid) && Date.now() < deadline) {
68
+ await new Promise((resolve) => setTimeout(resolve, 100));
69
+ }
70
+ if (isProcessAlive(pid)) {
71
+ try { process.kill(-pid, 'SIGKILL'); } catch {}
72
+ try { process.kill(pid, 'SIGKILL'); } catch {}
73
+ }
74
+ }
75
+
56
76
  function isProcessAlive(pid) {
57
77
  if (!Number.isInteger(pid) || pid <= 0) return false;
58
78
  try {
@@ -63,6 +83,79 @@ function isProcessAlive(pid) {
63
83
  }
64
84
  }
65
85
 
86
+ function parsePids(output) {
87
+ const pids = new Set();
88
+ String(output || '').split(/\s+/).forEach((value) => {
89
+ const pid = Number(value);
90
+ if (Number.isInteger(pid) && pid > 0) pids.add(pid);
91
+ });
92
+ return Array.from(pids);
93
+ }
94
+
95
+ function listeningPidsForPort(port) {
96
+ const parsed = Number(port);
97
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return [];
98
+ const result = spawnSync('lsof', ['-nP', `-iTCP:${parsed}`, '-sTCP:LISTEN', '-t'], {
99
+ encoding: 'utf8',
100
+ });
101
+ if (result.error || !result.stdout) return [];
102
+ return parsePids(result.stdout);
103
+ }
104
+
105
+ function pidCommand(pid) {
106
+ const result = spawnSync('ps', ['eww', '-p', String(pid), '-o', 'command='], {
107
+ encoding: 'utf8',
108
+ });
109
+ if (result.error || result.status !== 0) return '';
110
+ return String(result.stdout || '');
111
+ }
112
+
113
+ function pidCwd(pid) {
114
+ const result = spawnSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
115
+ encoding: 'utf8',
116
+ });
117
+ if (result.error || result.status !== 0) return '';
118
+ const line = String(result.stdout || '').split('\n').find((item) => item.startsWith('n'));
119
+ return line ? line.slice(1) : '';
120
+ }
121
+
122
+ function isPathInside(parent, maybeChild) {
123
+ if (!parent || !maybeChild) return false;
124
+ const relative = path.relative(path.resolve(parent), path.resolve(maybeChild));
125
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
126
+ }
127
+
128
+ function isAppOwnedPid(app, pid) {
129
+ if (app.pid === pid) return true;
130
+ const command = pidCommand(pid);
131
+ if (command.includes(`AMALGM_APP_ID=${app.id}`) || command.includes(`AMALGM_APP_ID='${app.id}'`)) return true;
132
+ if (app.appRef && (command.includes(`AMALGM_APP_REF=${app.appRef}`) || command.includes(`AMALGM_APP_REF='${app.appRef}'`))) {
133
+ return true;
134
+ }
135
+ if (app.cwd && command.includes(path.resolve(app.cwd))) return true;
136
+ return isPathInside(app.cwd, pidCwd(pid));
137
+ }
138
+
139
+ async function killOwnedPortListeners(app, options = {}) {
140
+ const pids = listeningPidsForPort(app.port).filter((pid) => pid !== process.pid);
141
+ if (pids.length === 0) return [];
142
+
143
+ const owned = [];
144
+ const unknown = [];
145
+ for (const pid of pids) {
146
+ (isAppOwnedPid(app, pid) ? owned : unknown).push(pid);
147
+ }
148
+
149
+ if (unknown.length > 0 && options.failOnUnknown !== false) {
150
+ throw new Error(`Port ${app.port} is already in use by pid(s) ${unknown.join(', ')}; refusing to kill non-app process`);
151
+ }
152
+
153
+ for (const pid of owned) {
154
+ await killProcessTree(pid);
155
+ }
156
+ return owned;
157
+ }
158
+
66
159
  async function syncRoutesBestEffort(reason) {
67
160
  try {
68
161
  await syncAppRoutesToGateway(reason);
@@ -215,24 +308,34 @@ async function startApp(appId) {
215
308
  }
216
309
 
217
310
  if (app.pid && isProcessAlive(app.pid)) {
218
- const current = updateAppMeta(app.id, {
219
- desiredState: 'running',
220
- status: 'running',
221
- error: null,
222
- });
223
- await syncRoutesBestEffort(`start:${appId}`);
224
- return current;
311
+ if ((app.envFingerprint || '') === runtimeEnvFingerprint()) {
312
+ const current = updateAppMeta(app.id, {
313
+ desiredState: 'running',
314
+ status: 'running',
315
+ error: null,
316
+ });
317
+ await syncRoutesBestEffort(`start:${appId}`);
318
+ return current;
319
+ }
320
+ // The saved process was spawned under a different runtime env (e.g. the
321
+ // storage layout moved); adopting it would keep an app alive against
322
+ // stale AMALGM_* paths. Replace it.
323
+ await killProcessTree(app.pid);
324
+ updateAppMeta(app.id, { pid: null });
225
325
  }
226
326
 
227
327
  if (app.pid && !isProcessAlive(app.pid)) {
228
328
  updateAppMeta(app.id, { pid: null });
229
329
  }
230
330
 
331
+ await killOwnedPortListeners(app);
332
+
231
333
  const child = startAppProcess(app);
232
334
  const current = updateAppMeta(app.id, {
233
335
  desiredState: 'running',
234
336
  status: 'running',
235
337
  pid: child.pid,
338
+ envFingerprint: runtimeEnvFingerprint(),
236
339
  error: null,
237
340
  lastStartedAt: new Date().toISOString(),
238
341
  });
@@ -251,11 +354,13 @@ async function stopApp(appId, options = {}) {
251
354
 
252
355
  const child = running.get(appId);
253
356
  if (child?.pid) {
254
- try { process.kill(-child.pid, 'SIGTERM'); } catch {}
255
- try { child.kill('SIGTERM'); } catch {}
357
+ await killProcessTree(child.pid);
256
358
  } else if (app.pid) {
257
- try { process.kill(app.pid, 'SIGTERM'); } catch {}
359
+ await killProcessTree(app.pid);
258
360
  }
361
+ await killOwnedPortListeners(app, { failOnUnknown: false }).catch((err) => {
362
+ console.warn(`[Apps] Could not clean listeners for ${appId} on port ${app.port}: ${err.message}`);
363
+ });
259
364
  running.delete(appId);
260
365
 
261
366
  setTimeout(() => stopping.delete(appId), 1000);