fraim 2.0.160 → 2.0.162

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.
@@ -8,18 +8,23 @@ exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
8
8
  exports.parseUsageSignal = parseUsageSignal;
9
9
  exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
10
10
  exports.detectEmployees = detectEmployees;
11
+ exports.prepareCodexBrowserHome = prepareCodexBrowserHome;
12
+ exports.sharedBrowserHostConfig = sharedBrowserHostConfig;
11
13
  exports.buildStartPlan = buildStartPlan;
12
14
  exports.buildContinuePlan = buildContinuePlan;
13
15
  exports.supportsDirectPath = supportsDirectPath;
14
16
  exports.buildDirectStartPlan = buildDirectStartPlan;
15
17
  exports.buildDirectContinuePlan = buildDirectContinuePlan;
16
18
  exports.parseHostLine = parseHostLine;
19
+ exports.findGeminiSessionIdForPrompt = findGeminiSessionIdForPrompt;
17
20
  const crypto_1 = require("crypto");
18
21
  const child_process_1 = require("child_process");
19
22
  const fs_1 = __importDefault(require("fs"));
20
23
  const os_1 = __importDefault(require("os"));
21
24
  const path_1 = __importDefault(require("path"));
22
25
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
26
+ const mcp_config_generator_1 = require("../cli/setup/mcp-config-generator");
27
+ const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
23
28
  // Parse a single line of host stdout looking for a seekMentoring tool-use
24
29
  // signal. Returns null if the line does not contain one. Supports both
25
30
  // hosts FRAIM ships against today:
@@ -89,8 +94,9 @@ function parseSeekMentoringSignal(line) {
89
94
  // Issue #347 — extract per-turn usage from the host's JSON stream.
90
95
  // Codex: `{"type":"turn.completed","usage":{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}}`.
91
96
  // Claude Code: `{"type":"result", ..., "usage":{input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens}, "total_cost_usd": ...}`.
97
+ // Gemini CLI: `{"stats":{"models":{"model-id":{"tokens":{input,prompt,cached,candidates,thoughts,tool}}}}}`.
92
98
  function parseUsageSignal(line) {
93
- if (!line.includes('usage'))
99
+ if (!line.includes('usage') && !line.includes('"stats"'))
94
100
  return null;
95
101
  let parsed;
96
102
  try {
@@ -138,6 +144,62 @@ function parseUsageSignal(line) {
138
144
  costUsd: costUsd ?? undefined,
139
145
  };
140
146
  }
147
+ // Gemini CLI JSON output reports per-model stats. Its `input` bucket is
148
+ // fresh prompt input, `cached` is prompt-cache reads, and `thoughts` are
149
+ // billable output/reasoning tokens.
150
+ if (typeof obj.stats === 'object' && obj.stats !== null) {
151
+ const stats = obj.stats;
152
+ const models = stats.models;
153
+ if (models && typeof models === 'object') {
154
+ let sawTokens = false;
155
+ let nonCachedInputTokens = 0;
156
+ let cachedInputTokens = 0;
157
+ let outputTokens = 0;
158
+ let reasoningTokens = 0;
159
+ let costUsd = 0;
160
+ let hasCompletePricing = true;
161
+ for (const [modelId, modelStats] of Object.entries(models)) {
162
+ if (typeof modelStats !== 'object' || modelStats === null)
163
+ continue;
164
+ const tokens = modelStats.tokens;
165
+ if (!tokens || typeof tokens !== 'object')
166
+ continue;
167
+ const freshInput = numberOrNull(tokens.input) ?? 0;
168
+ const cachedInput = numberOrNull(tokens.cached) ?? 0;
169
+ const candidates = numberOrNull(tokens.candidates) ?? 0;
170
+ const thoughts = numberOrNull(tokens.thoughts) ?? 0;
171
+ const toolTokens = numberOrNull(tokens.tool) ?? 0;
172
+ const modelOutput = candidates + thoughts + toolTokens;
173
+ if (freshInput === 0 && cachedInput === 0 && modelOutput === 0)
174
+ continue;
175
+ sawTokens = true;
176
+ nonCachedInputTokens += freshInput;
177
+ cachedInputTokens += cachedInput;
178
+ outputTokens += modelOutput;
179
+ reasoningTokens += thoughts;
180
+ const price = (0, agent_token_prices_1.lookupPrice)('gemini', modelId.toLowerCase());
181
+ if (price) {
182
+ costUsd +=
183
+ (freshInput / 1_000_000) * price.inputPerMTok +
184
+ (cachedInput / 1_000_000) * price.cacheReadPerMTok +
185
+ (modelOutput / 1_000_000) * price.outputPerMTok;
186
+ }
187
+ else {
188
+ hasCompletePricing = false;
189
+ }
190
+ }
191
+ if (!sawTokens)
192
+ return null;
193
+ return {
194
+ nonCachedInputTokens,
195
+ cachedInputTokens,
196
+ cacheCreationTokens: 0,
197
+ outputTokens,
198
+ reasoningTokens: reasoningTokens || undefined,
199
+ costUsd: hasCompletePricing ? costUsd : undefined,
200
+ };
201
+ }
202
+ }
141
203
  return null;
142
204
  }
143
205
  // Issue #347 — extract agent identity from the fraim_connect tool call.
@@ -212,7 +274,46 @@ function extractSignalFromArgs(args) {
212
274
  const discriminant = typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined;
213
275
  const jobName = typeof args.jobName === 'string' ? args.jobName : undefined;
214
276
  const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
215
- return { phaseId, phaseStatus, findingsText, discriminant, jobName, jobId };
277
+ const reviewHandoff = extractReviewHandoffFromArgs(args);
278
+ return { phaseId, phaseStatus, findingsText, discriminant, jobName, jobId, ...(reviewHandoff ? { reviewHandoff } : {}) };
279
+ }
280
+ function extractReviewHandoffFromArgs(args) {
281
+ const direct = readReviewHandoffCandidate(args.reviewHandoff);
282
+ if (direct)
283
+ return direct;
284
+ const evidence = args.evidence;
285
+ if (evidence && typeof evidence === 'object') {
286
+ return readReviewHandoffCandidate(evidence.reviewHandoff);
287
+ }
288
+ return null;
289
+ }
290
+ function readReviewHandoffCandidate(value) {
291
+ let candidate = value;
292
+ if (typeof candidate === 'string') {
293
+ try {
294
+ candidate = JSON.parse(candidate);
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
301
+ return null;
302
+ const obj = candidate;
303
+ if (typeof obj.reviewRequired !== 'boolean')
304
+ return null;
305
+ const artifacts = Array.isArray(obj.artifacts)
306
+ ? obj.artifacts.filter((artifact) => artifact && typeof artifact === 'object')
307
+ : [];
308
+ return {
309
+ reviewRequired: obj.reviewRequired,
310
+ reviewTarget: obj.reviewTarget && typeof obj.reviewTarget === 'object'
311
+ ? obj.reviewTarget
312
+ : null,
313
+ artifacts,
314
+ ...(typeof obj.summary === 'string' ? { summary: obj.summary } : {}),
315
+ ...(typeof obj.feedbackMode === 'string' ? { feedbackMode: obj.feedbackMode } : {}),
316
+ };
216
317
  }
217
318
  const EMPLOYEE_LABELS = {
218
319
  codex: 'Codex',
@@ -302,8 +403,40 @@ function transformHeadlessFraimMessage(message, kind) {
302
403
  if (parsed.remainder) {
303
404
  parts.push(`\n\nManager instructions: ${parsed.remainder}`);
304
405
  }
406
+ const storageGuard = machineLevelStorageGuard(parsed.jobId);
407
+ if (storageGuard) {
408
+ parts.push(`\n\n${storageGuard}`);
409
+ }
305
410
  return parts.join('');
306
411
  }
412
+ function machineLevelStorageGuard(jobId) {
413
+ const normalized = jobId.toLowerCase();
414
+ const userFraim = path_1.default.join(os_1.default.homedir(), '.fraim');
415
+ if (normalized === 'manager-agreements') {
416
+ const managerContext = path_1.default.join(userFraim, 'personalized-employee', 'context', 'manager_context.md');
417
+ const managerRules = path_1.default.join(userFraim, 'personalized-employee', 'rules', 'manager_rules.md');
418
+ return [
419
+ 'Storage scope guardrail:',
420
+ '- Manager agreements artifacts are machine-level, not repo-level.',
421
+ `- Required write targets: ${managerContext} and ${managerRules}.`,
422
+ '- Keep the split crisp: manager_context.md is what is true about the manager; manager_rules.md is how employees must behave because of those truths.',
423
+ '- Do not write, validate, call canonical, commit, or open a PR for repo-local fraim/personalized-employee/context/manager_context.md or fraim/personalized-employee/rules/manager_rules.md as substitutes.',
424
+ '- If the exact machine-level paths cannot be written, fail the phase and report the concrete filesystem error.',
425
+ ].join('\n');
426
+ }
427
+ if (normalized === 'organization-onboarding') {
428
+ const orgContext = path_1.default.join(userFraim, 'personalized-employee', 'context', 'org_context.md');
429
+ const orgRules = path_1.default.join(userFraim, 'personalized-employee', 'rules', 'org_rules.md');
430
+ return [
431
+ 'Storage scope guardrail:',
432
+ '- Organization onboarding artifacts are machine-level, not repo-level.',
433
+ `- Required write targets: ${orgContext} and ${orgRules}.`,
434
+ '- Do not write, validate, call canonical, commit, or open a PR for repo-local fraim/personalized-employee/context/org_context.md or fraim/personalized-employee/rules/org_rules.md as substitutes.',
435
+ '- If the exact machine-level paths cannot be written, fail the phase and report the concrete filesystem error.',
436
+ ].join('\n');
437
+ }
438
+ return null;
439
+ }
307
440
  // If ~/.gemini/settings.json has a wrong/test FRAIM_API_KEY, patch it with the
308
441
  // real key from ~/.fraim/config.json so the FRAIM MCP server can authenticate.
309
442
  // This self-heals when a test run accidentally writes a test key to global config.
@@ -329,49 +462,165 @@ function ensureGeminiApiKey() {
329
462
  }
330
463
  catch { /* best-effort: never crash the Hub over a config patch */ }
331
464
  }
332
- function buildStartPlan(hostId, message) {
465
+ // Build (idempotently) a Hub-managed CODEX_HOME so `codex exec` drives the shared
466
+ // browser. config.toml = user's real config + a playwright→cdp override; auth is
467
+ // copied; the sessions dir is junctioned to the real one so resume + new sessions
468
+ // keep working. The user's real ~/.codex is never modified. Returns the temp home
469
+ // path, or null on any failure (caller then leaves Codex on its own browser).
470
+ function prepareCodexBrowserHome(cdp, env = process.env) {
471
+ try {
472
+ const real = env['CODEX_HOME'] || path_1.default.join(os_1.default.homedir(), '.codex');
473
+ // Derive the temp-home path from the real home so different real homes (e.g.
474
+ // a test's fake home vs the user's ~/.codex) never share — and pollute — one
475
+ // temp home (a stale sessions junction breaks resume).
476
+ const homeKey = (0, crypto_1.createHash)('sha1').update(real).digest('hex').slice(0, 12);
477
+ const home = path_1.default.join(os_1.default.tmpdir(), 'fraim-codex-home-' + homeKey);
478
+ fs_1.default.mkdirSync(home, { recursive: true });
479
+ // config.toml = real config with the playwright server redirected to the CDP endpoint.
480
+ const realConfig = path_1.default.join(real, 'config.toml');
481
+ const existing = fs_1.default.existsSync(realConfig) ? fs_1.default.readFileSync(realConfig, 'utf8') : '';
482
+ const pwBlock = `[mcp_servers.playwright]\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "${cdp}"]\n`;
483
+ const merged = (0, mcp_config_generator_1.mergeTomlMCPServers)(existing, pwBlock, ['playwright']).content;
484
+ fs_1.default.writeFileSync(path_1.default.join(home, 'config.toml'), merged, 'utf8');
485
+ // Auth + the session index (so resume can find existing rollouts by thread id).
486
+ for (const f of ['auth.json', 'session_index.jsonl', 'history.jsonl']) {
487
+ const src = path_1.default.join(real, f);
488
+ if (fs_1.default.existsSync(src)) {
489
+ try {
490
+ fs_1.default.copyFileSync(src, path_1.default.join(home, f));
491
+ }
492
+ catch { /* best effort */ }
493
+ }
494
+ }
495
+ // Sessions: junction temp/sessions -> real/sessions so resume (existing sessions)
496
+ // works and new sessions persist alongside the user's. Re-point if the existing
497
+ // junction targets the wrong dir.
498
+ const realSessions = path_1.default.join(real, 'sessions');
499
+ fs_1.default.mkdirSync(realSessions, { recursive: true });
500
+ const tmpSessions = path_1.default.join(home, 'sessions');
501
+ try {
502
+ let ok = false;
503
+ if (fs_1.default.existsSync(tmpSessions)) {
504
+ const st = fs_1.default.lstatSync(tmpSessions);
505
+ if (st.isSymbolicLink()) {
506
+ ok = path_1.default.resolve(fs_1.default.readlinkSync(tmpSessions)) === path_1.default.resolve(realSessions);
507
+ if (!ok)
508
+ fs_1.default.unlinkSync(tmpSessions); // wrong target → drop the junction (not the target)
509
+ }
510
+ }
511
+ if (!ok && !fs_1.default.existsSync(tmpSessions))
512
+ fs_1.default.symlinkSync(realSessions, tmpSessions, 'junction');
513
+ }
514
+ catch { /* junction best-effort; new runs still work without resuming old sessions */ }
515
+ return home;
516
+ }
517
+ catch {
518
+ return null;
519
+ }
520
+ }
521
+ function sharedBrowserHostConfig(hostId, env = process.env) {
522
+ const cdp = env['FRAIM_BROWSER_CDP_ENDPOINT'];
523
+ if (!cdp)
524
+ return { args: [] };
525
+ const pwArgs = ['-y', '@playwright/mcp@latest', '--cdp-endpoint', cdp];
526
+ const mcpServers = { playwright: { command: 'npx', args: pwArgs } };
527
+ if (hostId === 'claude') {
528
+ // Claude Code's --mcp-config takes a FILE path (inline JSON is rejected by the
529
+ // CLI's schema). Write the ephemeral config to a temp file and pass its path —
530
+ // per-invocation; never touches the user's persisted ~/.claude.json.
531
+ const file = path_1.default.join(os_1.default.tmpdir(), 'fraim-shared-browser-mcp.json');
532
+ try {
533
+ fs_1.default.writeFileSync(file, JSON.stringify({ mcpServers }), 'utf8');
534
+ }
535
+ catch {
536
+ return { args: [] };
537
+ }
538
+ return { args: ['--mcp-config', file] };
539
+ }
333
540
  if (hostId === 'codex') {
541
+ // Codex `exec` ignores -c overrides of mcp_servers, so we point CODEX_HOME at a
542
+ // Hub-managed temp dir whose config.toml merges the user's real config with a
543
+ // playwright→cdp override. Auth is copied and the sessions dir is junctioned to
544
+ // ~/.codex/sessions, so resume (incl. existing sessions) still works and new
545
+ // sessions persist there. The user's real ~/.codex/config.toml is never touched.
546
+ const home = prepareCodexBrowserHome(cdp, env);
547
+ return home ? { args: [], env: { CODEX_HOME: home } } : { args: [] };
548
+ }
549
+ if (hostId === 'gemini') {
550
+ // Gemini CLI has no per-invocation MCP flag, but it loads a SYSTEM settings
551
+ // file from GEMINI_CLI_SYSTEM_SETTINGS_PATH which overrides the same-named
552
+ // server. Point it at an ephemeral temp file — per-invocation via env; the
553
+ // user's ~/.gemini/settings.json is untouched.
554
+ const file = path_1.default.join(os_1.default.tmpdir(), 'fraim-gemini-browser-settings.json');
555
+ try {
556
+ fs_1.default.writeFileSync(file, JSON.stringify({ mcpServers }), 'utf8');
557
+ }
558
+ catch {
559
+ return { args: [] };
560
+ }
561
+ return { args: [], env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: file } };
562
+ }
563
+ return { args: [] };
564
+ }
565
+ function buildStartPlan(hostId, message, sessionId) {
566
+ if (hostId === 'codex') {
567
+ const browser = sharedBrowserHostConfig('codex');
334
568
  return {
335
569
  command: executableName('codex'),
336
- args: ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'],
570
+ args: ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', ...browser.args],
337
571
  stdin: transformHeadlessFraimMessage(message, 'start'),
572
+ env: browser.env,
338
573
  };
339
574
  }
340
575
  if (hostId === 'gemini') {
341
576
  ensureGeminiApiKey();
577
+ const prompt = transformHeadlessFraimMessage(message, 'start');
578
+ const browser = sharedBrowserHostConfig('gemini');
342
579
  return {
343
580
  command: executableName('gemini'),
344
- args: ['--yolo', '--skip-trust'],
345
- stdin: transformHeadlessFraimMessage(message, 'start'),
581
+ // Gemini CLI creates the durable session id itself. Hub captures
582
+ // that real id from Gemini's chat log after start; pre-seeded UUIDs
583
+ // are not reliably accepted by `gemini --resume`.
584
+ args: ['--yolo', '--skip-trust', '-p', ' ', ...browser.args],
585
+ stdin: prompt,
586
+ env: browser.env,
346
587
  };
347
588
  }
589
+ const browser = sharedBrowserHostConfig('claude');
348
590
  return {
349
591
  command: executableName('claude'),
350
- args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'],
592
+ args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', ...browser.args],
351
593
  stdin: transformHeadlessFraimMessage(message, 'start'),
594
+ env: browser.env,
352
595
  };
353
596
  }
354
597
  function buildContinuePlan(hostId, sessionId, message) {
355
598
  if (hostId === 'codex') {
599
+ const browser = sharedBrowserHostConfig('codex');
356
600
  return {
357
601
  command: executableName('codex'),
358
- args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', sessionId],
602
+ args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', sessionId, ...browser.args],
359
603
  stdin: transformHeadlessFraimMessage(message, 'continue'),
604
+ env: browser.env,
360
605
  };
361
606
  }
362
607
  if (hostId === 'gemini') {
363
- // Gemini CLI does not have a native session-resume flag; each message
364
- // is sent as a fresh invocation. The Hub still tracks state client-side.
608
+ ensureGeminiApiKey();
609
+ const prompt = transformHeadlessFraimMessage(message, 'continue');
610
+ const browser = sharedBrowserHostConfig('gemini');
365
611
  return {
366
612
  command: executableName('gemini'),
367
- args: ['--yolo', '--skip-trust'],
368
- stdin: transformHeadlessFraimMessage(message, 'continue'),
613
+ args: ['--resume', sessionId, '--yolo', '--skip-trust', '-p', ' ', ...browser.args],
614
+ stdin: prompt,
615
+ env: browser.env,
369
616
  };
370
617
  }
618
+ const browser = sharedBrowserHostConfig('claude');
371
619
  return {
372
620
  command: executableName('claude'),
373
- args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', '-r', sessionId],
621
+ args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', '-r', sessionId, ...browser.args],
374
622
  stdin: transformHeadlessFraimMessage(message, 'continue'),
623
+ env: browser.env,
375
624
  };
376
625
  }
377
626
  // Issue #442: all agents support a direct-path invocation (no FRAIM, no
@@ -401,7 +650,7 @@ const DIRECT_PREAMBLE = 'DO NOT USE FRAIM FOR THIS SESSION. No phases, no seekMe
401
650
  // Issue #442: builds a CLI plan for the Direct (B) side of an A/B run.
402
651
  // All agents supported: Codex and Gemini run raw (no FRAIM preamble);
403
652
  // Claude uses --strict-mcp-config + --append-system-prompt for full isolation.
404
- function buildDirectStartPlan(hostId, message) {
653
+ function buildDirectStartPlan(hostId, message, sessionId) {
405
654
  if (hostId === 'codex') {
406
655
  return {
407
656
  command: executableName('codex'),
@@ -413,7 +662,7 @@ function buildDirectStartPlan(hostId, message) {
413
662
  ensureGeminiApiKey();
414
663
  return {
415
664
  command: executableName('gemini'),
416
- args: ['--yolo', '--skip-trust'],
665
+ args: ['--yolo', '--skip-trust', '-p', ' '],
417
666
  stdin: DIRECT_PREAMBLE + message,
418
667
  };
419
668
  }
@@ -443,7 +692,7 @@ function buildDirectContinuePlan(hostId, sessionId, message) {
443
692
  ensureGeminiApiKey();
444
693
  return {
445
694
  command: executableName('gemini'),
446
- args: ['--yolo', '--skip-trust'],
695
+ args: ['--resume', sessionId, '--yolo', '--skip-trust', '-p', ' '],
447
696
  stdin: DIRECT_PREAMBLE + message,
448
697
  };
449
698
  }
@@ -502,10 +751,16 @@ function parseHostLine(hostId, line) {
502
751
  // message so it still surfaces in the Hub timeline.
503
752
  if (hostId === 'gemini') {
504
753
  try {
505
- JSON.parse(trimmed); // validate JSON — if this throws, fall through to plain text
754
+ const parsed = JSON.parse(trimmed);
755
+ if (typeof parsed.session_id === 'string' && parsed.session_id.trim().length > 0) {
756
+ return withSignal({ sessionId: parsed.session_id.trim(), raw: trimmed });
757
+ }
506
758
  return withSignal({ raw: trimmed });
507
759
  }
508
760
  catch {
761
+ if (isGeminiCliNotice(trimmed)) {
762
+ return withSignal({ raw: trimmed });
763
+ }
509
764
  return withSignal({ message: trimmed, raw: trimmed });
510
765
  }
511
766
  }
@@ -531,6 +786,10 @@ function parseHostLine(hostId, line) {
531
786
  return withSignal({ raw: trimmed });
532
787
  }
533
788
  }
789
+ function isGeminiCliNotice(line) {
790
+ return line === 'YOLO mode is enabled. All tool calls will be automatically approved.' ||
791
+ line === 'Ripgrep is not available. Falling back to GrepTool.';
792
+ }
534
793
  function wireHostProcess(hostId, child, handlers) {
535
794
  const wire = (buffer, channel) => {
536
795
  let pending = '';
@@ -561,6 +820,7 @@ function wireHostProcess(hostId, child, handlers) {
561
820
  }
562
821
  function spawnHostProcess(hostId, plan, projectPath, handlers) {
563
822
  const invocation = resolveHostInvocation(plan);
823
+ const startedAtMs = Date.now();
564
824
  const child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
565
825
  cwd: projectPath,
566
826
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -570,20 +830,114 @@ function spawnHostProcess(hostId, plan, projectPath, handlers) {
570
830
  child.stdin.write(plan.stdin);
571
831
  }
572
832
  child.stdin.end();
833
+ if (typeof plan.stdin === 'string' && !plan.args.includes('--resume')) {
834
+ child.once('close', () => {
835
+ const sessionId = discoverSessionIdAfterStart(hostId, projectPath, plan.stdin || '', startedAtMs);
836
+ if (sessionId) {
837
+ handlers.onEvent({ sessionId, raw: `${hostId}-session:${sessionId}` }, 'system');
838
+ }
839
+ });
840
+ }
573
841
  return wireHostProcess(hostId, child, handlers);
574
842
  }
843
+ function discoverSessionIdAfterStart(hostId, projectPath, prompt, startedAtMs) {
844
+ if (hostId !== 'gemini')
845
+ return null;
846
+ return findGeminiSessionIdForPrompt(projectPath, prompt, startedAtMs);
847
+ }
848
+ function findGeminiSessionIdForPrompt(_projectPath, prompt, startedAtMs) {
849
+ const promptNeedle = normalizeGeminiPromptForMatch(prompt).slice(0, 160);
850
+ const records = readGeminiSessionRecords();
851
+ const recent = records
852
+ .filter((record) => record.updatedAtMs >= startedAtMs - 10_000)
853
+ .filter((record) => {
854
+ if (!promptNeedle)
855
+ return true;
856
+ const userText = normalizeGeminiPromptForMatch(record.userText);
857
+ return userText.includes(promptNeedle) || promptNeedle.includes(userText.slice(0, 80));
858
+ })
859
+ .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
860
+ if (recent[0])
861
+ return recent[0].sessionId;
862
+ const fallback = records
863
+ .filter((record) => record.updatedAtMs >= startedAtMs - 10_000)
864
+ .sort((a, b) => b.updatedAtMs - a.updatedAtMs)[0];
865
+ return fallback?.sessionId || null;
866
+ }
867
+ function readGeminiSessionRecords() {
868
+ const root = path_1.default.join(os_1.default.homedir(), '.gemini', 'tmp');
869
+ if (!fs_1.default.existsSync(root))
870
+ return [];
871
+ const records = [];
872
+ for (const filePath of collectGeminiSessionFiles(root)) {
873
+ try {
874
+ const stat = fs_1.default.statSync(filePath);
875
+ const lines = fs_1.default.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
876
+ const metadata = JSON.parse(lines[0] || '{}');
877
+ const sessionId = typeof metadata.sessionId === 'string' ? metadata.sessionId.trim() : '';
878
+ if (!sessionId)
879
+ continue;
880
+ const userText = lines
881
+ .slice(1, 8)
882
+ .map((line) => {
883
+ try {
884
+ const entry = JSON.parse(line);
885
+ if (entry.type !== 'user' || !Array.isArray(entry.content))
886
+ return '';
887
+ return entry.content.map((part) => typeof part.text === 'string' ? part.text : '').join('\n');
888
+ }
889
+ catch {
890
+ return '';
891
+ }
892
+ })
893
+ .filter(Boolean)
894
+ .join('\n');
895
+ records.push({ sessionId, filePath, updatedAtMs: stat.mtimeMs, userText });
896
+ }
897
+ catch {
898
+ // Ignore malformed or concurrently-written session files.
899
+ }
900
+ }
901
+ return records;
902
+ }
903
+ function collectGeminiSessionFiles(root) {
904
+ const files = [];
905
+ const visit = (dir) => {
906
+ let entries;
907
+ try {
908
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
909
+ }
910
+ catch {
911
+ return;
912
+ }
913
+ for (const entry of entries) {
914
+ const fullPath = path_1.default.join(dir, entry.name);
915
+ if (entry.isDirectory()) {
916
+ visit(fullPath);
917
+ }
918
+ else if (/^session-.*\.jsonl$/i.test(entry.name)) {
919
+ files.push(fullPath);
920
+ }
921
+ }
922
+ };
923
+ visit(root);
924
+ return files;
925
+ }
926
+ function normalizeGeminiPromptForMatch(value) {
927
+ return String(value || '').replace(/\s+/g, ' ').trim();
928
+ }
575
929
  class CliHostRuntime {
576
930
  detectEmployees() {
577
931
  return detectEmployees();
578
932
  }
579
- startRun(hostId, projectPath, message, handlers) {
580
- return spawnHostProcess(hostId, buildStartPlan(hostId, message), projectPath, handlers);
933
+ startRun(hostId, projectPath, message, handlers, sessionId) {
934
+ return spawnHostProcess(hostId, buildStartPlan(hostId, message, sessionId), projectPath, handlers);
581
935
  }
582
936
  continueRun(hostId, projectPath, sessionId, message, handlers) {
583
937
  return spawnHostProcess(hostId, buildContinuePlan(hostId, sessionId, message), projectPath, handlers);
584
938
  }
585
- startDirectRun(hostId, message, projectPath, handlers) {
586
- return spawnHostProcess(hostId, buildDirectStartPlan(hostId, message), projectPath, handlers);
939
+ startDirectRun(hostId, message, projectPath, handlers, sessionId) {
940
+ return spawnHostProcess(hostId, buildDirectStartPlan(hostId, message, sessionId), projectPath, handlers);
587
941
  }
588
942
  continueDirectRun(hostId, sessionId, message, projectPath, handlers) {
589
943
  return spawnHostProcess(hostId, buildDirectContinuePlan(hostId, sessionId, message), projectPath, handlers);
@@ -601,13 +955,15 @@ class FakeHostRuntime {
601
955
  detectEmployees() {
602
956
  return this.employees;
603
957
  }
604
- startRun(hostId, _projectPath, message, handlers) {
958
+ startRun(hostId, _projectPath, message, handlers, _sessionId) {
959
+ this.lastStartMessage = message;
605
960
  return this.fakeProcess(hostId, this.fakeEmployeeReply('start', message), handlers);
606
961
  }
607
962
  continueRun(hostId, _projectPath, sessionId, message, handlers) {
963
+ this.lastContinueMessage = message;
608
964
  return this.fakeProcess(hostId, this.fakeEmployeeReply('continue', message), handlers);
609
965
  }
610
- startDirectRun(hostId, _message, _projectPath, handlers) {
966
+ startDirectRun(hostId, _message, _projectPath, handlers, _sessionId) {
611
967
  return this.fakeProcess(hostId, 'Understood. Working directly on that now.', handlers);
612
968
  }
613
969
  continueDirectRun(hostId, _sessionId, _message, _projectPath, handlers) {
@@ -688,8 +1044,8 @@ class ScriptedHostRuntime {
688
1044
  detectEmployees() {
689
1045
  return this.employees;
690
1046
  }
691
- startRun(_hostId, _projectPath, _message, handlers) {
692
- const sessionId = (0, crypto_1.randomUUID)();
1047
+ startRun(_hostId, _projectPath, _message, handlers, requestedSessionId) {
1048
+ const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
693
1049
  handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
694
1050
  this.handlersBySession.set(sessionId, handlers);
695
1051
  return this.spawnDouble();
@@ -699,8 +1055,8 @@ class ScriptedHostRuntime {
699
1055
  handlers.onEvent({ sessionId, raw: 'scripted-session-resume' }, 'system');
700
1056
  return this.spawnDouble();
701
1057
  }
702
- startDirectRun(_hostId, _message, _projectPath, handlers) {
703
- const sessionId = (0, crypto_1.randomUUID)();
1058
+ startDirectRun(_hostId, _message, _projectPath, handlers, requestedSessionId) {
1059
+ const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
704
1060
  handlers.onEvent({ sessionId, raw: 'scripted-direct-session-start' }, 'system');
705
1061
  return this.spawnDouble();
706
1062
  }