amalgm 0.1.96 → 0.1.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.96",
3
+ "version": "0.1.97",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -34,7 +34,7 @@
34
34
  const cli = require('./cli');
35
35
  const backend = require('./backend');
36
36
  const cdp = require('./cdp');
37
- const { surfaces, knownSessions } = require('./sessions');
37
+ const { surfaces, knownSessions, backendFor } = require('./sessions');
38
38
 
39
39
  const SURFACE_EVENT_RESOURCE = 'browser_surfaces';
40
40
  const WEBVIEW_MARKER_PREFIX = 'amalgm-bsid-';
@@ -58,10 +58,10 @@ function guardCommand(session) {
58
58
  + `throw new Error(${JSON.stringify(GUARD_ERROR)}); })()`];
59
59
  }
60
60
 
61
- /** Options every cli call needs for the current backend. */
61
+ /** Options every cli call needs for this session's backend. */
62
62
  function cliOptions(session, extra = {}) {
63
63
  const options = { session, ...extra };
64
- if (backend.mode() === 'attached') options.cdp = backend.cdpEndpoint();
64
+ if (backendFor(session) === 'attached') options.cdp = backend.cdpEndpoint();
65
65
  return options;
66
66
  }
67
67
 
@@ -224,7 +224,7 @@ async function focusSurface(session, surface) {
224
224
  * target it names.
225
225
  */
226
226
  async function ensureReady(session, context) {
227
- if (backend.mode() !== 'attached') return null;
227
+ if (backendFor(session) !== 'attached') return null;
228
228
  const live = surfaces.get(session);
229
229
  if (live) return live;
230
230
 
@@ -82,11 +82,12 @@ module.exports = [
82
82
  || ctx?.callerSessionId
83
83
  || `login-${Date.now().toString(36)}`,
84
84
  );
85
- // In the desktop app, the login happens on the visible surface this
86
- // session already drives — open the page there and say so. The link
87
- // is the web/remote path, where a separate live view (noVNC today)
88
- // shows the headless browser.
89
- const attached = engine.mode() === 'attached';
85
+ // A desktop-born conversation logs in on the visible surface this
86
+ // session already drives — open the page there and say so. Everyone
87
+ // else gets the link, where a separate live view (noVNC today) shows
88
+ // the headless browser. sessionFor records the login session's home
89
+ // from the caller's birth stamp, same as every other verb.
90
+ const attached = engine.backendFor(engine.sessionFor({ session: profileId }, ctx)) === 'attached';
90
91
  const result = createBrowserLoginSession({
91
92
  ...args,
92
93
  transport: args.transport || (attached ? 'electron-splitview' : undefined),
@@ -1,18 +1,22 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Backend routing — which browser this runtime drives.
4
+ * Backend routing — which browser a session drives.
5
5
  *
6
6
  * Exactly two backends:
7
7
  * - "attached": the visible chromium the Amalgm desktop app advertises over
8
8
  * CDP (or an explicit AMALGM_BROWSER_CDP_URL endpoint). The user watches
9
9
  * this browser in the app's split view.
10
10
  * - "cli": a standalone agent-browser chromium, headless. The deterministic
11
- * path for npm-only machines, servers, Fly workers, and the web client.
11
+ * path for npm-only machines, servers, Fly workers, web chats, sub-agents,
12
+ * and automations.
12
13
  *
13
- * The rule: a fresh advertisement (or explicit CDP env) attached;
14
- * otherwise ⇒ cli. "Fresh" means the advertising pid is alive. Nothing else
15
- * may influence the choice visibility must be deterministic per entrypoint.
14
+ * The rule: a browser session inherits its home from the conversation that
15
+ * created it. Conversations born in the desktop app carry client:'desktop'
16
+ * in their session metadata (stamped at creation, immutable); only those get
17
+ * the visible browser, and only while the app's advertisement is fresh
18
+ * ("fresh" = the advertising pid is alive). Everything else — web chats,
19
+ * sub-agents, automations, anything unstamped — is headless by construction.
16
20
  * AMALGM_BROWSER_BACKEND forces a backend for debugging.
17
21
  */
18
22
 
@@ -84,10 +88,26 @@ function cdpEndpoint() {
84
88
  return cdpFlagValue(advertisement.cdpUrl);
85
89
  }
86
90
 
87
- function mode() {
91
+ /** The debug override, or null when the owner's stamp decides. */
92
+ function forcedMode() {
88
93
  const requested = (process.env.AMALGM_BROWSER_BACKEND || '').toLowerCase();
89
94
  if (['agent-browser', 'external', 'cli', 'headless'].includes(requested)) return 'cli';
90
95
  if (['attached', 'cdp', 'electron'].includes(requested)) return 'attached';
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * The backend for a session whose owner conversation carries `client` in its
101
+ * metadata. Pure: same stamp, same answer. 'desktop' is the only value that
102
+ * earns the visible browser, and only while the app is actually there.
103
+ */
104
+ function modeFor(client) {
105
+ const forced = forcedMode();
106
+ if (forced) return forced;
107
+ // An explicit CDP endpoint is an operator instruction to drive that
108
+ // chromium, regardless of who asks.
109
+ if (process.env.AMALGM_BROWSER_CDP_URL) return 'attached';
110
+ if (client !== 'desktop') return 'cli';
91
111
  return cdpEndpoint() ? 'attached' : 'cli';
92
112
  }
93
113
 
@@ -95,6 +115,7 @@ module.exports = {
95
115
  bridgeFileCandidates,
96
116
  cdpEndpoint,
97
117
  cdpFlagValue,
98
- mode,
118
+ forcedMode,
119
+ modeFor,
99
120
  readBridgeAdvertisement,
100
121
  };
@@ -26,6 +26,7 @@ const backend = require('./backend');
26
26
  const cli = require('./cli');
27
27
  const attach = require('./attach');
28
28
  const cdp = require('./cdp');
29
+ const { backendFor } = require('./sessions');
29
30
 
30
31
  // An on-screen surface answers captureScreenshot in well under 100ms
31
32
  // (measured 54–100ms live); one that has not answered in 4s is not going to.
@@ -51,7 +52,7 @@ async function pageTargetWsUrl(endpoint) {
51
52
  * is normalized to CSS pixels so coordinates map 1:1 onto cua_* input.
52
53
  */
53
54
  async function plan(session, context) {
54
- if (backend.mode() !== 'attached') {
55
+ if (backendFor(session) !== 'attached') {
55
56
  const raw = await daemonCdpUrl(session);
56
57
  if (!raw) throw new Error('Could not resolve a CDP endpoint for this browser session.');
57
58
  const wsUrl = /\/devtools\/browser\//.test(raw) ? await pageTargetWsUrl(raw) : raw;
@@ -20,8 +20,9 @@ const backend = require('./backend');
20
20
  const attach = require('./attach');
21
21
  const capture = require('./capture');
22
22
  const sessions = require('./sessions');
23
+ const typing = require('./typing');
23
24
 
24
- const { sessionFor, sessionInfo } = sessions;
25
+ const { backendFor, sessionFor, sessionInfo } = sessions;
25
26
  const { run, runBatch, readText } = attach;
26
27
 
27
28
  /**
@@ -96,18 +97,33 @@ async function fill(args = {}, context) {
96
97
  const session = sessionFor(args, context);
97
98
  const target = String(args.target || '');
98
99
  const text = String(args.text ?? '');
99
- const fillCommand = target.startsWith('text=') ? findArgs(target, 'fill', text) : ['fill', target, text];
100
- if (args.submit) {
101
- await runBatch(session, [fillCommand, ['press', 'Enter']], { context });
100
+ if (backendFor(session) === 'attached') {
101
+ // Shared window: synthesized keystrokes follow window focus and can land
102
+ // in the user's chat box (typing.js). Focus the element through the
103
+ // daemon (in-page, resolves @ref/CSS/text= alike), then set the value
104
+ // inside the page — the text cannot leave the session's page.
105
+ const focusCommand = target.startsWith('text=') ? findArgs(target, 'focus') : ['focus', target];
106
+ const commands = [focusCommand, ['eval', typing.setValueScript(text)]];
107
+ if (args.submit) commands.push(['eval', typing.keyScript('Enter')]);
108
+ await runBatch(session, commands, { context });
102
109
  } else {
103
- await run(session, fillCommand, { context });
110
+ const fillCommand = target.startsWith('text=') ? findArgs(target, 'fill', text) : ['fill', target, text];
111
+ if (args.submit) {
112
+ await runBatch(session, [fillCommand, ['press', 'Enter']], { context });
113
+ } else {
114
+ await run(session, fillCommand, { context });
115
+ }
104
116
  }
105
117
  return sessionInfo(session, { filled: target, submitted: Boolean(args.submit) });
106
118
  }
107
119
 
108
120
  async function press(args = {}, context) {
109
121
  const session = sessionFor(args, context);
110
- await run(session, ['press', args.key], { context });
122
+ if (backendFor(session) === 'attached') {
123
+ await run(session, ['eval', typing.keyScript(String(args.key || ''))], { context });
124
+ } else {
125
+ await run(session, ['press', args.key], { context });
126
+ }
111
127
  return sessionInfo(session, { pressed: args.key });
112
128
  }
113
129
 
@@ -174,7 +190,7 @@ async function passthrough(args = {}, context) {
174
190
  const session = sessionFor(args, context);
175
191
  const commandArgs = (args.args || []).map(String);
176
192
  if (!commandArgs.length) throw new Error('args is required, e.g. ["scroll", "down"] or ["get", "text", "@e1"]');
177
- if (backend.mode() === 'attached' && commandArgs[0] === 'tab' && commandArgs.length > 1) {
193
+ if (sessions.backendFor(session) === 'attached' && commandArgs[0] === 'tab' && commandArgs.length > 1) {
178
194
  // In the desktop app a session IS its surface; hopping targets is how a
179
195
  // session ends up driving another session's page. Listing stays allowed.
180
196
  throw new Error(
@@ -328,9 +344,10 @@ module.exports = {
328
344
  // Routing façade (backend.js) — kept here so engine stays the one import.
329
345
  bridgeFileCandidates: backend.bridgeFileCandidates,
330
346
  cdpEndpoint: backend.cdpEndpoint,
331
- mode: backend.mode,
347
+ modeFor: backend.modeFor,
332
348
  readBridgeAdvertisement: backend.readBridgeAdvertisement,
333
349
  // Session façade (sessions.js).
350
+ backendFor: sessions.backendFor,
334
351
  knownSessions: sessions.knownSessions,
335
352
  sessionFor,
336
353
  sessionInfo,
@@ -25,7 +25,6 @@ const path = require('path');
25
25
  const { spawn } = require('child_process');
26
26
 
27
27
  const cli = require('./cli');
28
- const backend = require('./backend');
29
28
  const capture = require('./capture');
30
29
  const sessions = require('./sessions');
31
30
 
@@ -246,7 +245,7 @@ async function start(args = {}, context = {}) {
246
245
  path: file,
247
246
  fps,
248
247
  mimeType: 'video/webm',
249
- backend: backend.mode(),
248
+ backend: sessions.backendFor(session),
250
249
  source: source.guest ? 'visible surface' : 'page',
251
250
  };
252
251
  }
@@ -23,8 +23,21 @@ const surfaces = new Map();
23
23
  /** Per-session bookkeeping: webviewTabId, lastActiveAt, last url/title. */
24
24
  const knownSessions = new Map();
25
25
 
26
+ /**
27
+ * Each session's home backend, decided once at first use from the owner
28
+ * conversation's birth stamp (metadata.client) and never re-decided — a
29
+ * session does not migrate between a visible surface and a headless browser
30
+ * mid-life. An MCP restart clears the map; the next call re-derives the same
31
+ * answer from the same stamp.
32
+ */
33
+ const homes = new Map();
34
+
35
+ function backendFor(session) {
36
+ return homes.get(session) || backend.modeFor(undefined);
37
+ }
38
+
26
39
  function sessionFor(args = {}, context = {}) {
27
- return cli.safeId(
40
+ const session = cli.safeId(
28
41
  args.session
29
42
  || args.profile
30
43
  || args.browserProfileId
@@ -34,6 +47,10 @@ function sessionFor(args = {}, context = {}) {
34
47
  || context?.callerSessionId
35
48
  || cli.DEFAULT_SESSION,
36
49
  );
50
+ if (!homes.has(session)) {
51
+ homes.set(session, backend.modeFor(context?.sessionMetadata?.client));
52
+ }
53
+ return session;
37
54
  }
38
55
 
39
56
  function rememberProfile(session) {
@@ -53,7 +70,7 @@ function sessionInfo(session, extra = {}) {
53
70
  session,
54
71
  browserSessionId: session,
55
72
  browserProfileId: session,
56
- backend: backend.mode(),
73
+ backend: backendFor(session),
57
74
  ...extra,
58
75
  };
59
76
  knownSessions.set(session, { ...knownSessions.get(session), ...info, lastActiveAt: Date.now() });
@@ -61,6 +78,8 @@ function sessionInfo(session, extra = {}) {
61
78
  }
62
79
 
63
80
  module.exports = {
81
+ backendFor,
82
+ homes,
64
83
  knownSessions,
65
84
  rememberProfile,
66
85
  sessionFor,
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * In-page typing — the scripts behind fill and press in attached mode.
5
+ *
6
+ * In the desktop app the agent and the user share one chromium window, and
7
+ * synthesized keystrokes (CDP Input events) are routed by *window focus* —
8
+ * an agent "typing" while the user's caret sits in the chat box lands the
9
+ * text in the chat box (observed live). These scripts act inside the
10
+ * session's page instead, through the same stamp-guarded transport as every
11
+ * other command, so agent input physically cannot reach anything but the
12
+ * page it was meant for.
13
+ *
14
+ * The boundary: page-level semantics work (value setting with real
15
+ * input/change events, key handlers, Enter activating buttons and
16
+ * submitting forms); OS-level editing defaults (Tab focus traversal,
17
+ * arrow-key scrolling) are real-input territory — that is what the cua_*
18
+ * verbs are for, and they stay focus-dependent by design.
19
+ */
20
+
21
+ /**
22
+ * Set the focused field's value the way the page expects: through the
23
+ * native prototype setter (so frameworks tracking the value descriptor —
24
+ * React — see the change), followed by real input/change events.
25
+ */
26
+ function setValueScript(text) {
27
+ return `(() => {
28
+ const el = document.activeElement;
29
+ if (!el || el === document.body) throw new Error('focus did not land on a field');
30
+ const text = ${JSON.stringify(String(text))};
31
+ if (el.isContentEditable) {
32
+ el.textContent = text;
33
+ } else {
34
+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype
35
+ : el instanceof HTMLInputElement ? HTMLInputElement.prototype
36
+ : null;
37
+ if (!proto && !('value' in el)) {
38
+ throw new Error('focused element <' + el.tagName.toLowerCase() + '> is not fillable');
39
+ }
40
+ const setter = proto && Object.getOwnPropertyDescriptor(proto, 'value').set;
41
+ if (setter) setter.call(el, text); else el.value = text;
42
+ }
43
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, data: text, inputType: 'insertReplacementText' }));
44
+ el.dispatchEvent(new Event('change', { bubbles: true }));
45
+ return true;
46
+ })()`;
47
+ }
48
+
49
+ /** "KeyA" for letters, "Digit1" for digits, the key's own name otherwise. */
50
+ function codeFor(key) {
51
+ if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
52
+ if (/^[0-9]$/.test(key)) return `Digit${key}`;
53
+ if (key === ' ') return 'Space';
54
+ return key;
55
+ }
56
+
57
+ /**
58
+ * Dispatch a key combination ("Enter", "Escape", "Control+a") to the focused
59
+ * element, mirroring the browser's own Enter defaults when no handler
60
+ * prevents them: buttons and links activate, single-line form fields submit
61
+ * their form (through requestSubmit, so submit handlers and validation run).
62
+ */
63
+ function keyScript(combo) {
64
+ const parts = String(combo).split('+');
65
+ const key = parts.pop() || '';
66
+ const mods = parts.map((part) => part.toLowerCase());
67
+ const init = {
68
+ key,
69
+ code: codeFor(key),
70
+ bubbles: true,
71
+ cancelable: true,
72
+ ctrlKey: mods.includes('control') || mods.includes('ctrl'),
73
+ shiftKey: mods.includes('shift'),
74
+ altKey: mods.includes('alt') || mods.includes('option'),
75
+ metaKey: mods.includes('meta') || mods.includes('cmd') || mods.includes('command'),
76
+ };
77
+ return `(() => {
78
+ const el = document.activeElement || document.body;
79
+ const init = ${JSON.stringify(init)};
80
+ const proceed = el.dispatchEvent(new KeyboardEvent('keydown', init));
81
+ if (proceed) {
82
+ el.dispatchEvent(new KeyboardEvent('keypress', init));
83
+ if (init.key === 'Enter') {
84
+ if (el instanceof HTMLButtonElement || el instanceof HTMLAnchorElement) {
85
+ el.click();
86
+ } else if (el instanceof HTMLInputElement && el.form) {
87
+ if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
88
+ }
89
+ }
90
+ }
91
+ el.dispatchEvent(new KeyboardEvent('keyup', init));
92
+ return true;
93
+ })()`;
94
+ }
95
+
96
+ module.exports = { codeFor, keyScript, setValueScript };
@@ -48,35 +48,36 @@ function writeAdvertisement(dir, data) {
48
48
  );
49
49
  }
50
50
 
51
- test('entrypoint routing: no advertisement means headless cli', () => {
51
+ test('ownership routing: no advertisement means headless for everyone', () => {
52
52
  withEnv({ AMALGM_DIR: tempDir('empty') }, () => {
53
- assert.equal(engine.mode(), 'cli');
53
+ assert.equal(engine.modeFor('desktop'), 'cli');
54
+ assert.equal(engine.modeFor('web'), 'cli');
54
55
  assert.equal(engine.cdpEndpoint(), null);
55
56
  });
56
57
  });
57
58
 
58
- test('entrypoint routing: AMALGM_BROWSER_BACKEND forces a backend', () => {
59
+ test('ownership routing: AMALGM_BROWSER_BACKEND forces a backend for everyone', () => {
59
60
  withEnv({ AMALGM_BROWSER_BACKEND: 'cli', AMALGM_BROWSER_CDP_URL: 'ws://127.0.0.1:9242/x' }, () => {
60
- assert.equal(engine.mode(), 'cli');
61
+ assert.equal(engine.modeFor('desktop'), 'cli');
61
62
  });
62
63
  withEnv({ AMALGM_BROWSER_BACKEND: 'attached' }, () => {
63
- assert.equal(engine.mode(), 'attached');
64
+ assert.equal(engine.modeFor('web'), 'attached');
64
65
  });
65
66
  // Old override value keeps meaning "the visible desktop browser".
66
67
  withEnv({ AMALGM_BROWSER_BACKEND: 'electron' }, () => {
67
- assert.equal(engine.mode(), 'attached');
68
+ assert.equal(engine.modeFor(undefined), 'attached');
68
69
  });
69
70
  });
70
71
 
71
- test('entrypoint routing: explicit AMALGM_BROWSER_CDP_URL attaches', () => {
72
+ test('ownership routing: explicit AMALGM_BROWSER_CDP_URL attaches regardless of owner', () => {
72
73
  withEnv({ AMALGM_BROWSER_CDP_URL: 'ws://127.0.0.1:9999/devtools/browser/abc' }, () => {
73
- assert.equal(engine.mode(), 'attached');
74
+ assert.equal(engine.modeFor('web'), 'attached');
74
75
  // --cdp takes a port; URLs are reduced to their port.
75
76
  assert.equal(engine.cdpEndpoint(), '9999');
76
77
  });
77
78
  });
78
79
 
79
- test('entrypoint routing: fresh advertisement in the runtime state dir attaches', () => {
80
+ test('ownership routing: a fresh advertisement attaches desktop-born chats ONLY', () => {
80
81
  const stateDir = tempDir('state');
81
82
  writeAdvertisement(stateDir, {
82
83
  cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/live',
@@ -84,35 +85,72 @@ test('entrypoint routing: fresh advertisement in the runtime state dir attaches'
84
85
  pid: process.pid,
85
86
  });
86
87
  withEnv({ AMALGM_RUNTIME_STATE_DIR: stateDir }, () => {
87
- assert.equal(engine.mode(), 'attached');
88
+ assert.equal(engine.modeFor('desktop'), 'attached');
89
+ // The app being alive is not enough: web chats, sub-agents, automations,
90
+ // and anything unstamped stay headless — the 2026-06-11 leak was a web
91
+ // chat's browser mounting visibly inside the user's open splitview.
92
+ assert.equal(engine.modeFor('web'), 'cli');
93
+ assert.equal(engine.modeFor(undefined), 'cli');
88
94
  assert.equal(engine.cdpEndpoint(), '9242');
89
95
  });
90
96
  });
91
97
 
92
- test('entrypoint routing: stale advertisement (dead pid) means headless', () => {
98
+ test('ownership routing: stale advertisement (dead pid) means headless', () => {
93
99
  const stateDir = tempDir('stale');
94
100
  writeAdvertisement(stateDir, {
95
101
  cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/dead',
96
102
  pid: 2 ** 30, // not a real pid
97
103
  });
98
104
  withEnv({ AMALGM_RUNTIME_STATE_DIR: stateDir, AMALGM_DIR: tempDir('empty2') }, () => {
99
- assert.equal(engine.mode(), 'cli');
105
+ assert.equal(engine.modeFor('desktop'), 'cli');
100
106
  });
101
107
  });
102
108
 
103
- test('entrypoint routing: AMALGM_DIR + label derives the state dir path', () => {
109
+ test('ownership routing: AMALGM_DIR + label derives the state dir path', () => {
104
110
  const amalgmDir = tempDir('scoped');
105
111
  writeAdvertisement(path.join(amalgmDir, 'runtimes', 'main'), {
106
112
  cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/scoped',
107
113
  pid: process.pid,
108
114
  });
109
115
  withEnv({ AMALGM_DIR: amalgmDir, AMALGM_RUNTIME_LABEL: 'main' }, () => {
110
- assert.equal(engine.mode(), 'attached');
116
+ assert.equal(engine.modeFor('desktop'), 'attached');
111
117
  assert.equal(engine.cdpEndpoint(), '9242');
112
118
  });
113
119
  // A different label does not see it (branch isolation).
114
120
  withEnv({ AMALGM_DIR: amalgmDir, AMALGM_RUNTIME_LABEL: 'preview' }, () => {
115
- assert.equal(engine.mode(), 'cli');
121
+ assert.equal(engine.modeFor('desktop'), 'cli');
122
+ });
123
+ });
124
+
125
+ test('ownership routing: a session home is decided at first use and sticks', () => {
126
+ const sessions = require('../browser/sessions');
127
+ const stateDir = tempDir('homes');
128
+ writeAdvertisement(stateDir, {
129
+ cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/live',
130
+ cdpPort: 9242,
131
+ pid: process.pid,
132
+ });
133
+ withEnv({ AMALGM_RUNTIME_STATE_DIR: stateDir }, () => {
134
+ sessions.homes.clear();
135
+ // Desktop-born conversation: visible browser.
136
+ const desktopSession = sessions.sessionFor(
137
+ { session: 'home-desktop' },
138
+ { callerSessionId: 'chat-1', sessionMetadata: { client: 'desktop' } },
139
+ );
140
+ assert.equal(sessions.backendFor(desktopSession), 'attached');
141
+ // Web-born conversation: headless, even with the app alive.
142
+ const webSession = sessions.sessionFor(
143
+ { session: 'home-web' },
144
+ { callerSessionId: 'chat-2', sessionMetadata: { client: 'web' } },
145
+ );
146
+ assert.equal(sessions.backendFor(webSession), 'cli');
147
+ // Unstamped caller (sub-agent, automation, legacy session): headless.
148
+ const unstampedSession = sessions.sessionFor({ session: 'home-none' }, { callerSessionId: 'chat-3' });
149
+ assert.equal(sessions.backendFor(unstampedSession), 'cli');
150
+ // Decided at birth: a later call with different metadata does not migrate it.
151
+ sessions.sessionFor({ session: 'home-desktop' }, { sessionMetadata: { client: 'web' } });
152
+ assert.equal(sessions.backendFor(desktopSession), 'attached');
153
+ sessions.homes.clear();
116
154
  });
117
155
  });
118
156