amalgm 0.1.257 → 0.1.259

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 (26) hide show
  1. package/lib/cli.js +207 -3
  2. package/lib/layout.js +30 -9
  3. package/lib/shared-realtime-tunnel.js +12 -14
  4. package/package.json +2 -2
  5. package/runtime/scripts/amalgm-mcp/lib/layout.js +32 -9
  6. package/runtime/scripts/amalgm-mcp/observer/index.js +65 -4
  7. package/runtime/scripts/amalgm-mcp/observer/scan.js +60 -1
  8. package/runtime/scripts/amalgm-mcp/observer/store.js +43 -8
  9. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +186 -32
  10. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +33 -6
  11. package/runtime/scripts/amalgm-mcp/registration/index.js +21 -0
  12. package/runtime/scripts/amalgm-mcp/registration/service.js +117 -11
  13. package/runtime/scripts/amalgm-mcp/registry/index.js +1 -1
  14. package/runtime/scripts/amalgm-mcp/repocard/capture.js +16 -8
  15. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +6 -0
  16. package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +67 -8
  17. package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +3 -3
  18. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +85 -0
  19. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +95 -3
  20. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +21 -0
  21. package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +3 -6
  22. package/runtime/scripts/amalgm-mcp/tests/toolbox-artifacts.test.js +88 -0
  23. package/runtime/scripts/amalgm-mcp/tests/toolbox-cloud-registration.test.js +91 -0
  24. package/runtime/scripts/amalgm-mcp/toolbox/artifacts.js +225 -0
  25. package/runtime/scripts/amalgm-mcp/toolbox/store.js +59 -10
  26. package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +14 -5
