amalgm 0.1.88 → 0.1.89

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 (45) hide show
  1. package/lib/cli.js +16 -6
  2. package/lib/service.js +19 -0
  3. package/lib/supervisor.js +51 -0
  4. package/package.json +1 -1
  5. package/runtime/scripts/amalgm-mcp/apps/supervisor.js +0 -25
  6. package/runtime/scripts/amalgm-mcp/browser/auth-tools.js +179 -0
  7. package/runtime/scripts/amalgm-mcp/browser/cli.js +217 -0
  8. package/runtime/scripts/amalgm-mcp/browser/cua-tools.js +147 -0
  9. package/runtime/scripts/amalgm-mcp/browser/engine.js +454 -0
  10. package/runtime/scripts/amalgm-mcp/browser/recorder-tools.js +82 -0
  11. package/runtime/scripts/amalgm-mcp/browser/recorder.js +199 -0
  12. package/runtime/scripts/amalgm-mcp/browser/rest.js +5 -1
  13. package/runtime/scripts/amalgm-mcp/browser/tools.js +104 -731
  14. package/runtime/scripts/amalgm-mcp/fs/rest.js +5 -6
  15. package/runtime/scripts/amalgm-mcp/index.js +2 -0
  16. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +32 -66
  17. package/runtime/scripts/amalgm-mcp/project-context/paths.js +266 -0
  18. package/runtime/scripts/amalgm-mcp/project-context/prompt.js +232 -0
  19. package/runtime/scripts/amalgm-mcp/project-context/rest.js +100 -0
  20. package/runtime/scripts/amalgm-mcp/project-context/store.js +671 -0
  21. package/runtime/scripts/amalgm-mcp/project-context/tools.js +146 -0
  22. package/runtime/scripts/amalgm-mcp/server/core-tools.js +35 -2
  23. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  24. package/runtime/scripts/amalgm-mcp/server/routes/project-context.js +33 -0
  25. package/runtime/scripts/amalgm-mcp/state/db.js +11 -0
  26. package/runtime/scripts/amalgm-mcp/state/snapshot.js +3 -3
  27. package/runtime/scripts/amalgm-mcp/tests/core-tools.test.js +37 -4
  28. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +328 -0
  29. package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +9 -9
  30. package/runtime/scripts/amalgm-mcp/tests/toolbox-service.test.js +2 -2
  31. package/runtime/scripts/amalgm-mcp/toolbox/loadout-context.js +1 -0
  32. package/runtime/scripts/amalgm-mcp/toolbox/store.js +0 -15
  33. package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +2 -0
  34. package/runtime/scripts/amalgm-mcp/workspace/rest.js +11 -7
  35. package/runtime/scripts/chat-core/adapters/opencode.js +1 -1
  36. package/runtime/scripts/chat-core/engine.js +16 -2
  37. package/runtime/scripts/chat-core/input.js +19 -1
  38. package/runtime/scripts/chat-core/tool-shape.js +6 -4
  39. package/runtime/scripts/chat-core/tooling/system-instructions.js +51 -0
  40. package/runtime/scripts/chat-core/tooling/system-prompt.js +34 -8
  41. package/runtime/scripts/local-gateway.js +1 -0
  42. package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +0 -569
  43. package/runtime/scripts/amalgm-mcp/browser/page.js +0 -25
  44. package/runtime/scripts/chat-core/tooling/active-memory.js +0 -574
  45. package/runtime/scripts/chat-core/tooling/passive-memory.js +0 -576
package/lib/cli.js CHANGED
@@ -65,6 +65,7 @@ const {
65
65
  ensureNativeBinaries,
66
66
  } = require('../runtime/scripts/chat-core/tooling/native-binaries');
67
67
  const {
68
+ compareVersions,
68
69
  shouldAutoUpdate,
69
70
  updatePackage,
70
71
  } = require('./updater');
@@ -713,18 +714,27 @@ function runningDaemonRestartReason(pid, record, localOnly) {
713
714
  const supervisorPid = Number(state?.supervisor_pid || state?.pid || 0);
714
715
  if (!state || supervisorPid !== pid) return 'runtime ownership state is missing';
715
716
  if (state.source && state.source !== 'npm') return `it is owned by ${state.source}`;
716
- if (state.package_version && state.package_version !== PACKAGE_VERSION) {
717
- return `it is running package ${state.package_version}, not ${PACKAGE_VERSION}`;
718
- }
719
- if (state.runtime_dir && state.runtime_dir !== RUNTIME_DIR) {
720
- return 'it is running a different package install';
721
- }
722
717
  if (!!state.local_only !== !!localOnly) {
723
718
  return localOnly ? 'it is running in registered mode' : 'it is running in local-only mode';
724
719
  }
725
720
  if (!localOnly && (state.computer_id || '') !== (record?.computer_id || '')) {
726
721
  return `it is connected as ${state.computer_id || 'another computer'}`;
727
722
  }
723
+ // An older CLI (e.g. the copy bundled inside the desktop app) must not
724
+ // restart a runtime from an equal-or-newer install: restarting it through
725
+ // startService() would re-home the service to the older tree and the
726
+ // auto-updater would restart-loop trying to escape it.
727
+ const runningNewerOrSame = state.package_version
728
+ ? compareVersions(state.package_version, PACKAGE_VERSION) >= 0
729
+ : false;
730
+ if (state.package_version && state.package_version !== PACKAGE_VERSION) {
731
+ if (runningNewerOrSame) return '';
732
+ return `it is running package ${state.package_version}, not ${PACKAGE_VERSION}`;
733
+ }
734
+ if (state.runtime_dir && state.runtime_dir !== RUNTIME_DIR) {
735
+ if (runningNewerOrSame) return '';
736
+ return 'it is running a different package install';
737
+ }
728
738
  return '';
729
739
  }
730
740
 
package/lib/service.js CHANGED
@@ -25,6 +25,7 @@ const {
25
25
  cleanupStaleRuntimeProcesses,
26
26
  RUNTIME_INSTANCE_ARG,
27
27
  } = require('./process-cleanup');
28
+ const { compareVersions } = require('./updater');
28
29
 
29
30
  const PACKAGE_VERSION = require('../package.json').version;
30
31
  const SERVICE_LABEL_SCOPE = `${AMALGM_RUNTIME_USER_SCOPE}.${AMALGM_RUNTIME_LABEL}`
@@ -634,9 +635,27 @@ function stateMatchesRequest(state, options = {}) {
634
635
  return true;
635
636
  }
636
637
 
638
+ // A service registration owned by an equal-or-newer install of this package
639
+ // must not be replaced by an older caller (e.g. the CLI bundled inside the
640
+ // desktop app re-registering the service over the auto-updated npm install).
641
+ // Re-pointing the service at an older tree makes the auto-updater restart-loop:
642
+ // it installs the newer version, restarts, and boots the older tree again.
643
+ // Explicit `amalgm service install` still takes over via installService().
644
+ function deferToExistingInstall(state, options = {}) {
645
+ if (!state || !state.backend || !state.bin || !state.package_version) return false;
646
+ if (state.package_root === PACKAGE_ROOT) return false;
647
+ if (!!state.local_only !== !!options.localOnly) return false;
648
+ const requested = normalizeMode(options.mode || 'auto');
649
+ if (requested !== 'auto' && requested !== state.backend) return false;
650
+ if (compareVersions(state.package_version, PACKAGE_VERSION) < 0) return false;
651
+ if (!fs.existsSync(state.bin)) return false;
652
+ return true;
653
+ }
654
+
637
655
  function ensureServiceInstalled(options = {}) {
638
656
  const state = readJson(SERVICE_STATE_FILE, null);
639
657
  if (stateMatchesRequest(state, options)) return state;
658
+ if (deferToExistingInstall(state, options)) return state;
640
659
  return installService(options);
641
660
  }
642
661
 
package/lib/supervisor.js CHANGED
@@ -51,6 +51,20 @@ const {
51
51
  updatePackage,
52
52
  } = require('./updater');
53
53
  const PACKAGE_VERSION = require('../package.json').version;
54
+ const PACKAGE_ROOT_DIR = path.join(__dirname, '..');
55
+
56
+ // npm installs land in the npm prefix, which is only the tree this process runs
57
+ // from when the runtime was started from that prefix. When the service points at
58
+ // another install (e.g. the CLI bundled inside the desktop app), the update can
59
+ // never apply by restarting, so restarting would loop forever.
60
+ function installedPackageVersionOnDisk() {
61
+ try {
62
+ const raw = fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8');
63
+ return JSON.parse(raw).version || null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
54
68
 
55
69
  const CHILD_RESTART_POLICY = {
56
70
  initialDelayMs: 1000,
@@ -532,6 +546,13 @@ function createAutoUpdateManager({ ports, runtimeToken, tunnels, foreground, req
532
546
  let timer = null;
533
547
  let inFlight = false;
534
548
  let installedUpdate = false;
549
+ // Survives restarts via the state file so a relaunched runtime does not
550
+ // re-install a target version that already failed to apply to this install.
551
+ let suppressedTarget = null;
552
+ const persistedAutoUpdateState = readJson(AUTO_UPDATE_STATE_FILE, null);
553
+ if (persistedAutoUpdateState?.status === 'restart_suppressed') {
554
+ suppressedTarget = persistedAutoUpdateState.target_version || null;
555
+ }
535
556
  const checkIntervalMs = numberFromEnv(
536
557
  'AMALGM_UPDATE_CHECK_INTERVAL_MS',
537
558
  AUTO_UPDATE_POLICY.checkIntervalMs,
@@ -663,6 +684,19 @@ function createAutoUpdateManager({ ports, runtimeToken, tunnels, foreground, req
663
684
  return;
664
685
  }
665
686
 
687
+ if (suppressedTarget && check.targetVersion === suppressedTarget) {
688
+ writeAutoUpdateState({
689
+ status: 'restart_suppressed',
690
+ current_version: PACKAGE_VERSION,
691
+ target_version: check.targetVersion,
692
+ tag: check.tag,
693
+ service_managed: serviceManaged,
694
+ last_checked_at: checkedAt,
695
+ last_error: `update ${check.targetVersion} does not apply to this install (running from ${PACKAGE_ROOT_DIR}); waiting for an install that includes it`,
696
+ });
697
+ return;
698
+ }
699
+
666
700
  log(`installing ${check.targetVersion} from ${check.tag}`);
667
701
  writeAutoUpdateState({
668
702
  status: 'installing',
@@ -681,6 +715,23 @@ function createAutoUpdateManager({ ports, runtimeToken, tunnels, foreground, req
681
715
  if (trimmed) log(trimmed, stream);
682
716
  },
683
717
  });
718
+ const diskVersion = installedPackageVersionOnDisk();
719
+ if (diskVersion !== check.targetVersion) {
720
+ suppressedTarget = check.targetVersion;
721
+ const reason = `installed ${check.targetVersion}, but this runtime still runs ${diskVersion || PACKAGE_VERSION} from ${PACKAGE_ROOT_DIR}; restart suppressed to avoid a restart loop`;
722
+ writeAutoUpdateState({
723
+ status: 'restart_suppressed',
724
+ current_version: PACKAGE_VERSION,
725
+ target_version: check.targetVersion,
726
+ tag: check.tag,
727
+ critical: isCriticalUpdate(check),
728
+ service_managed: serviceManaged,
729
+ last_checked_at: checkedAt,
730
+ last_error: reason,
731
+ });
732
+ log(reason, 'stderr');
733
+ return;
734
+ }
684
735
  installedUpdate = true;
685
736
  await waitForIdleAndRestart(check);
686
737
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.88",
3
+ "version": "0.1.89",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -9,7 +9,6 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const { spawn } = require('child_process');
11
11
  const { DEFAULT_CWD } = require('../config');
12
- const activeMemory = require('../../chat-core/tooling/active-memory');
13
12
  const { runtimePort } = require('../../../lib/runtime-manifest');
14
13
  const { syncAppRoutesToGateway } = require('./advertise');
15
14
  const {
@@ -330,22 +329,10 @@ async function registerApp(input) {
330
329
  const data = loadApps();
331
330
  data.apps.push(app);
332
331
  saveApps(data);
333
- activeMemory.ensureConstructMemory({
334
- type: 'app',
335
- id: app.id,
336
- name: app.name,
337
- projectPath: app.cwd,
338
- }, { source: 'app:register' });
339
332
 
340
333
  if (app.buildCommand) await runBuild(app);
341
334
  await startApp(app.id);
342
335
  const deployed = updateAppMeta(app.id, { lastDeployedAt: new Date().toISOString() });
343
- activeMemory.ensureConstructMemory({
344
- type: 'app',
345
- id: deployed.id,
346
- name: deployed.name,
347
- projectPath: deployed.cwd,
348
- }, { source: 'app:deploy' });
349
336
  return deployed;
350
337
  }
351
338
 
@@ -367,12 +354,6 @@ async function redeployApp(appId, updates = {}) {
367
354
  if (updates.keep_alive !== undefined) patch.keepAlive = updates.keep_alive !== false;
368
355
  if (updates.autostart !== undefined) patch.autostart = updates.autostart !== false;
369
356
  if (Object.keys(patch).length > 0) app = updateAppMeta(appId, patch);
370
- activeMemory.ensureConstructMemory({
371
- type: 'app',
372
- id: app.id,
373
- name: app.name,
374
- projectPath: app.cwd,
375
- }, { source: 'app:update' });
376
357
 
377
358
  if (!app.startCommand) throw new Error('startCommand is required');
378
359
  if (!fs.existsSync(app.cwd)) throw new Error(`cwd does not exist: ${app.cwd}`);
@@ -381,12 +362,6 @@ async function redeployApp(appId, updates = {}) {
381
362
  await stopApp(appId, { desiredState: 'running' });
382
363
  await startApp(appId);
383
364
  const deployed = updateAppMeta(appId, { lastDeployedAt: new Date().toISOString() });
384
- activeMemory.ensureConstructMemory({
385
- type: 'app',
386
- id: deployed.id,
387
- name: deployed.name,
388
- projectPath: deployed.cwd,
389
- }, { source: 'app:deploy' });
390
365
  return deployed;
391
366
  }
392
367
 
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Browser auth tools — durable profiles, encrypted auth bundles, and
5
+ * temporary login links. Split from the core browser surface: these manage
6
+ * login state, they don't drive pages.
7
+ */
8
+
9
+ const { textResult, errorResult } = require('../lib/tool-result');
10
+ const engine = require('./engine');
11
+ const {
12
+ createBrowserLoginSession,
13
+ listBrowserAuthBundles,
14
+ listBrowserLoginSessions,
15
+ listBrowserProfiles,
16
+ markBrowserAuthBundleImported,
17
+ readEncryptedStateBundle,
18
+ safeId,
19
+ touchBrowserProfile,
20
+ writeEncryptedStateBundle,
21
+ } = require('./store');
22
+
23
+ function jsonText(value) {
24
+ return textResult(JSON.stringify(value, null, 2));
25
+ }
26
+
27
+ function authDomainsFrom(inputDomains, fallbackUrl) {
28
+ if (Array.isArray(inputDomains) && inputDomains.length > 0) return inputDomains;
29
+ try {
30
+ const hostname = new URL(fallbackUrl).hostname;
31
+ return hostname ? [hostname] : [];
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ module.exports = [
38
+ {
39
+ name: 'browser_profiles_list',
40
+ description: 'List durable Amalgm browser profiles on this computer.',
41
+ inputSchema: { type: 'object', properties: {} },
42
+ async handler() {
43
+ try {
44
+ return jsonText({ profiles: listBrowserProfiles() });
45
+ } catch (err) {
46
+ return errorResult(`List browser profiles failed: ${err.message}`);
47
+ }
48
+ },
49
+ },
50
+ {
51
+ name: 'browser_auth_bundles_list',
52
+ description: 'List encrypted browser auth bundles available on this computer. Raw cookies/storage are never returned.',
53
+ inputSchema: { type: 'object', properties: {} },
54
+ async handler() {
55
+ try {
56
+ return jsonText({ bundles: listBrowserAuthBundles() });
57
+ } catch (err) {
58
+ return errorResult(`List browser auth bundles failed: ${err.message}`);
59
+ }
60
+ },
61
+ },
62
+ {
63
+ name: 'browser_login_sessions_list',
64
+ description: 'List temporary browser login links on this computer. Raw tokens and cookies are never returned.',
65
+ inputSchema: { type: 'object', properties: {} },
66
+ async handler() {
67
+ try {
68
+ return jsonText({ sessions: listBrowserLoginSessions() });
69
+ } catch (err) {
70
+ return errorResult(`List browser login sessions failed: ${err.message}`);
71
+ }
72
+ },
73
+ },
74
+ {
75
+ name: 'browser_auth_link_create',
76
+ description: 'Create a short-lived browser login link for the user. Use this when an agent needs the user to log into a site before QA or browser work.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: {
80
+ targetUrl: { type: 'string', description: 'The site or app URL the user should log into.' },
81
+ browserProfileId: { type: 'string', description: 'Durable browser profile id that will receive the login.' },
82
+ name: { type: 'string', description: 'Human-readable login/bundle name.' },
83
+ domains: { type: 'array', items: { type: 'string' }, description: 'Domains to save after login, e.g. app.example.com.' },
84
+ transport: { type: 'string', description: 'Optional live-view transport: electron-splitview, novnc, or auto.' },
85
+ liveUrl: { type: 'string', description: 'Optional noVNC/live-view URL for web/mobile login.' },
86
+ appBaseUrl: { type: 'string', description: 'Optional Amalgm app base URL used to build an absolute link.' },
87
+ ttlMs: { type: 'number', description: 'Optional link lifetime in milliseconds. Defaults to 15 minutes, max 1 hour.' },
88
+ },
89
+ required: ['targetUrl'],
90
+ },
91
+ async handler(args, ctx) {
92
+ try {
93
+ const profileId = safeId(
94
+ args.browserProfileId
95
+ || args.browserSessionId
96
+ || ctx?.callerSessionId
97
+ || `login-${Date.now().toString(36)}`,
98
+ );
99
+ const result = createBrowserLoginSession({
100
+ ...args,
101
+ browserProfileId: profileId,
102
+ profileId,
103
+ }, { source: 'browser-auth-link-tool' });
104
+ return jsonText({
105
+ session: result.session,
106
+ authLink: result.authLink,
107
+ authLinkPath: result.authLinkPath,
108
+ message: `Open this temporary login link: ${result.authLink}`,
109
+ });
110
+ } catch (err) {
111
+ return errorResult(`Create browser auth link failed: ${err.message}`);
112
+ }
113
+ },
114
+ },
115
+ {
116
+ name: 'browser_auth_save',
117
+ description: 'Save the current browser profile cookies/storage into an encrypted auth bundle for later agent use.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: {
121
+ browserProfileId: { type: 'string', description: 'Durable browser profile id to save from' },
122
+ name: { type: 'string', description: 'Human-readable bundle name' },
123
+ domains: { type: 'array', items: { type: 'string' }, description: 'Optional domains to include, e.g. example.com' },
124
+ },
125
+ },
126
+ async handler(args, ctx) {
127
+ try {
128
+ const profileId = safeId(args.browserProfileId || args.browserSessionId || ctx?.callerSessionId || 'default');
129
+ const saved = await engine.saveState({ ...args, session: profileId }, ctx);
130
+ const bundle = writeEncryptedStateBundle({
131
+ name: args.name || saved.title || profileId,
132
+ profileId,
133
+ domains: authDomainsFrom(args.domains, saved.url),
134
+ state: saved.state || {},
135
+ });
136
+ touchBrowserProfile({
137
+ id: profileId,
138
+ name: args.name || profileId,
139
+ source: saved.backend || 'browser-auth-save',
140
+ }, { source: 'browser-auth-save' });
141
+ return jsonText({ bundle });
142
+ } catch (err) {
143
+ return errorResult(`Save browser auth bundle failed: ${err.message}`);
144
+ }
145
+ },
146
+ },
147
+ {
148
+ name: 'browser_auth_load',
149
+ description: 'Load an encrypted browser auth bundle into a durable browser profile so an agent can start past login.',
150
+ inputSchema: {
151
+ type: 'object',
152
+ properties: {
153
+ id: { type: 'string', description: 'Auth bundle id' },
154
+ browserProfileId: { type: 'string', description: 'Target durable browser profile id' },
155
+ },
156
+ required: ['id'],
157
+ },
158
+ async handler(args, ctx) {
159
+ try {
160
+ const { bundle, payload } = readEncryptedStateBundle(args.id);
161
+ const profileId = safeId(args.browserProfileId || args.browserSessionId || bundle.profileId);
162
+ const loaded = await engine.loadState({
163
+ ...args,
164
+ session: profileId,
165
+ state: payload.state,
166
+ }, ctx);
167
+ const updated = markBrowserAuthBundleImported(args.id, profileId);
168
+ touchBrowserProfile({
169
+ id: profileId,
170
+ name: profileId,
171
+ source: loaded.backend || 'browser-auth-load',
172
+ }, { source: 'browser-auth-load' });
173
+ return jsonText({ bundle: updated, loaded });
174
+ } catch (err) {
175
+ return errorResult(`Load browser auth bundle failed: ${err.message}`);
176
+ }
177
+ },
178
+ },
179
+ ];
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Low-level runner for the agent-browser CLI (vercel-labs/agent-browser).
5
+ *
6
+ * One agent-browser daemon exists per session; sessions map 1:1 to durable
7
+ * Amalgm browser profiles. This module owns process spawning, environment
8
+ * setup, and the JSON envelope — nothing else. Verb-level behavior lives in
9
+ * engine.js.
10
+ *
11
+ * Environment fixes handled here (learned from live testing):
12
+ * - AGENT_BROWSER_SOCKET_DIR must be short or the daemon socket path can
13
+ * exceed the 103-byte unix socket limit and every command fails.
14
+ * - The daemon resolves `ffmpeg` from PATH at daemon spawn time, so video
15
+ * recording only works if ffmpeg is on PATH for the *first* command of a
16
+ * session. We prepend ffmpeg-static's directory when available.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { spawn } = require('child_process');
23
+
24
+ const DEFAULT_TIMEOUT_MS = 120_000;
25
+ const DEFAULT_SESSION = 'default';
26
+
27
+ function amalgmDir() {
28
+ return process.env.AMALGM_DIR || path.join(os.homedir(), '.amalgm');
29
+ }
30
+
31
+ function ensureDir(dir) {
32
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
33
+ try { fs.chmodSync(dir, 0o700); } catch {}
34
+ return dir;
35
+ }
36
+
37
+ function safeId(value, fallback = DEFAULT_SESSION) {
38
+ const raw = String(value || fallback).replace(/^agent-browser:/, '');
39
+ return raw.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64) || fallback;
40
+ }
41
+
42
+ function browserRoot() {
43
+ return ensureDir(path.join(amalgmDir(), 'browser'));
44
+ }
45
+
46
+ function profileDir(session) {
47
+ return ensureDir(path.join(browserRoot(), 'profiles', session));
48
+ }
49
+
50
+ function screenshotDir() {
51
+ return ensureDir(path.join(browserRoot(), 'screenshots'));
52
+ }
53
+
54
+ /**
55
+ * Unix socket paths cap at ~103 bytes on macOS. The default socket dir is
56
+ * derived from TMPDIR which can be deep; keep ours short and stable.
57
+ */
58
+ function socketDir() {
59
+ if (process.env.AGENT_BROWSER_SOCKET_DIR) return process.env.AGENT_BROWSER_SOCKET_DIR;
60
+ return ensureDir(path.join(os.tmpdir(), 'amalgm-ab'));
61
+ }
62
+
63
+ function ffmpegDir() {
64
+ try {
65
+ const resolved = require('ffmpeg-static');
66
+ if (resolved && fs.existsSync(resolved)) return path.dirname(resolved);
67
+ } catch {}
68
+ return null;
69
+ }
70
+
71
+ function agentBrowserCommand() {
72
+ if (process.env.AMALGM_AGENT_BROWSER_BIN) {
73
+ return { command: process.env.AMALGM_AGENT_BROWSER_BIN, prefix: [] };
74
+ }
75
+ try {
76
+ return {
77
+ command: process.execPath,
78
+ prefix: [require.resolve('agent-browser/bin/agent-browser.js')],
79
+ };
80
+ } catch {
81
+ return { command: 'agent-browser', prefix: [] };
82
+ }
83
+ }
84
+
85
+ function wantsHeaded() {
86
+ const explicit = process.env.AMALGM_BROWSER_HEADED || process.env.AGENT_BROWSER_HEADED;
87
+ if (explicit) return !/^(0|false|no)$/i.test(explicit);
88
+ if (/^(1|true|yes)$/i.test(process.env.AMALGM_BROWSER_HEADLESS || '')) return false;
89
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
90
+ return true;
91
+ }
92
+
93
+ function baseArgs(session, json = true) {
94
+ const args = [
95
+ '--session', session,
96
+ '--profile', profileDir(session),
97
+ '--screenshot-dir', screenshotDir(),
98
+ ];
99
+ if (json) args.push('--json');
100
+ if (wantsHeaded()) args.push('--headed');
101
+ if (process.env.AMALGM_BROWSER_EXECUTABLE_PATH) {
102
+ args.push('--executable-path', process.env.AMALGM_BROWSER_EXECUTABLE_PATH);
103
+ }
104
+ if (process.env.AMALGM_BROWSER_PROVIDER) {
105
+ args.push('--provider', process.env.AMALGM_BROWSER_PROVIDER);
106
+ }
107
+ return args;
108
+ }
109
+
110
+ function parseJson(stdout) {
111
+ const text = String(stdout || '').trim();
112
+ if (!text) return {};
113
+ try {
114
+ return JSON.parse(text);
115
+ } catch {
116
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
117
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
118
+ if (!/^[{[]/.test(lines[i])) continue;
119
+ try {
120
+ return JSON.parse(lines.slice(i).join('\n'));
121
+ } catch {
122
+ // Keep looking for a JSON suffix.
123
+ }
124
+ }
125
+ }
126
+ return { text };
127
+ }
128
+
129
+ function asText(value) {
130
+ if (value == null) return '';
131
+ if (typeof value === 'string') return value;
132
+ if (value.success === true && value.data !== undefined) return asText(value.data);
133
+ if (typeof value.snapshot === 'string') return value.snapshot;
134
+ if (typeof value.text === 'string') return value.text;
135
+ if (typeof value.value === 'string') return value.value;
136
+ if (typeof value.result === 'string') return value.result;
137
+ if (typeof value.output === 'string') return value.output;
138
+ if (typeof value.url === 'string') return value.url;
139
+ if (typeof value.title === 'string') return value.title;
140
+ return JSON.stringify(value, null, 2);
141
+ }
142
+
143
+ function runCli(commandArgs, options = {}) {
144
+ const session = safeId(options.session);
145
+ const json = options.json !== false;
146
+ const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
147
+ const bin = agentBrowserCommand();
148
+ const args = [...bin.prefix, ...baseArgs(session, json), ...commandArgs.map(String)];
149
+
150
+ const env = {
151
+ ...process.env,
152
+ AGENT_BROWSER_SESSION: session,
153
+ AGENT_BROWSER_PROFILE: profileDir(session),
154
+ AGENT_BROWSER_SOCKET_DIR: socketDir(),
155
+ };
156
+ // The daemon (spawned by the first command of a session) inherits this env,
157
+ // and resolves ffmpeg from PATH when recording starts.
158
+ const ffmpeg = ffmpegDir();
159
+ if (ffmpeg) env.PATH = `${ffmpeg}${path.delimiter}${env.PATH || ''}`;
160
+
161
+ return new Promise((resolve, reject) => {
162
+ const child = spawn(bin.command, args, {
163
+ cwd: os.homedir(),
164
+ env,
165
+ stdio: ['ignore', 'pipe', 'pipe'],
166
+ });
167
+
168
+ let stdout = '';
169
+ let stderr = '';
170
+ const timer = setTimeout(() => {
171
+ child.kill('SIGTERM');
172
+ reject(new Error(`agent-browser timed out running: ${commandArgs.join(' ')}`));
173
+ }, timeoutMs);
174
+
175
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
176
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
177
+ child.on('error', (err) => {
178
+ clearTimeout(timer);
179
+ reject(err.code === 'ENOENT'
180
+ ? new Error('agent-browser is not installed. Reinstall Amalgm or run `npm install agent-browser`.')
181
+ : err);
182
+ });
183
+ child.on('close', (code) => {
184
+ clearTimeout(timer);
185
+ if (code !== 0) {
186
+ reject(new Error((stderr || stdout || `agent-browser exited with code ${code}`).trim()));
187
+ return;
188
+ }
189
+ const result = json ? parseJson(stdout) : { text: stdout.trim() };
190
+ if (result && result.success === false) {
191
+ reject(new Error(result.error || result.message || 'agent-browser command failed'));
192
+ return;
193
+ }
194
+ resolve(result);
195
+ });
196
+ });
197
+ }
198
+
199
+ async function readText(commandArgs, session, timeoutMs = 30_000) {
200
+ return asText(await runCli(commandArgs, { session, timeoutMs }));
201
+ }
202
+
203
+ module.exports = {
204
+ DEFAULT_SESSION,
205
+ amalgmDir,
206
+ asText,
207
+ browserRoot,
208
+ ensureDir,
209
+ ffmpegDir,
210
+ profileDir,
211
+ readText,
212
+ runCli,
213
+ safeId,
214
+ screenshotDir,
215
+ socketDir,
216
+ wantsHeaded,
217
+ };