fraim-hub 2.0.337 → 2.0.339

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.
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ // Read the shared author's current entry format for identity and update guards.
4
+ function learningEntryBlocks(body) {
5
+ const headings = [...body.matchAll(/^#{2,6}\s+\[P-[A-Z]+\].*$/gm)];
6
+ return headings.map((heading, index) => {
7
+ const start = heading.index;
8
+ const next = headings[index + 1]?.index ?? body.length;
9
+ const ownDepth = heading[0].match(/^#+/)[0].length;
10
+ const afterHeading = start + heading[0].length;
11
+ const shallower = [...body.slice(afterHeading, next).matchAll(/^(#{1,6})\s+.*$/gm)].find(m => m[1].length <= ownDepth);
12
+ const limit = shallower ? afterHeading + shallower.index : next;
13
+ const text = body.slice(start, limit).trimEnd();
14
+ return { start, end: start + text.length, text, id: text.match(/^\*\*Id\*\*:\s*(L-[a-f0-9]{10})\s*$/m)?.[1] ?? null };
15
+ });
16
+ }
17
+
18
+ function learningEntryMetadata(content) {
19
+ const metadata = {};
20
+ for (const match of content.matchAll(/^\*\*(Recurrences|First recorded|First synthesized)\*\*:\s*([^\r\n]+)$/gm)) {
21
+ if (metadata[match[1]] !== undefined && metadata[match[1]] !== match[2]) throw new Error('entry-metadata-conflict');
22
+ metadata[match[1]] = match[2];
23
+ }
24
+ return metadata;
25
+ }
26
+
27
+ module.exports = { learningEntryMetadata, learningEntryBlocks };
@@ -0,0 +1,78 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "mapVersion": 1,
4
+ "domains": [
5
+ "engineering",
6
+ "product",
7
+ "go-to-market",
8
+ "people-ops",
9
+ "finance",
10
+ "legal",
11
+ "compliance",
12
+ "customer",
13
+ "company-building",
14
+ "operations"
15
+ ],
16
+ "jobDomainMap": {
17
+ "product-building": "engineering",
18
+ "dev-ops": "engineering",
19
+ "quality-assurance": "engineering",
20
+ "migration": "engineering",
21
+ "replicate": "engineering",
22
+ "reviewer": "engineering",
23
+ "security": "engineering",
24
+ "delivery-ops": "engineering",
25
+ "salesforce": "engineering",
26
+ "ai-engineering": "engineering",
27
+ "product-management": "product",
28
+ "customer-development": "product",
29
+ "brainstorming": "product",
30
+ "marketing": "go-to-market",
31
+ "branding": "go-to-market",
32
+ "sales": "go-to-market",
33
+ "go-to-market": "go-to-market",
34
+ "business-development": "go-to-market",
35
+ "commercial-ops": "go-to-market",
36
+ "people-ops": "people-ops",
37
+ "inclusion-ops": "people-ops",
38
+ "career": "people-ops",
39
+ "finance": "finance",
40
+ "invoicing": "finance",
41
+ "banking": "finance",
42
+ "legal": "legal",
43
+ "compliance": "compliance",
44
+ "customer-success": "customer",
45
+ "customer-communication": "customer",
46
+ "company-creation": "company-building",
47
+ "bootstrap": "company-building",
48
+ "fundraising": "company-building",
49
+ "business-ops": "operations",
50
+ "procurement": "operations",
51
+ "project-management": "operations"
52
+ },
53
+ "globalJobCategories": [
54
+ "manager",
55
+ "fraim",
56
+ "1-1",
57
+ "coaching",
58
+ "delegation",
59
+ "hiring",
60
+ "productivity",
61
+ "project-setup",
62
+ "setup-guardrails",
63
+ "team-learning",
64
+ "verification"
65
+ ],
66
+ "domainVoice": {
67
+ "engineering": "Precise and technical; file paths, commands, and test names are appropriate.",
68
+ "product": "Product and user-outcome language; behavior and flow, not code symbols.",
69
+ "go-to-market": "Marketing and sales language; campaigns, funnels, messaging, audience.",
70
+ "people-ops": "Plain HR and recruiting language; no engineering jargon.",
71
+ "finance": "Finance and accounting language; figures, close, forecasts.",
72
+ "legal": "Contract and IP language; obligations, clauses, risk.",
73
+ "compliance": "Controls, evidence, and regulation language.",
74
+ "customer": "Customer-relationship and support language.",
75
+ "company-building": "Founder and operations language; setup, entity, funding.",
76
+ "operations": "Business-operations language; process, vendors, planning, coordination."
77
+ }
78
+ }
@@ -110,15 +110,27 @@ function buildBrowserLaunchArgs(opts) {
110
110
  * baked into any registry job or skill. Returns '' when the shared browser is not
111
111
  * available (so the note only appears when there is something to connect to).
112
112
  */
113
- function buildBrowserContextNote(cdpEndpoint, hubBaseUrl) {
113
+ function buildBrowserContextNote(cdpEndpoint, hubBaseUrl, conversationId, page) {
114
114
  if (!cdpEndpoint || !hubBaseUrl)
115
115
  return '';
116
+ const trimmedConversationId = typeof conversationId === 'string' ? conversationId.trim() : '';
117
+ const pageGuidance = trimmedConversationId
118
+ ? [
119
+ `Browser work for this Hub conversation must use the conversation page for \`${trimmedConversationId}\`, not the globally active tab.`,
120
+ `Before using a browser, ensure it is running and get this conversation's page: \`POST ${hubBaseUrl}/api/ai-hub/browser/start\` with JSON body \`{"conversationId":"${trimmedConversationId}"}\` (idempotent - it reuses the running browser and this conversation's page, or relaunches/recreates them if they were closed).`,
121
+ page?.targetId
122
+ ? `Current allocated page target: \`${page.targetId}\`${page.webSocketDebuggerUrl ? ` (${page.webSocketDebuggerUrl})` : ''}.`
123
+ : 'Use the returned `page.targetId` / `page.webSocketDebuggerUrl` from that endpoint when your browser tooling lets you choose a CDP target.',
124
+ ]
125
+ : [
126
+ `Before using a browser, ensure it is running: \`POST ${hubBaseUrl}/api/ai-hub/browser/start\` (idempotent - it reuses the running browser, or relaunches it if it was closed).`,
127
+ ];
116
128
  return [
117
129
  '',
118
- '[FRAIM shared browser] A persistent shared browser is available — use it instead of launching your own.',
119
- `Before using a browser, ensure it is running: \`POST ${hubBaseUrl}/api/ai-hub/browser/start\` (idempotent — it reuses the running browser, or relaunches it if it was closed).`,
120
- `Then drive the shared browser at ${cdpEndpoint}: your Playwright/browser tools are pointed at it on supported agents; if your tools would otherwise open their own browser, connect to the shared one with \`connectOverCDP("${cdpEndpoint}")\` and reuse its existing context/tab.`,
121
- 'Never close the shared browser or sign me out. If a site needs login, open it there and ask me to log in — the session persists to your next turn.',
130
+ '[FRAIM shared browser] A persistent shared browser is available; use it instead of launching your own.',
131
+ ...pageGuidance,
132
+ `Then drive the shared browser at ${cdpEndpoint}: your Playwright/browser tools are pointed at it on supported agents; if your tools would otherwise open their own browser, connect to the shared one with \`connectOverCDP("${cdpEndpoint}")\` and use the conversation page target instead of an arbitrary existing tab.`,
133
+ 'Never close the shared browser or sign me out. If a site needs login, open it there and ask me to log in; the session persists to your next turn.',
122
134
  ].join('\n');
123
135
  }
124
136
  /** Default persistent profile directory — sandboxed from the user's daily browser. */
@@ -168,6 +180,7 @@ class ManagedBrowser {
168
180
  this.child = null;
169
181
  this.ownsProcess = false;
170
182
  this.resolved = null;
183
+ this.conversationPages = new Map();
171
184
  this.opts = opts;
172
185
  this.channel = opts.channel || 'auto';
173
186
  this.port = opts.port || DEFAULT_PORT;
@@ -180,6 +193,11 @@ class ManagedBrowser {
180
193
  probe() {
181
194
  return this.opts.probeFn ? this.opts.probeFn(this.cdpEndpoint()) : probeCdpEndpoint(this.cdpEndpoint());
182
195
  }
196
+ async cdpRequest(requestPath, method = 'GET') {
197
+ if (this.opts.cdpRequestFn)
198
+ return this.opts.cdpRequestFn(requestPath, method);
199
+ return cdpJsonRequest(this.cdpEndpoint(), requestPath, method);
200
+ }
183
201
  /** Live check — does a Chromium CDP endpoint answer on the port right now. */
184
202
  isRunning() {
185
203
  return this.probe();
@@ -232,6 +250,71 @@ class ManagedBrowser {
232
250
  await this.waitForRunning();
233
251
  return { endpoint: this.cdpEndpoint(), reused: false, executable: exe.path, channel: exe.channel };
234
252
  }
253
+ async listPageTargets() {
254
+ const raw = await this.cdpRequest('/json/list');
255
+ if (!Array.isArray(raw))
256
+ return [];
257
+ return raw
258
+ .filter((target) => Boolean(target && typeof target === 'object' && typeof target.id === 'string'))
259
+ .filter((target) => !target.type || target.type === 'page');
260
+ }
261
+ pageFromTarget(conversationId, target) {
262
+ return {
263
+ conversationId,
264
+ targetId: target.id,
265
+ url: target.url || 'about:blank',
266
+ webSocketDebuggerUrl: target.webSocketDebuggerUrl || null,
267
+ endpoint: this.cdpEndpoint(),
268
+ };
269
+ }
270
+ async ensureConversationPage(conversationId, startUrl = 'about:blank') {
271
+ const id = conversationId.trim();
272
+ if (!id)
273
+ throw new Error('conversationId is required for browser page allocation.');
274
+ await this.start();
275
+ const existing = this.conversationPages.get(id);
276
+ const targets = await this.listPageTargets();
277
+ if (existing && targets.some((target) => target.id === existing.targetId)) {
278
+ try {
279
+ await this.cdpRequest(`/json/activate/${encodeURIComponent(existing.targetId)}`);
280
+ }
281
+ catch { /* best effort */ }
282
+ return existing;
283
+ }
284
+ const created = await this.cdpRequest(`/json/new?${encodeURIComponent(startUrl)}`, 'PUT');
285
+ if (!created || typeof created.id !== 'string') {
286
+ throw new Error('Browser did not return a page target for the conversation.');
287
+ }
288
+ const page = this.pageFromTarget(id, created);
289
+ this.conversationPages.set(id, page);
290
+ try {
291
+ await this.cdpRequest(`/json/activate/${encodeURIComponent(page.targetId)}`);
292
+ }
293
+ catch { /* best effort */ }
294
+ return page;
295
+ }
296
+ async releaseConversationPage(conversationId) {
297
+ const id = conversationId.trim();
298
+ if (!id)
299
+ return { released: false, conversationId: id, targetId: null };
300
+ const existing = this.conversationPages.get(id);
301
+ this.conversationPages.delete(id);
302
+ if (!existing)
303
+ return { released: false, conversationId: id, targetId: null };
304
+ if (await this.probe()) {
305
+ try {
306
+ await this.cdpRequest(`/json/close/${encodeURIComponent(existing.targetId)}`);
307
+ }
308
+ catch {
309
+ // The user may have closed the tab or browser manually. Dropping the
310
+ // mapping is the important cleanup invariant.
311
+ }
312
+ }
313
+ return { released: true, conversationId: id, targetId: existing.targetId };
314
+ }
315
+ conversationPage(conversationId) {
316
+ return this.conversationPages.get(conversationId.trim()) || null;
317
+ }
235
318
  async waitForRunning() {
236
319
  const timeoutMs = this.opts.waitTimeoutMs ?? 15000;
237
320
  const intervalMs = this.opts.waitIntervalMs ?? 250;
@@ -263,7 +346,49 @@ class ManagedBrowser {
263
346
  channel: this.resolved?.channel || this.channel,
264
347
  userDataDir: this.userDataDir,
265
348
  executable: this.resolved?.path || null,
349
+ conversationPages: Array.from(this.conversationPages.values()).map((page) => ({
350
+ conversationId: page.conversationId,
351
+ targetId: page.targetId,
352
+ })),
266
353
  };
267
354
  }
268
355
  }
269
356
  exports.ManagedBrowser = ManagedBrowser;
357
+ function cdpJsonRequest(endpoint, requestPath, method) {
358
+ return new Promise((resolve, reject) => {
359
+ let url;
360
+ try {
361
+ url = new URL(requestPath, endpoint);
362
+ }
363
+ catch (error) {
364
+ reject(error);
365
+ return;
366
+ }
367
+ const req = http_1.default.request(url, { method, timeout: 3000 }, (res) => {
368
+ let body = '';
369
+ res.on('data', (chunk) => { body += chunk.toString(); });
370
+ res.on('end', () => {
371
+ if ((res.statusCode || 500) >= 400) {
372
+ reject(new Error(`CDP ${method} ${requestPath} failed with HTTP ${res.statusCode}`));
373
+ return;
374
+ }
375
+ if (!body.trim()) {
376
+ resolve({});
377
+ return;
378
+ }
379
+ try {
380
+ resolve(JSON.parse(body));
381
+ }
382
+ catch {
383
+ resolve(body);
384
+ }
385
+ });
386
+ });
387
+ req.on('error', reject);
388
+ req.on('timeout', () => {
389
+ req.destroy();
390
+ reject(new Error(`CDP ${method} ${requestPath} timed out.`));
391
+ });
392
+ req.end();
393
+ });
394
+ }
@@ -3705,8 +3705,24 @@ class AiHubServer {
3705
3705
  // at spawn time instead, so it is available for the entire life of the
3706
3706
  // process. Call this at every startRun/continueRun call site (Direct-mode
3707
3707
  // spawns are exempt — they never load FRAIM, so there is nothing to report).
3708
- withHubRunIdEnv(launchContext, runId) {
3709
- return { ...launchContext, env: { ...(launchContext.env || {}), FRAIM_HUB_RUN_ID: runId } };
3708
+ withBrowserConversationEnv(launchContext, runId, conversationId, page) {
3709
+ return {
3710
+ ...launchContext,
3711
+ env: {
3712
+ ...(launchContext.env || {}),
3713
+ FRAIM_HUB_RUN_ID: runId,
3714
+ FRAIM_BROWSER_CONVERSATION_ID: conversationId,
3715
+ ...(page ? {
3716
+ FRAIM_BROWSER_PAGE_TARGET_ID: page.targetId,
3717
+ ...(page.webSocketDebuggerUrl ? { FRAIM_BROWSER_PAGE_WS_URL: page.webSocketDebuggerUrl } : {}),
3718
+ } : {}),
3719
+ },
3720
+ };
3721
+ }
3722
+ async releaseBrowserConversationPage(conversationId) {
3723
+ await this.managedBrowser.releaseConversationPage(conversationId).catch((error) => {
3724
+ console.warn('[ai-hub] shared browser page release failed:', error instanceof Error ? error.message : error);
3725
+ });
3710
3726
  }
3711
3727
  // Shared by both the runId-keyed and legacy by-session execution-mode routes.
3712
3728
  applyExecutionModeToRun(runId, body) {
@@ -4204,7 +4220,7 @@ class AiHubServer {
4204
4220
  this.maybeStartDelegatedChildRuns(updated);
4205
4221
  });
4206
4222
  },
4207
- }, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(freshLaunch.launchContext, run.id));
4223
+ }, startSessionSeedForHost(run.hostId, run.id), this.withBrowserConversationEnv(freshLaunch.launchContext, run.id, run.conversationId || run.id));
4208
4224
  this.runRegistry.attachChildIfRunning(run.id, freshChild);
4209
4225
  }
4210
4226
  scheduleRunConversationPersistence(run, activeId) {
@@ -4507,7 +4523,7 @@ class AiHubServer {
4507
4523
  onExit: (exitCode) => {
4508
4524
  this.handleRunExit(currentRun.id, exitCode, options.postPark);
4509
4525
  },
4510
- }, this.withHubRunIdEnv(launch.launchContext, currentRun.id));
4526
+ }, this.withBrowserConversationEnv(launch.launchContext, currentRun.id, currentRun.conversationId || currentRun.id));
4511
4527
  this.runRegistry.attachChildIfRunning(currentRun.id, child);
4512
4528
  const updated = this.runRegistry.get(currentRun.id);
4513
4529
  if (updated)
@@ -4840,7 +4856,7 @@ class AiHubServer {
4840
4856
  }
4841
4857
  this.runRegistry.dispose(childRun.id);
4842
4858
  },
4843
- }, startSessionSeedForHost(managerRun.hostId, childRun.id), this.withHubRunIdEnv(childLaunch.launchContext, childRun.id));
4859
+ }, startSessionSeedForHost(managerRun.hostId, childRun.id), this.withBrowserConversationEnv(childLaunch.launchContext, childRun.id, childRun.conversationId || childRun.id));
4844
4860
  this.runRegistry.attachChildIfRunning(childRun.id, child);