package/lib/cli.js CHANGED
@@ -120,7 +120,10 @@ function usage() {
120
120
  ' amalgm stop',
121
121
  ' amalgm register [path ...] Register local ground (default: current directory)',
122
122
  ' amalgm add <entity-uuid> [directory] Add a cloud-backed tree to this machine',
123
- ' amalgm status',
123
+ ' amalgm ls [entity] List the verified cloud workspace',
124
+ ' amalgm cd <entity> Resolve a materialized cloud directory',
125
+ ' amalgm status [path-or-uuid] Show cloud-backed entity and watcher state',
126
+ ' amalgm shell init zsh Enable `amalgm cd` in the current shell',
124
127
  ' amalgm doctor',
125
128
  ' amalgm service [install|start|stop|status|uninstall] [--mode auto|systemd|launchd|windows|portable]',
126
129
  ' amalgm update [--tag latest|canary]',
@@ -1112,7 +1115,7 @@ async function waitForServices(timeoutMs) {
1112
1115
  return latest;
1113
1116
  }
1114
1117
 
1115
- async function status() {
1118
+ async function runtimeStatus() {
1116
1119
  const pid = readPid();
1117
1120
  const record = loadComputerRecord({ migrate: true });
1118
1121
  const runtimeState = readJson(RUNTIME_STATE_FILE, null);
@@ -1177,6 +1180,203 @@ async function status() {
1177
1180
  }
1178
1181
  }
1179
1182
 
1183
+ /** One runtime read for every navigation command. The endpoint owns facts;
1184
+ * these commands only select and render them, so navigating never becomes a
1185
+ * backdoor mutation or cloud refresh. */
1186
+ async function readEntityStatus() {
1187
+ const port = servicePorts().find(([name]) => name === 'amalgm-mcp')?.[1];
1188
+ let answer = null;
1189
+ if (port) {
1190
+ try {
1191
+ answer = await runtimeGet(port, '/entities/status');
1192
+ } catch (error) {
1193
+ if (error.code !== 'ECONNREFUSED') throw error;
1194
+ }
1195
+ }
1196
+ if (!answer) {
1197
+ console.error('Amalgm runtime is not running. Start it with `amalgm start`.');
1198
+ process.exitCode = 1;
1199
+ return null;
1200
+ }
1201
+ if (answer.status !== 200 || !answer.payload) {
1202
+ console.error(`Error: ${answer.payload?.error || `runtime responded ${answer.status}`}`);
1203
+ process.exitCode = 1;
1204
+ return null;
1205
+ }
1206
+ return answer.payload;
1207
+ }
1208
+
1209
+ /** A selector is deliberately exact: full UUID, local address, canonical
1210
+ * cloud path, or an unambiguous entity name. A guessed fuzzy match would
1211
+ * make a cloud navigation command choose the wrong ground. */
1212
+ function selectEntity(status, selector) {
1213
+ const entities = status.entities || [];
1214
+ if (!selector) return null;
1215
+ const uuid = selector && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(selector)
1216
+ ? selector.toLowerCase()
1217
+ : null;
1218
+ const localPath = selector && !uuid ? path.resolve(selector) : null;
1219
+ const cloudPath = uuid ? null : String(selector).replace(/^\/+|\/+$/g, '');
1220
+ const matches = entities.filter((entity) => entity.uuid === uuid
1221
+ || entity.localPath === localPath
1222
+ || entity.cloudPath === cloudPath
1223
+ || entity.name === selector);
1224
+ if (matches.length === 0) throw new Error(`No cloud-backed entity matches ${selector}.`);
1225
+ if (matches.length > 1) {
1226
+ throw new Error(`Entity selector ${selector} is ambiguous: ${matches.map((entity) => entity.cloudPath || entity.uuid).join(', ')}. Use a UUID or canonical cloud path.`);
1227
+ }
1228
+ return matches[0];
1229
+ }
1230
+
1231
+ function requireCurrentCloudWorkspace(status) {
1232
+ const cloud = status.cloud || {};
1233
+ if (!cloud.replica) {
1234
+ throw new Error(cloud.identity?.enabled
1235
+ ? 'Cloud workspace is initializing; wait for the signed-in runtime to finish its handshake.'
1236
+ : 'Cloud workspace is unavailable because this machine is not signed in.');
1237
+ }
1238
+ if (!cloud.catalog?.current) {
1239
+ throw new Error('Cloud workspace is catching up; wait for the active verified cloud head before listing it.');
1240
+ }
1241
+ }
1242
+
1243
+ function printEntityLine(entity) {
1244
+ const where = entity.localPath ? 'local' : 'cloud only';
1245
+ console.log(` ${entity.name}\t${entity.type}\t${entity.status}\t${where}\t${entity.uuid}`);
1246
+ }
1247
+
1248
+ /** `amalgm status [path-or-uuid]` is a read-only health view. Daemon status
1249
+ * remains available as `amalgm service status`. */
1250
+ async function entityStatus(positionals) {
1251
+ if (positionals.length > 1) throw new Error('Usage: amalgm status [path-or-uuid]');
1252
+ const status = await readEntityStatus();
1253
+ if (!status) return;
1254
+ const selector = positionals[0] || null;
1255
+ let entities = status.entities || [];
1256
+ if (selector) {
1257
+ try {
1258
+ entities = [selectEntity(status, selector)];
1259
+ } catch (error) {
1260
+ console.error(error.message);
1261
+ process.exitCode = 1;
1262
+ return;
1263
+ }
1264
+ }
1265
+
1266
+ const cloud = status.cloud || {};
1267
+ const replica = cloud.replica;
1268
+ if (!replica) {
1269
+ console.log(`Cloud: ${cloud.identity?.enabled ? 'initializing' : 'not signed in'}`);
1270
+ } else {
1271
+ console.log(`Cloud: ${cloud.catalog?.current ? 'current' : 'catching up'} (head ${replica.headVersion})`);
1272
+ }
1273
+ const outbox = cloud.outbox || {};
1274
+ console.log(`Delivery: ${outbox.pending || 0} pending, ${outbox.syncing || 0} sending, ${outbox.rejected || 0} rejected`);
1275
+ const jobs = cloud.materializations || {};
1276
+ if ((jobs.waitingForContent || 0) + (jobs.materializing || 0) + (jobs.failed || 0) > 0) {
1277
+ console.log(`Materializations: ${jobs.waitingForContent || 0} waiting, ${jobs.materializing || 0} applying, ${jobs.failed || 0} failed`);
1278
+ }
1279
+ if (entities.length === 0) {
1280
+ console.log('Entities: none on this cloud replica.');
1281
+ return;
1282
+ }
1283
+ console.log('Entities:');
1284
+ for (const entity of entities) {
1285
+ console.log(` ${entity.uuid} ${entity.type} ${entity.status}`);
1286
+ if (entity.localPath) console.log(` Local: ${entity.localPath}`);
1287
+ else console.log(' Local: not materialized on this machine');
1288
+ if (entity.root) {
1289
+ const watcher = entity.root.watching && !entity.root.degraded ? 'on' : 'unavailable';
1290
+ console.log(` Watching: ${watcher}`);
1291
+ }
1292
+ }
1293
+ }
1294
+
1295
+ /** `amalgm ls [entity]` lists the canonical cloud graph, never this
1296
+ * machine's incidental directory. A stale catalog is useful in `status`,
1297
+ * but is never presented as a navigable workspace here. */
1298
+ async function entityList(positionals) {
1299
+ if (positionals.length > 1) throw new Error('Usage: amalgm ls [entity]');
1300
+ const status = await readEntityStatus();
1301
+ if (!status) return;
1302
+ try {
1303
+ requireCurrentCloudWorkspace(status);
1304
+ const selected = positionals[0] ? selectEntity(status, positionals[0]) : null;
1305
+ if (selected && !selected.catalogued) {
1306
+ throw new Error(`Entity ${selected.uuid} is not in the verified cloud workspace yet.`);
1307
+ }
1308
+ const entities = (status.entities || []).filter((entity) => entity.catalogued
1309
+ && entity.status === 'active'
1310
+ && (selected ? entity.parentUUID === selected.uuid : entity.parentUUID === null))
1311
+ .sort((left, right) => left.name.localeCompare(right.name));
1312
+ if (selected) console.log(`${selected.cloudPath || selected.uuid}:`);
1313
+ console.log('NAME\tTYPE\tSTATE\tLOCATION\tUUID');
1314
+ for (const entity of entities) printEntityLine(entity);
1315
+ } catch (error) {
1316
+ console.error(error.message);
1317
+ process.exitCode = 1;
1318
+ }
1319
+ }
1320
+
1321
+ async function resolveEntityDirectory(positionals) {
1322
+ if (positionals.length !== 1) throw new Error('Usage: amalgm cd <entity>');
1323
+ const status = await readEntityStatus();
1324
+ if (!status) return null;
1325
+ const entity = selectEntity(status, positionals[0]);
1326
+ if (!entity.localPath) {
1327
+ throw new Error(`Cloud entity ${entity.cloudPath || entity.uuid} is not materialized on this machine. Run \`amalgm add ${entity.uuid} <directory>\` first.`);
1328
+ }
1329
+ let stat;
1330
+ try { stat = fs.statSync(entity.localPath); } catch {
1331
+ throw new Error(`Local binding for ${entity.cloudPath || entity.uuid} is unavailable at ${entity.localPath}. Run \`amalgm status ${entity.uuid}\` for details.`);
1332
+ }
1333
+ if (!stat.isDirectory()) {
1334
+ throw new Error(`${entity.cloudPath || entity.uuid} is a ${entity.type}, not a directory.`);
1335
+ }
1336
+ return entity.localPath;
1337
+ }
1338
+
1339
+ /** A standalone process cannot change the invoking shell. This direct form
1340
+ * resolves a directory honestly and points to the opt-in shell integration;
1341
+ * the shell function below intercepts the same spelling and performs cd. */
1342
+ async function entityCd(positionals) {
1343
+ try {
1344
+ const target = await resolveEntityDirectory(positionals);
1345
+ if (!target) return;
1346
+ console.log(target);
1347
+ console.log('Run `eval "$(amalgm shell init zsh)"` once to make `amalgm cd <entity>` change this shell.');
1348
+ } catch (error) {
1349
+ console.error(error.message);
1350
+ process.exitCode = 1;
1351
+ }
1352
+ }
1353
+
1354
+ async function resolveEntityDirectoryMachine(positionals) {
1355
+ try {
1356
+ const target = await resolveEntityDirectory(positionals);
1357
+ if (target) console.log(target);
1358
+ } catch (error) {
1359
+ console.error(error.message);
1360
+ process.exitCode = 1;
1361
+ }
1362
+ }
1363
+
1364
+ function shellCommand(positionals) {
1365
+ if (positionals[0] !== 'init' || !['zsh', 'bash'].includes(positionals[1] || 'zsh') || positionals.length > 2) {
1366
+ throw new Error('Usage: amalgm shell init [zsh|bash]');
1367
+ }
1368
+ console.log(`amalgm() {
1369
+ if [ "$1" = cd ]; then
1370
+ shift
1371
+ local _amalgm_destination
1372
+ _amalgm_destination="$(command amalgm __resolve-directory "$@")" || return $?
1373
+ builtin cd "$_amalgm_destination"
1374
+ return $?
1375
+ fi
1376
+ command amalgm "$@"
1377
+ }`);
1378
+ }
1379
+
1180
1380
  async function doctor() {
1181
1381
  ensureBaseDirs();
1182
1382
 
@@ -1649,7 +1849,11 @@ async function main(argv) {
1649
1849
  if (command === 'stop') return stop();
1650
1850
  if (command === 'register') return register(positionals);
1651
1851
  if (command === 'add') return add(positionals);
1652
- if (command === 'status') return status();
1852
+ if (command === 'ls') return entityList(positionals);
1853
+ if (command === 'cd') return entityCd(positionals);
1854
+ if (command === 'status') return entityStatus(positionals);
1855
+ if (command === 'shell') return shellCommand(positionals);
1856
+ if (command === '__resolve-directory') return resolveEntityDirectoryMachine(positionals);
1653
1857
  if (command === 'doctor') return doctor();
1654
1858
  if (command === 'service') return serviceCommand(options, positionals);
1655
1859
  if (command === 'update') return update(options);
package/lib/layout.js CHANGED
@@ -33,7 +33,9 @@ const ROOT_MANIFEST_NAME = '.amalgm-root.json';
33
33
  const USER_MANIFEST_NAME = '.amalgm-user.json';
34
34
  const LAYOUT_VERSION = 1;
35
35
  const USER_HOME_MANIFEST_NAME = 'home.json';
36
- const USER_HOME_SCHEMA_VERSION = 1;
36
+ // Changing the declared exact graph changes the identity contract. A client
37
+ // must never accept a prior graph and silently invent a new managed child.
38
+ const USER_HOME_SCHEMA_VERSION = 2;
37
39
 
38
40
  // Stable per-user product namespaces. Their authority is NOT inferred from
39
41
  // their path: user content and exact platform-managed targets are declared
@@ -59,6 +61,19 @@ const USER_HOME_ROOTS = Object.freeze([
59
61
  // Platform management is an exact-node fact. It deliberately does not
60
62
  // cascade from `system/`: human permissions may still inherit normally, while
61
63
  // only these particular entities are product-managed.
64
+ //
65
+ // A Toolbox tool is one complete entity: its definition and actions travel in
66
+ // the same file. These names are deliberately stable and human-readable.
67
+ const SYSTEM_TOOL_ARTIFACTS = Object.freeze([
68
+ { key: 'amalgm-automations', toolId: 'automations', relativePath: 'toolbox/amalgm-automations.json', rootKey: 'toolbox' },
69
+ { key: 'amalgm-notifications', toolId: 'notifications', relativePath: 'toolbox/amalgm-notifications.json', rootKey: 'toolbox' },
70
+ { key: 'amalgm-memories', toolId: 'memories', relativePath: 'toolbox/amalgm-memories.json', rootKey: 'toolbox' },
71
+ { key: 'amalgm-agents', toolId: 'agents', relativePath: 'toolbox/amalgm-agents.json', rootKey: 'toolbox' },
72
+ { key: 'amalgm-apps', toolId: 'apps', relativePath: 'toolbox/amalgm-apps.json', rootKey: 'toolbox' },
73
+ { key: 'amalgm-cua', toolId: 'computer-use', relativePath: 'toolbox/amalgm-cua.json', rootKey: 'toolbox' },
74
+ { key: 'amalgm-browser', toolId: 'browser', relativePath: 'toolbox/amalgm-browser.json', rootKey: 'toolbox' },
75
+ ]);
76
+
62
77
  const USER_HOME_EXACT_MANAGED = Object.freeze([
63
78
  ...USER_HOME_ROOTS.map(({ key, relativePath }) => ({
64
79
  key: `${key}-root`, relativePath, rootKey: key,
@@ -66,6 +81,7 @@ const USER_HOME_EXACT_MANAGED = Object.freeze([
66
81
  { key: 'home-manifest', relativePath: 'system/home.json', rootKey: 'system' },
67
82
  { key: 'defaults', relativePath: 'system/defaults', rootKey: 'system' },
68
83
  { key: 'policy', relativePath: 'system/policy', rootKey: 'system' },
84
+ ...SYSTEM_TOOL_ARTIFACTS,
69
85
  ]);
70
86
 
71
87
  function readJson(file, fallback = null) {
@@ -106,6 +122,7 @@ function declaredUserHome(userDir) {
106
122
  */
107
123
  function ensureUserHome(userDir) {
108
124
  const declaration = declaredUserHome(userDir);
125
+ const materializedExactPaths = [];
109
126
  for (const root of declaration.roots) {
110
127
  fs.mkdirSync(root.path, { recursive: true, mode: 0o700 });
111
128
  }
@@ -115,15 +132,18 @@ function ensureUserHome(userDir) {
115
132
  }
116
133
 
117
134
  const manifest = declaration.exactManaged.find((entry) => entry.key === 'home-manifest');
118
- if (manifest && !fs.existsSync(manifest.path)) {
119
- writeJson(manifest.path, {
120
- schemaVersion: USER_HOME_SCHEMA_VERSION,
121
- kind: 'amalgm-user-home',
122
- roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
123
- exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
124
- });
135
+ const manifestContents = {
136
+ schemaVersion: USER_HOME_SCHEMA_VERSION,
137
+ kind: 'amalgm-user-home',
138
+ roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
139
+ exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
140
+ };
141
+ if (manifest && JSON.stringify(readJson(manifest.path, null)) !== JSON.stringify(manifestContents)) {
142
+ writeJson(manifest.path, manifestContents);
143
+ materializedExactPaths.push(manifest.relativePath);
125
144
  }
126
- return declaration;
145
+
146
+ return { ...declaration, materializedExactPaths };
127
147
  }
128
148
 
129
149
  function lstatOrNull(target) {
@@ -401,6 +421,7 @@ module.exports = {
401
421
  LAYOUT_VERSION,
402
422
  PRIMITIVE_DIRS,
403
423
  ROOT_MANIFEST_NAME,
424
+ SYSTEM_TOOL_ARTIFACTS,
404
425
  USER_MANIFEST_NAME,
405
426
  USER_HOME_EXACT_MANAGED,
406
427
  USER_HOME_MANIFEST_NAME,
@@ -19,8 +19,8 @@ const RETRY_MS = 1_000;
19
19
  const REAUTHORIZE_MS = 30_000;
20
20
  const REPO_OBJECT_REQUEST_TIMEOUT_MS = 15_000;
21
21
  const COOKIE_PRIVATE_CONTRACT = 'browser-cookie-jar@1';
22
- const USER_HOME_PRIVATE_CONTRACT = 'amalgm-user-home@1';
23
- const ENTITY_REGISTRY_PRIVATE_CONTRACT = 'amalgm-entity-registry@2';
22
+ const USER_HOME_PRIVATE_CONTRACT = 'amalgm-user-home@2';
23
+ const ENTITY_REGISTRY_PRIVATE_CONTRACT = 'amalgm-entity-registry@3';
24
24
 
25
25
  function createSharedRealtimeTunnel({
26
26
  send,
@@ -938,13 +938,10 @@ function createSharedRealtimeTunnel({
938
938
  committedAt: frame.committed_at,
939
939
  }).then((ack) => {
940
940
  entityMutationInflight.delete(frame.request_id);
941
- // An acknowledgement advances the official head. Re-read the
942
- // private resource immediately: the gateway compacts that head into
943
- // one immutable snapshot before replying, so this device never has
944
- // to use its mutation tail as an `amalgm add` starting point. A
945
- // legacy empty entity resource is seeded in ordered records first;
946
- // intermediate heads are intentionally not imported as a usable
947
- // snapshot, because they are not the complete declared home yet.
941
+ // The runtime reports whether it converged its catalog locally from
942
+ // this acknowledgement. Only when it could not (a version gap, a
943
+ // reconciliation handshake, a stale local view) is the compacted
944
+ // snapshot re-downloaded; the ordinary in-order ack costs nothing.
948
945
  if (ack?.requiresSnapshot !== false) void ensurePrivateEntityRegistry();
949
946
  scheduleEntityMutationDrain();
950
947
  }).catch(() => {
@@ -1109,11 +1106,12 @@ function createSharedRealtimeTunnel({
1109
1106
  version: frame.version,
1110
1107
  committedAt: frame.committed_at,
1111
1108
  actorUserId: frame.actor_user_id,
1112
- })).then(() => {
1113
- if (entityRegistryMutation) {
1114
- // The live frame is useful for collaboration, but cannot become the
1115
- // reconstruction path for a new local binding. Ask the authority to
1116
- // compact and return its active snapshot before `add` can continue.
1109
+ })).then((result) => {
1110
+ if (entityRegistryMutation && result?.requiresSnapshot !== false) {
1111
+ // The runtime could not converge its catalog from this frame alone
1112
+ // (a gap, a snapshot replacement, or a stale local view). Ask the
1113
+ // authority to compact and return its active snapshot; an in-order
1114
+ // frame needs no re-download.
1117
1115
  void ensurePrivateEntityRegistry();
1118
1116
  }
1119
1117
  }).catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.257",
3
+ "version": "0.1.259",
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/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/amalgm-mcp/registration/entity-cloud.js && node --check runtime/scripts/amalgm-mcp/registration/entity-content.js && node --check runtime/scripts/amalgm-mcp/registration/service.js && node --check runtime/scripts/amalgm-mcp/server/routes/entities.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/amalgm-mcp/registration/entity-cloud.js && node --check runtime/scripts/amalgm-mcp/registration/entity-content.js && node --check runtime/scripts/amalgm-mcp/registration/service.js && node --check runtime/scripts/amalgm-mcp/toolbox/artifacts.js && node --check runtime/scripts/amalgm-mcp/server/routes/entities.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"
@@ -33,7 +33,9 @@ const ROOT_MANIFEST_NAME = '.amalgm-root.json';
33
33
  const USER_MANIFEST_NAME = '.amalgm-user.json';
34
34
  const LAYOUT_VERSION = 1;
35
35
  const USER_HOME_MANIFEST_NAME = 'home.json';
36
- const USER_HOME_SCHEMA_VERSION = 1;
36
+ // Changing the declared exact graph changes the identity contract. A client
37
+ // must never accept a prior graph and silently invent a new managed child.
38
+ const USER_HOME_SCHEMA_VERSION = 2;
37
39
 
38
40
  // Stable per-user product namespaces. Their authority is NOT inferred from
39
41
  // their path: user content and exact platform-managed targets are declared
@@ -59,6 +61,21 @@ const USER_HOME_ROOTS = Object.freeze([
59
61
  // Platform management is an exact-node fact. It deliberately does not
60
62
  // cascade from `system/`: human permissions may still inherit normally, while
61
63
  // only these particular entities are product-managed.
64
+ //
65
+ // A Toolbox tool is one complete entity: its definition and actions travel in
66
+ // the same file. These names are deliberately stable and human-readable. They
67
+ // are not an `amalgm/` subtree because that would falsely imply ownership
68
+ // cascades to every future child of that directory.
69
+ const SYSTEM_TOOL_ARTIFACTS = Object.freeze([
70
+ { key: 'amalgm-automations', toolId: 'automations', relativePath: 'toolbox/amalgm-automations.json', rootKey: 'toolbox' },
71
+ { key: 'amalgm-notifications', toolId: 'notifications', relativePath: 'toolbox/amalgm-notifications.json', rootKey: 'toolbox' },
72
+ { key: 'amalgm-memories', toolId: 'memories', relativePath: 'toolbox/amalgm-memories.json', rootKey: 'toolbox' },
73
+ { key: 'amalgm-agents', toolId: 'agents', relativePath: 'toolbox/amalgm-agents.json', rootKey: 'toolbox' },
74
+ { key: 'amalgm-apps', toolId: 'apps', relativePath: 'toolbox/amalgm-apps.json', rootKey: 'toolbox' },
75
+ { key: 'amalgm-cua', toolId: 'computer-use', relativePath: 'toolbox/amalgm-cua.json', rootKey: 'toolbox' },
76
+ { key: 'amalgm-browser', toolId: 'browser', relativePath: 'toolbox/amalgm-browser.json', rootKey: 'toolbox' },
77
+ ]);
78
+
62
79
  const USER_HOME_EXACT_MANAGED = Object.freeze([
63
80
  ...USER_HOME_ROOTS.map(({ key, relativePath }) => ({
64
81
  key: `${key}-root`, relativePath, rootKey: key,
@@ -66,6 +83,7 @@ const USER_HOME_EXACT_MANAGED = Object.freeze([
66
83
  { key: 'home-manifest', relativePath: 'system/home.json', rootKey: 'system' },
67
84
  { key: 'defaults', relativePath: 'system/defaults', rootKey: 'system' },
68
85
  { key: 'policy', relativePath: 'system/policy', rootKey: 'system' },
86
+ ...SYSTEM_TOOL_ARTIFACTS,
69
87
  ]);
70
88
 
71
89
  function readJson(file, fallback = null) {
@@ -106,6 +124,7 @@ function declaredUserHome(userDir) {
106
124
  */
107
125
  function ensureUserHome(userDir) {
108
126
  const declaration = declaredUserHome(userDir);
127
+ const materializedExactPaths = [];
109
128
  for (const root of declaration.roots) {
110
129
  fs.mkdirSync(root.path, { recursive: true, mode: 0o700 });
111
130
  }
@@ -115,15 +134,18 @@ function ensureUserHome(userDir) {
115
134
  }
116
135
 
117
136
  const manifest = declaration.exactManaged.find((entry) => entry.key === 'home-manifest');
118
- if (manifest && !fs.existsSync(manifest.path)) {
119
- writeJson(manifest.path, {
120
- schemaVersion: USER_HOME_SCHEMA_VERSION,
121
- kind: 'amalgm-user-home',
122
- roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
123
- exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
124
- });
137
+ const manifestContents = {
138
+ schemaVersion: USER_HOME_SCHEMA_VERSION,
139
+ kind: 'amalgm-user-home',
140
+ roots: declaration.roots.map(({ key, relativePath }) => ({ key, path: relativePath })),
141
+ exactManaged: declaration.exactManaged.map(({ key, relativePath, scope }) => ({ key, path: relativePath, scope })),
142
+ };
143
+ if (manifest && JSON.stringify(readJson(manifest.path, null)) !== JSON.stringify(manifestContents)) {
144
+ writeJson(manifest.path, manifestContents);
145
+ materializedExactPaths.push(manifest.relativePath);
125
146
  }
126
- return declaration;
147
+
148
+ return { ...declaration, materializedExactPaths };
127
149
  }
128
150
 
129
151
  function lstatOrNull(target) {
@@ -401,6 +423,7 @@ module.exports = {
401
423
  LAYOUT_VERSION,
402
424
  PRIMITIVE_DIRS,
403
425
  ROOT_MANIFEST_NAME,
426
+ SYSTEM_TOOL_ARTIFACTS,
404
427
  USER_MANIFEST_NAME,
405
428
  USER_HOME_EXACT_MANAGED,
406
429
  USER_HOME_MANIFEST_NAME,
@@ -40,7 +40,7 @@ const path = require('path');
40
40
  const { createApply } = require('./apply');
41
41
  const { createContinuity } = require('./continuity');
42
42
  const { createStore } = require('./store');
43
- const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot } = require('./scan');
43
+ const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot } = require('./scan');
44
44
  const { createVerify } = require('./verify');
45
45
  const { watchDir, watchRecursive } = require('./watch');
46
46
  const { adapterFor } = require('../adapters');
@@ -343,18 +343,75 @@ function createObserver(options) {
343
343
  }
344
344
  if (parent) {
345
345
  // Born from a files root, returned to it: the parent's next scan
346
- // reclaims the files as creates.
346
+ // reclaims the files as creates. No doorbell will ever name the
347
+ // reclaimed ground, so every file is suspect — the scan must walk.
347
348
  detach(root.rootId);
348
349
  store.removeRoot(root.rootId);
349
- live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
350
+ const parentState = live.get(parent.rootId);
351
+ parentState?.territories.delete(root.path.slice(parent.path.length + 1));
352
+ if (parentState) parentState.checkAll = true;
350
353
  scanFilesRoot(parent);
351
354
  return;
352
355
  }
353
356
  store.setRootKind(root.rootId, 'files');
354
- if (state) state.kind = 'files';
357
+ if (state) {
358
+ state.kind = 'files';
359
+ state.checkAll = true; // conversion ground has no doorbells: walk it
360
+ }
355
361
  scanFilesRoot({ ...root, kind: 'files' });
356
362
  }
357
363
 
364
+ /**
365
+ * The scoped scan a NAMED doorbell earns: read only the directories
366
+ * the rung paths live in, compare only those directories' rows, and
367
+ * conclude only what is channel-local — an existing file's bytes
368
+ * changed, an existing link's written target changed. Anything
369
+ * structural (a create, a delete, a rename, a retag, a `.git`, an
370
+ * unreadable or absent directory) may be half of a story another
371
+ * directory holds, so it concludes nothing here and escalates to the
372
+ * full walk, which owns those laws unchanged. Returns true when the
373
+ * scoped conclusion was complete; false means walk.
374
+ */
375
+ function scanRungDirs(root, state, rung) {
376
+ const scopeDirs = new Set();
377
+ for (const relPath of rung) {
378
+ scopeDirs.add(relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '');
379
+ // A rung directory is also looked at directly: its own children
380
+ // may be what actually changed (the OS may name only the folder).
381
+ if (isDirectory(path.join(root.path, relPath))) scopeDirs.add(relPath);
382
+ }
383
+ const entries = { files: [], dirs: [], links: [] };
384
+ for (const relDir of scopeDirs) {
385
+ if (relDir !== '' && underTerritory(state, relDir)) return false; // territory ground answers to its own root
386
+ const look = listDir(root.path, relDir, shouldEnroll);
387
+ if (look.absent || look.unsettled || look.hasGit) return false;
388
+ for (const dir of look.dirs) {
389
+ // A nested repo is territory, never a dir channel — walkRoot
390
+ // reports it to repoDirs; the scoped look just leaves it alone.
391
+ if (state.territories.has(dir.relPath)) continue;
392
+ entries.dirs.push(dir);
393
+ }
394
+ entries.files.push(...look.files);
395
+ entries.links.push(...look.links);
396
+ }
397
+ const dirs = [...scopeDirs];
398
+ const rows = {
399
+ files: dirs.flatMap((relDir) => store.filesInDir(root.rootId, relDir)),
400
+ dirs: dirs.flatMap((relDir) => store.dirsInDir(root.rootId, relDir)),
401
+ links: dirs.flatMap((relDir) => store.linksInDir(root.rootId, relDir)),
402
+ };
403
+ const dirDiff = diffDirState(rows.dirs, entries.dirs);
404
+ const linkDiff = diffLinkState(rows.links, entries.links);
405
+ const fileDiff = diffRootState(rows.files, entries.files, rung);
406
+ const structural = dirDiff.renames.length || dirDiff.retags.length || dirDiff.creates.length || dirDiff.deletes.length
407
+ || linkDiff.renames.length || linkDiff.creates.length || linkDiff.deletes.length
408
+ || fileDiff.renames.length || fileDiff.creates.length || fileDiff.deletes.length;
409
+ if (structural) return false;
410
+ applyLinkDiff(root, linkDiff); // changes and retags: payload-local facts
411
+ applyDiff(root, fileDiff); // checks only: content truth on existing channels
412
+ return true;
413
+ }
414
+
358
415
  function scanFilesRoot(root, { allowRootRepository = false } = {}) {
359
416
  // Drain the doorbell's named hints first: anything that rings during the
360
417
  // walk below belongs to the next scan, not this one.
@@ -366,6 +423,10 @@ function createObserver(options) {
366
423
  state.checkAll = false;
367
424
  }
368
425
 
426
+ // A named doorbell earns a scoped look; only a nameless one (or a
427
+ // scoped look that met structural news) pays for the whole walk.
428
+ if (!checkAll && rung.size > 0 && state && scanRungDirs(root, state, rung)) return;
429
+
369
430
  const { files: entries, dirs, links, repoDirs, unsettled } = walkRoot(
370
431
  root.path,
371
432
  shouldEnroll,
@@ -126,6 +126,65 @@ function walkRoot(rootPath, shouldEnroll, { allowRootRepository = false } = {})
126
126
  return { files, dirs, links, repoDirs, unsettled };
127
127
  }
128
128
 
129
+ /**
130
+ * One directory's immediate truth — the scoped look a NAMED doorbell
131
+ * earns (walkRoot is the nameless doorbell's). Same laws, one level
132
+ * deep: entries classified exactly as walkRoot classifies them,
133
+ * symlinks read but never followed. The caller decides what a scoped
134
+ * look may conclude; this function only refuses to pretend it saw more
135
+ * than one directory: a `.git` here is territory news (`hasGit`), a
136
+ * listing that failed is `unsettled`, a directory that is gone is
137
+ * `absent` — all three send the caller to the full walk, which owns
138
+ * those conclusions.
139
+ */
140
+ function listDir(rootPath, relDir, shouldEnroll) {
141
+ const absDir = relDir ? path.join(rootPath, relDir) : rootPath;
142
+ const out = { files: [], dirs: [], links: [], hasGit: false, absent: false, unsettled: false };
143
+ let entries;
144
+ try {
145
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
146
+ } catch (error) {
147
+ if (lookFailed(error).truth === 'absent') out.absent = true;
148
+ else out.unsettled = true;
149
+ return out;
150
+ }
151
+ if (entries.some((entry) => entry.name === '.git')) {
152
+ out.hasGit = true;
153
+ return out;
154
+ }
155
+ for (const entry of entries) {
156
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
157
+ if (!shouldEnroll(relPath)) continue;
158
+ let stat;
159
+ try {
160
+ stat = fs.lstatSync(path.join(rootPath, relPath));
161
+ } catch (error) {
162
+ // Vanished mid-look is honest absence (simply not listed); a
163
+ // failed look concludes nothing — the full walk reads again.
164
+ if (lookFailed(error).truth === 'unsettled') out.unsettled = true;
165
+ continue;
166
+ }
167
+ if (stat.isDirectory()) {
168
+ out.dirs.push({ relPath, device: stat.dev, inode: stat.ino });
169
+ } else if (stat.isSymbolicLink()) {
170
+ try {
171
+ out.links.push({ relPath, device: stat.dev, inode: stat.ino, target: fs.readlinkSync(path.join(rootPath, relPath)) });
172
+ } catch (error) {
173
+ if (lookFailed(error).truth === 'unsettled') out.unsettled = true;
174
+ }
175
+ } else if (stat.isFile()) {
176
+ out.files.push({
177
+ relPath,
178
+ device: stat.dev,
179
+ inode: stat.ino,
180
+ size: stat.size,
181
+ mtimeMs: Math.floor(stat.mtimeMs),
182
+ });
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+
129
188
  function defaultShouldEnroll(relPath) {
130
189
  return require('../workspace/tree/inclusion').shouldEnroll(relPath);
131
190
  }
@@ -384,4 +443,4 @@ function diffRootState(rows, entries, mustCheck = null) {
384
443
  return actions;
385
444
  }
386
445
 
387
- module.exports = { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot };
446
+ module.exports = { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot };