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
@@ -39,14 +39,17 @@ function pidIsRunning(pid) {
39
39
  */
40
40
  function bridgeFileCandidates() {
41
41
  const path = require('path');
42
- const os = require('os');
43
42
  const dirs = [];
44
43
  if (process.env.AMALGM_RUNTIME_STATE_DIR) dirs.push(process.env.AMALGM_RUNTIME_STATE_DIR);
45
44
  const amalgmDir = process.env.AMALGM_DIR;
46
45
  const label = process.env.AMALGM_RUNTIME_LABEL || process.env.AMALGM_BRANCH;
47
46
  if (amalgmDir && label) dirs.push(path.join(amalgmDir, 'runtimes', label));
48
47
  if (amalgmDir) dirs.push(amalgmDir);
49
- dirs.push(path.join(os.homedir(), '.amalgm'));
48
+ else {
49
+ try {
50
+ dirs.push(require('../../lib/runtime-paths').amalgmDir());
51
+ } catch {}
52
+ }
50
53
  return [...new Set(dirs)].map((dir) => path.join(dir, 'electron-browser-bridge.json'));
51
54
  }
52
55
 
@@ -20,12 +20,13 @@ const fs = require('fs');
20
20
  const os = require('os');
21
21
  const path = require('path');
22
22
  const { spawn } = require('child_process');
23
+ const { amalgmDir: resolveAmalgmDir } = require('../../lib/runtime-paths');
23
24
 
24
25
  const DEFAULT_TIMEOUT_MS = 120_000;
25
26
  const DEFAULT_SESSION = 'default';
26
27
 
27
28
  function amalgmDir() {
28
- return process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
29
+ return resolveAmalgmDir();
29
30
  }
30
31
 
31
32
  function ensureDir(dir) {
@@ -9,18 +9,21 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const os = require('os');
12
+ const runtimePaths = require('../lib/runtime-paths');
12
13
 
13
14
  const PORT = parseInt(process.env.AMALGM_MCP_PORT || '8083', 10);
14
15
  const MCP_PROTOCOL_VERSION = '2024-11-05';
15
16
  const CLI_HOMES_BACKFILL_ID = 'cli-homes-merge-v1';
17
+ const WORKSPACES_BACKFILL_ID = 'workspaces-merge-v1';
18
+ const CONTEXT_BACKFILL_ID = 'context-merge-v1';
16
19
 
17
- // Layout-aware home: visible ~/amalgm once initialized, legacy ~/.amalgm
18
- // otherwise. Explicit env always wins (isolated runtimes, tests).
20
+ // Layout-aware home: visible ~/amalgm once initialized. User storage resolves
21
+ // under users/<identity>; the root is only installation/device state.
19
22
  const AMALGM_HOME = require('./lib/layout').resolveAmalgmHome(process.env);
20
- const AMALGM_DIR = process.env.AMALGM_DIR || AMALGM_HOME;
23
+ const AMALGM_DIR = process.env.AMALGM_DIR || runtimePaths.amalgmDir();
21
24
  const AMALGM_RUNTIME_LABEL = normalizeRuntimeLabel(process.env.AMALGM_RUNTIME_LABEL || process.env.AMALGM_BRANCH);
22
25
  const AMALGM_RUNTIME_STATE_DIR = process.env.AMALGM_RUNTIME_STATE_DIR
23
- || path.join(AMALGM_DIR, 'runtimes', AMALGM_RUNTIME_LABEL);
26
+ || runtimePaths.runtimeStateDir();
24
27
 
25
28
  // Storage file/dir layout under AMALGM_DIR.
26
29
  const STORAGE_DIR = AMALGM_DIR; // tasks.json etc. live directly under ~/.amalgm
@@ -179,6 +182,8 @@ function migrateLegacyUserState() {
179
182
  const hadMarker = Boolean(markerData);
180
183
  const completedMigrations = markerCompletedMigrations(markerData);
181
184
  const needsCliHomesBackfill = !completedMigrations.has(CLI_HOMES_BACKFILL_ID);
185
+ const needsWorkspacesBackfill = !completedMigrations.has(WORKSPACES_BACKFILL_ID);
186
+ const needsContextBackfill = !completedMigrations.has(CONTEXT_BACKFILL_ID);
182
187
  fs.mkdirSync(AMALGM_DIR, { recursive: true, mode: 0o700 });
183
188
 
184
189
  const copied = [];
@@ -208,7 +213,23 @@ function migrateLegacyUserState() {
208
213
  completedMigrations.add(CLI_HOMES_BACKFILL_ID);
209
214
  }
210
215
 
211
- if (!hadMarker || copied.length > 0 || needsCliHomesBackfill) {
216
+ if (needsWorkspacesBackfill) {
217
+ const workspacesSource = path.join(AMALGM_HOME, 'workspaces');
218
+ const workspacesTarget = path.join(AMALGM_DIR, 'workspaces');
219
+ if (mergeDirContents(workspacesSource, workspacesTarget)) copied.push('workspaces/');
220
+ completedMigrations.add(WORKSPACES_BACKFILL_ID);
221
+ }
222
+
223
+ if (needsContextBackfill) {
224
+ // System context (user-authored system-instructions.md) is the singular
225
+ // context/ dir; earlier passes only migrated per-project state.
226
+ const contextSource = path.join(AMALGM_HOME, 'context');
227
+ const contextTarget = path.join(AMALGM_DIR, 'context');
228
+ if (mergeDirContents(contextSource, contextTarget)) copied.push('context/');
229
+ completedMigrations.add(CONTEXT_BACKFILL_ID);
230
+ }
231
+
232
+ if (!hadMarker || copied.length > 0 || needsCliHomesBackfill || needsWorkspacesBackfill || needsContextBackfill) {
212
233
  fs.writeFileSync(marker, `${JSON.stringify({
213
234
  migrated_at: markerData?.migrated_at || new Date().toISOString(),
214
235
  source: AMALGM_HOME,
@@ -6,6 +6,7 @@ const os = require('os');
6
6
  const path = require('path');
7
7
  const { spawnSync } = require('child_process');
8
8
  const yaml = require('js-yaml');
9
+ const { resolveAmalgmHome } = require('../lib/layout');
9
10
 
10
11
  const ENGINE_REPO_FULL_NAME = 'amalgm-inc/amalgm-engine';
11
12
  const UI_REPO_FULL_NAME = 'amalgm-inc/amalgm-ui';
@@ -13,8 +14,9 @@ const DEFAULT_DESKTOP_RELEASE_REPO_FULL_NAME = 'amalgm-inc/amalgm-desktop-releas
13
14
  const DEFAULT_DESKTOP_LEGACY_BRIDGE_REPO_FULL_NAME = ENGINE_REPO_FULL_NAME;
14
15
  const DEFAULT_ENGINE_REPO_URL = 'https://github.com/amalgm-inc/amalgm-engine.git';
15
16
  const DEFAULT_UI_REPO_URL = 'https://github.com/amalgm-inc/amalgm-ui.git';
16
- const DEFAULT_CHECKOUT_ROOT = path.join(os.homedir(), '.amalgm', 'workflow-checkouts', 'amalgm-desktop-release');
17
- const DEFAULT_ENV_FILE = path.join(os.homedir(), '.amalgm', 'desktop-release.env');
17
+ const AMALGM_HOME = resolveAmalgmHome(process.env);
18
+ const DEFAULT_CHECKOUT_ROOT = path.join(AMALGM_HOME, 'workflow-checkouts', 'amalgm-desktop-release');
19
+ const DEFAULT_ENV_FILE = path.join(AMALGM_HOME, 'desktop-release.env');
18
20
  const DEFAULT_CSC_NAME = 'amalgm, Inc. (C5S6UATV3L)';
19
21
  const DEFAULT_DESKTOP_UPDATE_BASE_URL = 'https://amalgm-desktop-releases.fly.dev';
20
22
  const DEFAULT_DESKTOP_RELEASE_FLY_APP = 'amalgm-desktop-releases';
@@ -119,6 +121,29 @@ function notarytoolCredentialArgs(env = process.env) {
119
121
  throw new Error('Missing Apple notarization credentials for manual desktop release notarization.');
120
122
  }
121
123
 
124
+ function notarytoolSubmitTransportArgs(env = process.env) {
125
+ const raw = cleanString(env.AMALGM_DESKTOP_NOTARY_S3_ACCELERATION).toLowerCase();
126
+ if (!raw || ['0', 'false', 'no', 'off'].includes(raw)) return ['--no-s3-acceleration'];
127
+ if (['1', 'true', 'yes', 'on'].includes(raw)) return [];
128
+ throw new Error(`Unsupported AMALGM_DESKTOP_NOTARY_S3_ACCELERATION value: ${raw}`);
129
+ }
130
+
131
+ function prepareNotarySubmissionFile(filePath) {
132
+ const ext = path.extname(filePath) || '.dmg';
133
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-notary-submit-'));
134
+ const tempPath = path.join(
135
+ tempRoot,
136
+ `amalgm-notary-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`,
137
+ );
138
+ fs.copyFileSync(filePath, tempPath);
139
+ return {
140
+ filePath: tempPath,
141
+ cleanup() {
142
+ fs.rmSync(tempRoot, { recursive: true, force: true });
143
+ },
144
+ };
145
+ }
146
+
122
147
  function parseRepoFullName(value) {
123
148
  const raw = cleanString(value);
124
149
  const match = /^([^/\s]+)\/([^/\s]+)$/.exec(raw);
@@ -684,6 +709,13 @@ function envForBuild(
684
709
  DESKTOP_RELEASE_LANE: request.lane,
685
710
  DESKTOP_RELEASE_CHANNEL: request.lane === 'preview' ? 'preview' : 'latest',
686
711
  AMALGM_DESKTOP_THIN: desktopThinRuntimeBuildEnabled(baseEnv) ? '1' : '0',
712
+ AMALGM_DESKTOP_RENDERER_DELIVERY: desktopRendererDelivery(baseEnv),
713
+ AMALGM_DESKTOP_RUNTIME_DELIVERY: desktopRuntimeDelivery(baseEnv),
714
+ ...(desktopRendererDelivery(baseEnv) === 'bundled-next' ? {
715
+ NEXT_IMAGE_UNOPTIMIZED: '1',
716
+ NEXT_PUBLIC_SUPABASE_URL: '',
717
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: '',
718
+ } : {}),
687
719
  AMALGM_RUNTIME_LABEL: request.lane === 'preview' ? 'preview' : 'main',
688
720
  AMALGM_BRANCH: request.lane,
689
721
  ELECTRON_VERSION: version,
@@ -737,6 +769,26 @@ function desktopThinRuntimeBuildEnabled(env = process.env) {
737
769
  throw new Error(`Unsupported AMALGM_DESKTOP_THIN value: ${raw}`);
738
770
  }
739
771
 
772
+ function desktopRendererDelivery(env = process.env) {
773
+ const raw = cleanString(
774
+ env.AMALGM_DESKTOP_RENDERER_DELIVERY
775
+ || env.AMALGM_DESKTOP_UI_DELIVERY
776
+ || (cleanString(env.AMALGM_DESKTOP_UI_MODE).toLowerCase() === 'local' ? 'bundled-next' : ''),
777
+ ).toLowerCase();
778
+ if (!raw) return desktopThinRuntimeBuildEnabled(env) ? 'hosted' : 'bundled-next';
779
+ if (['hosted', 'remote', 'web'].includes(raw)) return 'hosted';
780
+ if (['bundled-next', 'bundled', 'local', 'next', 'local-next'].includes(raw)) return 'bundled-next';
781
+ throw new Error(`Unsupported AMALGM_DESKTOP_RENDERER_DELIVERY value: ${raw}`);
782
+ }
783
+
784
+ function desktopRuntimeDelivery(env = process.env) {
785
+ const raw = cleanString(env.AMALGM_DESKTOP_RUNTIME_DELIVERY).toLowerCase();
786
+ if (!raw) return desktopThinRuntimeBuildEnabled(env) ? 'npm' : 'bundled-with-npm-handoff';
787
+ if (['npm', 'managed-npm'].includes(raw)) return 'npm';
788
+ if (['bundled-with-npm-handoff', 'bundled', 'local', 'embedded'].includes(raw)) return 'bundled-with-npm-handoff';
789
+ throw new Error(`Unsupported AMALGM_DESKTOP_RUNTIME_DELIVERY value: ${raw}`);
790
+ }
791
+
740
792
  function appOriginForLane(lane, env = process.env) {
741
793
  if (lane === 'preview') {
742
794
  return cleanString(env.AMALGM_DESKTOP_PREVIEW_APP_ORIGIN)
@@ -749,14 +801,8 @@ function appOriginForLane(lane, env = process.env) {
749
801
  }
750
802
 
751
803
  function writeBundledWebEnv(engineDir, lane, env = process.env) {
752
- const supabaseUrl = cleanString(env.NEXT_PUBLIC_SUPABASE_URL);
753
- const supabaseAnonKey = cleanString(env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
754
- if (!supabaseUrl) throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL for bundled desktop web app.');
755
- if (!supabaseAnonKey) throw new Error('Missing NEXT_PUBLIC_SUPABASE_ANON_KEY for bundled desktop web app.');
756
804
  const appOrigin = appOriginForLane(lane, env);
757
805
  fs.writeFileSync(path.join(engineDir, 'web', '.env'), [
758
- `NEXT_PUBLIC_SUPABASE_URL=${supabaseUrl}`,
759
- `NEXT_PUBLIC_SUPABASE_ANON_KEY=${supabaseAnonKey}`,
760
806
  `NEXT_PUBLIC_SITE_URL=${appOrigin}`,
761
807
  `NEXT_PUBLIC_APP_URL=${appOrigin}`,
762
808
  `NEXT_PUBLIC_AMALGM_RUNTIME_LABEL=${lane === 'preview' ? 'preview' : 'main'}`,
@@ -1015,46 +1061,52 @@ function waitForNotaryDecision(submissionId, artifactName, cwd, env, authArgs) {
1015
1061
  function submitArtifactForNotarization(filePath, cwd, env) {
1016
1062
  const { args: authArgs, credentialType } = notarytoolCredentialArgs(env);
1017
1063
  const startedAtMs = Date.now();
1018
- const artifactName = path.basename(filePath);
1019
- const result = runResult('xcrun', [
1020
- 'notarytool',
1021
- 'submit',
1022
- filePath,
1023
- ...authArgs,
1024
- '--no-progress',
1025
- '--output-format',
1026
- 'json',
1027
- ], {
1028
- cwd,
1029
- env,
1030
- maxBuffer: 10 * 1024 * 1024,
1031
- });
1064
+ const submissionFile = prepareNotarySubmissionFile(filePath);
1065
+ const artifactName = path.basename(submissionFile.filePath);
1066
+ try {
1067
+ const result = runResult('xcrun', [
1068
+ 'notarytool',
1069
+ 'submit',
1070
+ submissionFile.filePath,
1071
+ ...notarytoolSubmitTransportArgs(env),
1072
+ ...authArgs,
1073
+ '--no-progress',
1074
+ '--output-format',
1075
+ 'json',
1076
+ ], {
1077
+ cwd,
1078
+ env,
1079
+ maxBuffer: 10 * 1024 * 1024,
1080
+ });
1032
1081
 
1033
- let submissionId = '';
1034
- if (cleanString(result.stdout)) {
1035
- try {
1036
- submissionId = parseNotarySubmissionId(parseJsonOutput(result.stdout, 'notarytool submit'));
1037
- } catch {
1038
- submissionId = '';
1082
+ let submissionId = '';
1083
+ if (cleanString(result.stdout)) {
1084
+ try {
1085
+ submissionId = parseNotarySubmissionId(parseJsonOutput(result.stdout, 'notarytool submit'));
1086
+ } catch {
1087
+ submissionId = '';
1088
+ }
1089
+ }
1090
+ if (!submissionId) {
1091
+ submissionId = parseNotarySubmissionId(waitForNotarySubmission(artifactName, startedAtMs, cwd, env, authArgs));
1092
+ }
1093
+ if (!submissionId) {
1094
+ throw new Error(`Failed to resolve notary submission id for ${artifactName}.`);
1095
+ }
1096
+ if (result.status !== 0) {
1097
+ console.warn(`notarytool submit exited with code ${result.status} for ${artifactName}; continuing with submission ${submissionId}.`);
1039
1098
  }
1040
- }
1041
- if (!submissionId) {
1042
- submissionId = parseNotarySubmissionId(waitForNotarySubmission(artifactName, startedAtMs, cwd, env, authArgs));
1043
- }
1044
- if (!submissionId) {
1045
- throw new Error(`Failed to resolve notary submission id for ${artifactName}.`);
1046
- }
1047
- if (result.status !== 0) {
1048
- console.warn(`notarytool submit exited with code ${result.status} for ${artifactName}; continuing with submission ${submissionId}.`);
1049
- }
1050
1099
 
1051
- const info = waitForNotaryDecision(submissionId, artifactName, cwd, env, authArgs);
1052
- return {
1053
- id: submissionId,
1054
- status: parseNotaryStatus(info),
1055
- credentialType,
1056
- info,
1057
- };
1100
+ const info = waitForNotaryDecision(submissionId, artifactName, cwd, env, authArgs);
1101
+ return {
1102
+ id: submissionId,
1103
+ status: parseNotaryStatus(info),
1104
+ credentialType,
1105
+ info,
1106
+ };
1107
+ } finally {
1108
+ submissionFile.cleanup();
1109
+ }
1058
1110
  }
1059
1111
 
1060
1112
  function manualNotarizeDesktopArtifacts({ engineDir, lane, env, artifactPaths, channelPath }) {
@@ -1701,18 +1753,30 @@ function publishDesktopRelease(request, options = {}) {
1701
1753
  run('git', ['push', 'origin', `refs/tags/v${version}`, '--force'], { cwd: engineDir });
1702
1754
  run('npm', ['version', '--no-git-tag-version', '--allow-same-version', version], { cwd: engineDir, env: releaseEnv });
1703
1755
  run('npm', ['run', 'compile'], { cwd: engineDir, env: releaseEnv });
1704
- const thinRuntimeBuild = desktopThinRuntimeBuildEnabled(releaseEnv);
1756
+ const rendererDelivery = desktopRendererDelivery(releaseEnv);
1757
+ const runtimeDelivery = desktopRuntimeDelivery(releaseEnv);
1758
+ const hostedRenderer = rendererDelivery === 'hosted';
1759
+ const npmRuntime = runtimeDelivery === 'npm';
1705
1760
  let appOrigin = appOriginForLane(request.lane, releaseEnv);
1706
- if (thinRuntimeBuild) {
1707
- if (!cleanString(releaseEnv.NEXT_PUBLIC_SUPABASE_URL)) {
1708
- throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL for thin desktop auth metadata.');
1709
- }
1710
- console.log('Building thin hosted-UI desktop shell; skipping bundled runtime and Next.js renderer payload.');
1761
+ if (hostedRenderer) {
1762
+ console.log('Building hosted-UI desktop shell; skipping bundled Next.js renderer payload.');
1711
1763
  } else {
1712
- run('npm', ['run', 'npm:sync'], { cwd: engineDir, env: releaseEnv });
1713
1764
  copyUiIntoEngine(uiDir, engineDir);
1714
1765
  appOrigin = writeBundledWebEnv(engineDir, request.lane, releaseEnv);
1715
- run('npm', ['run', 'build:next'], { cwd: engineDir, env: releaseEnv });
1766
+ run('npm', ['run', 'build:next'], {
1767
+ cwd: engineDir,
1768
+ env: {
1769
+ ...releaseEnv,
1770
+ NEXT_IMAGE_UNOPTIMIZED: '1',
1771
+ NEXT_PUBLIC_SUPABASE_URL: '',
1772
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: '',
1773
+ },
1774
+ });
1775
+ }
1776
+ if (npmRuntime) {
1777
+ console.log('Building desktop shell with managed npm runtime; skipping bundled engine runtime payload.');
1778
+ } else {
1779
+ run('npm', ['run', 'npm:sync'], { cwd: engineDir, env: releaseEnv });
1716
1780
  }
1717
1781
  if (request.lane === 'preview') run('npm', ['run', 'assets:preview-icon'], { cwd: engineDir, env: releaseEnv });
1718
1782
 
@@ -1826,7 +1890,9 @@ module.exports = {
1826
1890
  envForBuild,
1827
1891
  manualDesktopNotarizationEnabled,
1828
1892
  notarytoolCredentialArgs,
1893
+ notarytoolSubmitTransportArgs,
1829
1894
  parseEnvFile,
1895
+ prepareNotarySubmissionFile,
1830
1896
  parseUploadOffsetMismatch,
1831
1897
  publishLegacyGithubBridgeRelease,
1832
1898
  publishDesktopRelease,
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
- const os = require('os');
5
4
  const path = require('path');
5
+ const { resolveAmalgmHome } = require('../lib/layout');
6
6
 
7
7
  function findEngineRepoPath() {
8
8
  const explicit = process.env.AMALGM_ENGINE_REPO_PATH;
@@ -137,7 +137,7 @@ function engineNpmPublishWorkflow(repoPath) {
137
137
  function internalWorkflowSeeds() {
138
138
  if (!findEngineRepoPath()) return [];
139
139
  const repoPath = process.env.AMALGM_ENGINE_PUBLISH_REPO_PATH
140
- || path.join(os.homedir(), '.amalgm', 'workflow-checkouts', 'amalgm-engine-publish');
140
+ || path.join(resolveAmalgmHome(process.env), 'workflow-checkouts', 'amalgm-engine-publish');
141
141
 
142
142
  return [
143
143
  {
@@ -4,11 +4,13 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { spawnSync } = require('child_process');
7
+ const { resolveAmalgmHome } = require('../lib/layout');
7
8
 
8
9
  const ENGINE_REPO_FULL_NAME = 'amalgm-inc/amalgm-engine';
9
10
  const DEFAULT_ENGINE_REPO_URL = 'https://github.com/amalgm-inc/amalgm-engine.git';
10
- const DEFAULT_CHECKOUT_ROOT = path.join(os.homedir(), '.amalgm', 'workflow-checkouts', 'amalgm-npm-release');
11
- const DEFAULT_ENV_FILE = path.join(os.homedir(), '.amalgm', 'npm-release.env');
11
+ const AMALGM_HOME = resolveAmalgmHome(process.env);
12
+ const DEFAULT_CHECKOUT_ROOT = path.join(AMALGM_HOME, 'workflow-checkouts', 'amalgm-npm-release');
13
+ const DEFAULT_ENV_FILE = path.join(AMALGM_HOME, 'npm-release.env');
12
14
 
13
15
  function cleanString(value) {
14
16
  return typeof value === 'string' ? value.trim() : '';
@@ -4,13 +4,15 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { spawnSync } = require('child_process');
7
+ const { resolveAmalgmHome } = require('../lib/layout');
7
8
 
8
9
  const ENGINE_REPO_FULL_NAME = 'amalgm-inc/amalgm-engine';
9
10
  const UI_REPO_FULL_NAME = 'amalgm-inc/amalgm-ui';
10
11
  const DEFAULT_ENGINE_REPO_URL = 'https://github.com/amalgm-inc/amalgm-engine.git';
11
12
  const DEFAULT_UI_REPO_URL = 'https://github.com/amalgm-inc/amalgm-ui.git';
12
- const DEFAULT_CHECKOUT_ROOT = path.join(os.homedir(), '.amalgm', 'workflow-checkouts', 'amalgm-pr-check');
13
- const DEFAULT_ENV_FILE = path.join(os.homedir(), '.amalgm', 'pr-check.env');
13
+ const AMALGM_HOME = resolveAmalgmHome(process.env);
14
+ const DEFAULT_CHECKOUT_ROOT = path.join(AMALGM_HOME, 'workflow-checkouts', 'amalgm-pr-check');
15
+ const DEFAULT_ENV_FILE = path.join(AMALGM_HOME, 'pr-check.env');
14
16
  const DEFAULT_STATUS_CONTEXT = 'amalgm/pr-check';
15
17
 
16
18
  function cleanString(value) {
@@ -61,8 +61,26 @@ function isHiddenFilesystemEntry(name) {
61
61
 
62
62
  function expandHome(targetPath) {
63
63
  if (targetPath === '~') return os.homedir();
64
+ // Platform-data token: clients address the user's amalgm data dir without
65
+ // knowing the on-disk layout (email folder, machine differences). This is
66
+ // the contract for uploads and md-library paths from the UI.
67
+ if (targetPath === '~amalgm') return AMALGM_DIR;
68
+ if (typeof targetPath === 'string' && targetPath.startsWith('~amalgm/')) {
69
+ return path.join(AMALGM_DIR, targetPath.slice('~amalgm/'.length));
70
+ }
64
71
  if (typeof targetPath === 'string' && targetPath.startsWith('~/')) {
65
- return path.join(os.homedir(), targetPath.slice(2));
72
+ const expanded = path.join(os.homedir(), targetPath.slice(2));
73
+ // Transitional wire-compat: UI builds deployed before 2026-07-03 address
74
+ // platform data via the old hidden layout (uploads, notes-files,
75
+ // browser-sessions, ...). Redirect the whole legacy prefix into the user
76
+ // data dir — the wire-level equivalent of the removed ~/.amalgm symlink —
77
+ // so the hidden dir is never recreated on disk. Remove once all deployed
78
+ // UIs send ~amalgm/... paths.
79
+ const legacyHiddenRoot = path.join(os.homedir(), '.amalgm');
80
+ if (expanded === legacyHiddenRoot || expanded.startsWith(legacyHiddenRoot + path.sep)) {
81
+ return path.join(AMALGM_DIR, path.relative(legacyHiddenRoot, expanded));
82
+ }
83
+ return expanded;
66
84
  }
67
85
  return targetPath;
68
86
  }
@@ -772,6 +790,7 @@ module.exports = {
772
790
  handleWrite,
773
791
  _private: {
774
792
  buildFilesResource,
793
+ expandHome,
775
794
  buildUploadsResource,
776
795
  configuredRootPaths,
777
796
  filesResourceName,
@@ -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,
@@ -1,10 +1,10 @@
1
1
  const fs = require('fs');
2
- const os = require('os');
3
2
  const path = require('path');
4
3
 
5
4
  const { buildSessionConfig, getMcpApp } = require('../../../lib/mcpApps/registry.js');
5
+ const { amalgmDir } = require('../../lib/runtime-paths');
6
6
 
7
- const CONNECTIONS_FILE = path.join(os.homedir(), '.amalgm', 'mcp-connections.json');
7
+ const CONNECTIONS_FILE = path.join(process.env.AMALGM_DIR || amalgmDir(), 'mcp-connections.json');
8
8
 
9
9
  function readConnectionsFile() {
10
10
  try {
@@ -155,15 +155,37 @@ function resolveProject(inputPath) {
155
155
 
156
156
  function projectFromInput(input) {
157
157
  if (input && typeof input === 'object' && cleanString(input.path)) {
158
+ // Workspace records must go through the same path canonicalization as
159
+ // string inputs. Boot/list passes feed raw row paths here; without this,
160
+ // a legacy managed-copy path and its canonical checkout fight over the
161
+ // same project row (each pass re-pins projectPath to its own view).
162
+ const canonical = canonicalProjectPath(input.path) || path.resolve(input.path);
158
163
  return {
159
164
  id: cleanString(input.id) || null,
160
- path: path.resolve(input.path),
161
- name: cleanString(input.name) || path.basename(path.resolve(input.path)),
165
+ path: canonical,
166
+ name: cleanString(input.name) || path.basename(canonical),
162
167
  };
163
168
  }
164
169
  return resolveProject(input);
165
170
  }
166
171
 
172
+ function unregisteredProjectError(inputPath) {
173
+ const requested = cleanString(typeof inputPath === 'string' ? inputPath : inputPath?.path) || '(no path)';
174
+ let known = [];
175
+ try {
176
+ known = require('../workspace/store')
177
+ .listWorkspaces()
178
+ .map((workspace) => workspace?.path)
179
+ .filter(Boolean);
180
+ } catch {
181
+ // Listing is best-effort; the error is still actionable without it.
182
+ }
183
+ const hint = known.length
184
+ ? ` Registered project roots: ${known.join(', ')}.`
185
+ : ' No workspaces are registered on this computer.';
186
+ return new Error(`No registered Amalgm project contains: ${requested}.${hint} Register the checkout as a workspace or pass projectPath explicitly.`);
187
+ }
188
+
167
189
  function atomicWrite(filePath, content) {
168
190
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
169
191
  const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
@@ -481,7 +503,7 @@ function listProjectContexts(options = {}) {
481
503
  function appendChangeLog(inputPath, body, options = {}) {
482
504
  const project = projectFromInput(inputPath);
483
505
  if (!project?.id) {
484
- throw new Error(`No registered Amalgm project contains: ${cleanString(typeof inputPath === 'string' ? inputPath : inputPath?.path) || '(no path)'}`);
506
+ throw unregisteredProjectError(inputPath);
485
507
  }
486
508
  if (isSystemProject(project)) {
487
509
  throw new Error('The system context has system-instructions.md only — change logs are per-project.');
@@ -511,7 +533,7 @@ function appendChangeLog(inputPath, body, options = {}) {
511
533
  function writeContextDoc(inputPath, docKey, content, options = {}) {
512
534
  const project = projectFromInput(inputPath);
513
535
  if (!project?.id) {
514
- throw new Error(`No registered Amalgm project contains: ${cleanString(typeof inputPath === 'string' ? inputPath : inputPath?.path) || '(no path)'}`);
536
+ throw unregisteredProjectError(inputPath);
515
537
  }
516
538
  if (isSystemProject(project) && docKey !== 'instructions') {
517
539
  throw new Error('The system context has system-instructions.md only — project goals, project state, and change logs are per-project.');
@@ -599,7 +621,7 @@ function upsertNoteSection(current, title, body) {
599
621
  function upsertProjectNote(inputPath, title, body, options = {}) {
600
622
  const project = projectFromInput(inputPath);
601
623
  if (!project?.id) {
602
- throw new Error(`No registered Amalgm project contains: ${cleanString(typeof inputPath === 'string' ? inputPath : inputPath?.path) || '(no path)'}`);
624
+ throw unregisteredProjectError(inputPath);
603
625
  }
604
626
  if (isSystemProject(project)) {
605
627
  throw new Error('The system context has system-instructions.md only — project notes are per-project.');
@@ -120,3 +120,16 @@ test('agents create REST preserves caller id and creates per-agent config', asyn
120
120
  assert.equal(response.payload.agent.config.runtimeHomeLayout, 'per-agent');
121
121
  assert.equal(getAgentConfig(agentId).runtimeHomeLayout, 'per-agent');
122
122
  });
123
+
124
+ test('freshly seeded builtin agents carry the default platform loadout including memories', () => {
125
+ const { DEFAULT_BUILTIN_TOOL_IDS } = require('../agents/store');
126
+ assert.ok(DEFAULT_BUILTIN_TOOL_IDS.includes('memories'));
127
+ for (const id of ['claude_code', 'codex']) {
128
+ const agent = getAllAgentsWithBuiltins().find((candidate) => candidate.id === id);
129
+ assert.ok(agent, `${id} should exist`);
130
+ assert.ok(
131
+ agent.loadout.toolIds.includes('memories'),
132
+ `${id} loadout should include memories, got: ${JSON.stringify(agent.loadout.toolIds)}`,
133
+ );
134
+ }
135
+ });