4845
4861
  }
4846
4862
  notifyManagerOfDelegatedChild(managerRunId, childRun) {
@@ -5004,7 +5020,7 @@ class AiHubServer {
5004
5020
  if (latestManager)
5005
5021
  this.drainPendingDelegatedReviews(latestManager);
5006
5022
  },
5007
- }, this.withHubRunIdEnv(managerLaunch.launchContext, managerRun.id));
5023
+ }, this.withBrowserConversationEnv(managerLaunch.launchContext, managerRun.id, managerRun.conversationId || managerRun.id));
5008
5024
  this.runRegistry.attachChildIfRunning(managerRun.id, child);
5009
5025
  }
5010
5026
  computeFirstRun(projectPath, jobCount, personas) {
@@ -5113,6 +5129,9 @@ class AiHubServer {
5113
5129
  // unpromoted and then dropping its phase signals).
5114
5130
  this.maybePromoteFreeformRunToJob(run, signal.jobName || signal.jobId);
5115
5131
  applySeekMentoringSignal(run, signal);
5132
+ if (signal.phaseId === 'retrospective' && signal.phaseStatus === 'complete') {
5133
+ void this.releaseBrowserConversationPage(run.conversationId || run.id);
5134
+ }
5116
5135
  }
5117
5136
  applyFraimJobSignalToRun(run, signal) {
5118
5137
  this.maybePromoteFreeformRunToJob(run, signal.jobId);
@@ -5153,7 +5172,7 @@ class AiHubServer {
5153
5172
  // #521: the shared-browser guidance is injected HERE, at the Hub layer — never
5154
5173
  // baked into the registry job/skill. It only appears when a shared browser is
5155
5174
  // available (env published at boot).
5156
- const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
5175
+ const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL, options?.browserConversationId, options?.browserPage);
5157
5176
  const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
5158
5177
  const backgroundTaskNote = (0, manager_turns_1.buildBackgroundTaskPolicyNote)();
5159
5178
  const ignoreEmbedded = options?.ignoreEmbeddedInvocation === true;
@@ -5873,7 +5892,7 @@ class AiHubServer {
5873
5892
  });
5874
5893
  // Issue #1740: interactive run deletion is a bounded one-record mutation. It requires an
5875
5894
  // explicit record-owned partition and never falls back to the currently selected project.
5876
- this.app.delete('/api/ai-hub/conversations/:conversationId', (req, res) => {
5895
+ this.app.delete('/api/ai-hub/conversations/:conversationId', async (req, res) => {
5877
5896
  try {
5878
5897
  const scope = scopeParam(req.query.scope);
5879
5898
  if (!scope && (typeof req.query.projectPath !== 'string' || req.query.projectPath.trim().length === 0)) {
@@ -5885,6 +5904,7 @@ class AiHubServer {
5885
5904
  const result = this.conversationStore.deleteConversation(key, req.params.conversationId);
5886
5905
  if (!result.removed)
5887
5906
  return res.status(404).json({ error: 'Conversation not found.' });
5907
+ await this.releaseBrowserConversationPage(req.params.conversationId);
5888
5908
  return res.json(result);
5889
5909
  }
5890
5910
  catch (error) {
@@ -6017,7 +6037,7 @@ class AiHubServer {
6017
6037
  attemptedRunId = run.id;
6018
6038
  const hostMessage = strategy === 'resume_same_host'
6019
6039
  ? this.prepareContinueMessage(run, managerNote).message
6020
- : this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, renderAgentSwitchHandoffPrompt(handoffSummary, managerNote)).message;
6040
+ : this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, renderAgentSwitchHandoffPrompt(handoffSummary, managerNote), { browserConversationId: run.conversationId || run.id }).message;
6021
6041
  this.runRegistry.create(run, {});
6022
6042
  this.persistRunConversation(run, run.conversationId || run.id);
6023
6043
  const handlers = {
@@ -6063,8 +6083,8 @@ class AiHubServer {
6063
6083
  };
6064
6084
  this.recordHubGeneratedEvent(run, 'agent_input', hostMessage);
6065
6085
  const child = strategy === 'resume_same_host'
6066
- ? this.hostRuntime.continueRun(run.hostId, run.projectPath, priorSessionId, hostMessage, handlers, this.withHubRunIdEnv(resolved.launchContext, run.id))
6067
- : this.hostRuntime.startRun(run.hostId, run.projectPath, hostMessage, handlers, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(resolved.launchContext, run.id));
6086
+ ? this.hostRuntime.continueRun(run.hostId, run.projectPath, priorSessionId, hostMessage, handlers, this.withBrowserConversationEnv(resolved.launchContext, run.id, run.conversationId || run.id))
6087
+ : this.hostRuntime.startRun(run.hostId, run.projectPath, hostMessage, handlers, startSessionSeedForHost(run.hostId, run.id), this.withBrowserConversationEnv(resolved.launchContext, run.id, run.conversationId || run.id));
6068
6088
  this.runRegistry.attachChildIfRunning(run.id, child);
6069
6089
  attemptedRunId = null;
6070
6090
  console.info(`[ai-hub] hub.agent_switch.${strategy}`, {
@@ -6794,7 +6814,7 @@ class AiHubServer {
6794
6814
  // The proxy parses the Execution Mode Context block and POSTs here; Hub script reads from run poll.
6795
6815
  //
6796
6816
  // Issue #1443: the local proxy's only stable identity for "which run is this" is the
6797
- // Hub's own run id (see withHubRunIdEnv / FRAIM_HUB_RUN_ID) — the FRAIM/MCP session id it
6817
+ // Hub's own run id (see FRAIM_HUB_RUN_ID) — the FRAIM/MCP session id it
6798
6818
  // carries is unrelated to any Hub run, and the host CLI's own session id (run.sessionId)
6799
6819
  // often isn't known yet when the first get_fraim_job call fires. Runs by id, not session.
6800
6820
  this.app.post('/api/ai-hub/runs/:runId/execution-mode', (req, res) => {
@@ -6849,8 +6869,13 @@ class AiHubServer {
6849
6869
  }
6850
6870
  const employees = this.hostRuntime.detectEmployees();
6851
6871
  const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(req.body.configuredAgentId, requestedHostId, employees);
6872
+ const runId = (0, crypto_1.randomUUID)();
6873
+ const requestedConversationId = typeof req.body.conversationId === 'string' && req.body.conversationId.trim()
6874
+ ? req.body.conversationId.trim()
6875
+ : undefined;
6876
+ const browserConversationId = requestedConversationId || runId;
6852
6877
  const prepared = instructions
6853
- ? this.prepareStartPayload(projectPath, hostId, req.body.jobId, instructions)
6878
+ ? this.prepareStartPayload(projectPath, hostId, req.body.jobId, instructions, { browserConversationId })
6854
6879
  : {
6855
6880
  jobId: req.body.jobId,
6856
6881
  message: legacyMessage,
@@ -6890,9 +6915,6 @@ class AiHubServer {
6890
6915
  const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
6891
6916
  ? req.body.jobTitle.trim()
6892
6917
  : jobId;
6893
- const requestedConversationId = typeof req.body.conversationId === 'string' && req.body.conversationId.trim()
6894
- ? req.body.conversationId.trim()
6895
- : undefined;
6896
6918
  const continuity = this.validateRunConversationContinuity({
6897
6919
  conversationId: requestedConversationId,
6898
6920
  projectPath,
@@ -6912,7 +6934,7 @@ class AiHubServer {
6912
6934
  ? this.resolveMessageAttachments((0, conversation_store_1.conversationScopeKey)(scope, projectPath), requestedConversationId, req.body.attachmentIds)
6913
6935
  : { attachments: undefined, referenceSuffix: '' };
6914
6936
  const run = {
6915
- id: (0, crypto_1.randomUUID)(),
6937
+ id: runId,
6916
6938
  conversationId: requestedConversationId,
6917
6939
  conversationTitle: typeof req.body.conversationTitle === 'string' && req.body.conversationTitle.trim() ? req.body.conversationTitle.trim() : undefined,
6918
6940
  jobTitle: jobMetadata?.title || fallbackJobTitle,
@@ -7025,7 +7047,7 @@ class AiHubServer {
7025
7047
  this.maybeStartDelegatedChildRuns(updated);
7026
7048
  });
7027
7049
  },
7028
- }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
7050
+ }, startSessionSeedForHost(hostId, run.id), this.withBrowserConversationEnv(launchContext, run.id, run.conversationId || run.id));
7029
7051
  pendingSpawnRuns.delete(run);
7030
7052
  this.runRegistry.attachChildIfRunning(run.id, child);
7031
7053
  // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
@@ -7103,9 +7125,14 @@ class AiHubServer {
7103
7125
  const running = await this.managedBrowser.isRunning();
7104
7126
  res.json({ running, ...this.managedBrowser.status() });
7105
7127
  });
7106
- this.app.post('/api/ai-hub/browser/start', async (_req, res) => {
7128
+ this.app.post('/api/ai-hub/browser/start', async (req, res) => {
7107
7129
  try {
7108
- const result = await this.ensureManagedBrowser();
7130
+ const body = (req.body ?? {});
7131
+ const conversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
7132
+ ? body.conversationId.trim()
7133
+ : undefined;
7134
+ const url = typeof body.url === 'string' && body.url.trim() ? body.url.trim() : undefined;
7135
+ const result = await this.ensureManagedBrowser(conversationId, url);
7109
7136
  res.json({
7110
7137
  ok: true,
7111
7138
  ...result,
@@ -7203,8 +7230,8 @@ class AiHubServer {
7203
7230
  ? buildAgentSwitchHandoffSummary(conversation)
7204
7231
  : null;
7205
7232
  const freshPayload = handoffSummary
7206
- ? this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, renderAgentSwitchHandoffPrompt(handoffSummary, prepared.display || message))
7207
- : this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, prepared.display || message);
7233
+ ? this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, renderAgentSwitchHandoffPrompt(handoffSummary, prepared.display || message), { browserConversationId: run.conversationId || run.id })
7234
+ : this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, prepared.display || message, { browserConversationId: run.conversationId || run.id });
7208
7235
  this.runRegistry.update(run.id, (current) => {
7209
7236
  current.status = 'running';
7210
7237
  current.pauseReason = 'working';
@@ -7269,7 +7296,7 @@ class AiHubServer {
7269
7296
  this.maybeStartDelegatedChildRuns(updated);
7270
7297
  });
7271
7298
  },
7272
- }, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(freshLaunch.launchContext, run.id));
7299
+ }, startSessionSeedForHost(run.hostId, run.id), this.withBrowserConversationEnv(freshLaunch.launchContext, run.id, run.conversationId || run.id));
7273
7300
  this.runRegistry.attachChildIfRunning(run.id, freshChild);
7274
7301
  const refreshedFresh = this.runRegistry.get(run.id);
7275
7302
  return res.json(refreshedFresh ? this.enrichRunForResponse(refreshedFresh) : refreshedFresh);
@@ -7392,7 +7419,7 @@ class AiHubServer {
7392
7419
  if (failedInterrupt)
7393
7420
  this.scheduleRunConversationPersistence(failedInterrupt, failedInterrupt.conversationId || failedInterrupt.id);
7394
7421
  },
7395
- }, this.withHubRunIdEnv(continueLaunch.launchContext, run.id), deliveryIntent);
7422
+ }, this.withBrowserConversationEnv(continueLaunch.launchContext, run.id, run.conversationId || run.id), deliveryIntent);
7396
7423
  if (!startedCodexReviewApprovalFallback)
7397
7424
  this.runRegistry.attachChildIfRunning(run.id, child);
7398
7425
  const refreshed = this.runRegistry.get(run.id);
@@ -7504,7 +7531,7 @@ class AiHubServer {
7504
7531
  const reviewApprovalSystemEventText = buildReviewApprovalSystemEventText(preparedResume.display || preparedResume.message);
7505
7532
  const browserContextNote = reviewApprovalSystemEventText
7506
7533
  ? ''
7507
- : (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
7534
+ : (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL, run.conversationId || run.id);
7508
7535
  const message = preparedResume.message + browserContextNote;
7509
7536
  // #521: bubble shows the manager's words; the agent gets the full message.
7510
7537
  const managerMessage = (0, hosts_1.createHubMessage)('manager', preparedResume.display || message);
@@ -7559,7 +7586,7 @@ class AiHubServer {
7559
7586
  onExit: (exitCode) => {
7560
7587
  this.handleRunExit(run.id, exitCode);
7561
7588
  },
7562
- }, this.withHubRunIdEnv(launchContext, run.id));
7589
+ }, this.withBrowserConversationEnv(launchContext, run.id, run.conversationId || run.id));
7563
7590
  this.runRegistry.attachChildIfRunning(run.id, child);
7564
7591
  res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
7565
7592
  }
@@ -8453,7 +8480,7 @@ class AiHubServer {
8453
8480
  this.finalizeRunConversationProjection(run.id, updated.conversationId || updated.id);
8454
8481
  this.runRegistry.dispose(run.id);
8455
8482
  },
8456
- }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
8483
+ }, startSessionSeedForHost(hostId, run.id), this.withBrowserConversationEnv(launchContext, run.id, run.conversationId || run.id));
8457
8484
  // Update the registry entry with the real child process handle.
8458
8485
  this.runRegistry.attachChildIfRunning(run.id, child);
8459
8486
  return res.json({
@@ -8473,10 +8500,13 @@ class AiHubServer {
8473
8500
  // #521: start (or reuse) the shared browser and publish its CDP endpoint so
8474
8501
  // every agent spawned afterward inherits FRAIM_BROWSER_CDP_ENDPOINT and its
8475
8502
  // browser-use skill connects to the long-lived window instead of a throwaway.
8476
- async ensureManagedBrowser() {
8503
+ async ensureManagedBrowser(conversationId, url) {
8477
8504
  const result = await this.managedBrowser.start();
8478
8505
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
8479
- return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
8506
+ const page = conversationId
8507
+ ? await this.managedBrowser.ensureConversationPage(conversationId, url)
8508
+ : undefined;
8509
+ return { endpoint: result.endpoint, reused: result.reused, channel: result.channel, ...(page ? { page } : {}) };
8480
8510
  }
8481
8511
  // ─── Issue #578: Scheduled deployment helpers ─────────────────────────────
8482
8512
  rehydrateScheduledDeployments() {
@@ -8734,7 +8764,7 @@ class AiHubServer {
8734
8764
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
8735
8765
  }),
8736
8766
  };
8737
- const childLaunchContext = this.withHubRunIdEnv(launchContext, run.id);
8767
+ const childLaunchContext = this.withBrowserConversationEnv(launchContext, run.id, run.conversationId || run.id);
8738
8768
  this.recordHubGeneratedEvent(run, 'agent_input', instructions);
8739
8769
  const child = existingHostSession
8740
8770
  ? this.hostRuntime.continueRun(hostId, effectiveDeployment.projectPath, existingHostSession.sessionId, instructions, handlers, childLaunchContext)
@@ -8917,7 +8947,7 @@ class AiHubServer {
8917
8947
  this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
8918
8948
  },
8919
8949
  onExit: (code) => this.handleRunExit(runId, code, postPark),
8920
- }, this.withHubRunIdEnv(launchContext, runId));
8950
+ }, this.withBrowserConversationEnv(launchContext, runId, current.conversationId || current.id));
8921
8951
  this.runRegistry.attachChildIfRunning(runId, child);
8922
8952
  }, delay);
8923
8953
  if (tid.unref)
@@ -113,12 +113,14 @@ function defaultCloneDir(layer) {
113
113
  * Resolve where this layer's details live. Never throws and never touches the
114
114
  * network; call `ensurePackClone` separately when the git working copy has to
115
115
  * be present and current.
116
+ * Read-only inventory callers set `migrateLegacy: false` to suppress the
117
+ * otherwise backwards-compatible flat org layout migration.
116
118
  */
117
- function resolvePackHome(layer) {
119
+ function resolvePackHome(layer, options = {}) {
118
120
  const config = (0, user_config_1.getLayerBackend)(layer);
119
121
  const standard = standardHome(layer);
120
122
  const make = (root, extra) => {
121
- if (layer === 'org')
123
+ if (layer === 'org' && options.migrateLegacy !== false)
122
124
  migrateOrgFlatContent(root);
123
125
  return { layer, root, contentRoot: path_1.default.join(root, 'personalized-employee'), ...extra };
124
126
  };