fraim 2.0.161 → 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.
- package/dist/src/ai-hub/conversation-store.js +164 -0
- package/dist/src/ai-hub/desktop-main.js +24 -1
- package/dist/src/ai-hub/hosts.js +383 -27
- package/dist/src/ai-hub/managed-browser.js +269 -0
- package/dist/src/ai-hub/manager-turns.js +13 -0
- package/dist/src/ai-hub/preferences.js +10 -1
- package/dist/src/ai-hub/server.js +1227 -65
- package/dist/src/cli/commands/init-project.js +7 -1
- package/dist/src/cli/utils/agent-adapters.js +1 -1
- package/dist/src/core/fraim-config-schema.generated.js +50 -13
- package/dist/src/local-mcp-server/agent-token-prices.js +23 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +438 -2
- package/dist/src/local-mcp-server/stdio-server.js +45 -6
- package/package.json +5 -4
- package/public/ai-hub/index.html +456 -7
- package/public/ai-hub/review.css +354 -0
- package/public/ai-hub/script.js +5945 -1279
- package/public/ai-hub/styles.css +1805 -16
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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:
|
|
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);
|
|
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
|
}
|