fraim 2.0.203 → 2.0.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +3 -3
  2. package/bin/fraim.js +8 -18
  3. package/dist/src/cli/commands/first-run.js +2 -37
  4. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +1 -1
  5. package/dist/src/cli/fraim.js +0 -2
  6. package/dist/src/cli/setup/ide-invocation-surfaces.js +6 -6
  7. package/dist/src/cli/utils/managed-agent-paths.js +76 -6
  8. package/dist/src/cli/utils/version-utils.js +2 -2
  9. package/dist/src/config/persona-capability-bundles.js +3 -3
  10. package/dist/src/core/job-phases.js +168 -0
  11. package/dist/src/core/utils/ports.js +32 -0
  12. package/dist/src/core/utils/stub-generator.js +24 -2
  13. package/dist/src/first-run/session-service.js +106 -24
  14. package/dist/src/local-mcp-server/learning-context-builder.js +1 -1
  15. package/dist/src/local-mcp-server/stdio-server.js +41 -9
  16. package/dist/src/routes/analytics.js +2 -2
  17. package/dist/src/services/registry-service.js +2 -2
  18. package/index.js +1 -1
  19. package/package.json +61 -181
  20. package/dist/src/ai-hub/catalog.js +0 -406
  21. package/dist/src/ai-hub/cert-store.js +0 -70
  22. package/dist/src/ai-hub/conversation-store.js +0 -359
  23. package/dist/src/ai-hub/desktop-main.js +0 -449
  24. package/dist/src/ai-hub/hosts.js +0 -1664
  25. package/dist/src/ai-hub/hub-latest-version.js +0 -52
  26. package/dist/src/ai-hub/hub-launch-decision.js +0 -57
  27. package/dist/src/ai-hub/hub-runtime-file.js +0 -43
  28. package/dist/src/ai-hub/managed-browser.js +0 -269
  29. package/dist/src/ai-hub/manager-turns.js +0 -64
  30. package/dist/src/ai-hub/office-sideload.js +0 -156
  31. package/dist/src/ai-hub/openclaw-bridge.js +0 -250
  32. package/dist/src/ai-hub/preferences.js +0 -201
  33. package/dist/src/ai-hub/remote-hub-gateway.js +0 -88
  34. package/dist/src/ai-hub/server.js +0 -3827
  35. package/dist/src/ai-hub/types.js +0 -2
  36. package/dist/src/ai-hub/word-sideload.js +0 -95
  37. package/dist/src/cli/commands/hub.js +0 -238
  38. package/dist/src/cli/commands/test-mcp.js +0 -171
  39. package/dist/src/cli/setup/first-run.js +0 -242
  40. package/dist/src/core/config-writer.js +0 -75
  41. package/dist/src/core/utils/job-aliases.js +0 -47
  42. package/dist/src/core/utils/workflow-parser.js +0 -174
  43. package/dist/src/services/email-service-clean.js +0 -782
  44. package/extensions/office-word/favicon.ico +0 -0
  45. package/extensions/office-word/icon-64.png +0 -0
  46. package/extensions/office-word/manifest.xml +0 -33
  47. package/extensions/office-word/taskpane.html +0 -242
  48. package/public/ai-hub/index.html +0 -894
  49. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  50. package/public/ai-hub/powerpoint-taskpane/index.html +0 -236
  51. package/public/ai-hub/powerpoint-taskpane/manifest.xml +0 -30
  52. package/public/ai-hub/review.css +0 -381
  53. package/public/ai-hub/script.js +0 -11015
  54. package/public/ai-hub/styles.css +0 -4873
  55. package/public/portfolio/ashley.html +0 -523
  56. package/public/portfolio/auditya.html +0 -83
  57. package/public/portfolio/banke.html +0 -83
  58. package/public/portfolio/beza.html +0 -659
  59. package/public/portfolio/careena.html +0 -632
  60. package/public/portfolio/casey.html +0 -568
  61. package/public/portfolio/celia.html +0 -490
  62. package/public/portfolio/deidre.html +0 -642
  63. package/public/portfolio/gautam.html +0 -597
  64. package/public/portfolio/hari.html +0 -469
  65. package/public/portfolio/huxley.html +0 -1354
  66. package/public/portfolio/index.html +0 -741
  67. package/public/portfolio/maestro.html +0 -518
  68. package/public/portfolio/mandy.html +0 -590
  69. package/public/portfolio/mona.html +0 -597
  70. package/public/portfolio/pam.html +0 -887
  71. package/public/portfolio/procella.html +0 -107
  72. package/public/portfolio/qasm.html +0 -569
  73. package/public/portfolio/ricardo.html +0 -489
  74. package/public/portfolio/sade.html +0 -560
  75. package/public/portfolio/sam.html +0 -654
  76. package/public/portfolio/sechar.html +0 -580
  77. package/public/portfolio/sreya.html +0 -599
  78. package/public/portfolio/swen.html +0 -601
  79. /package/dist/src/{ai-hub → core}/brand-store.js +0 -0
@@ -1,1664 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.createHubEvent = exports.createHubMessage = exports.ScriptedHostRuntime = exports.FakeHostRuntime = exports.CliHostRuntime = void 0;
7
- exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
8
- exports.parseFraimJobLoadSignal = parseFraimJobLoadSignal;
9
- exports.parseUsageSignal = parseUsageSignal;
10
- exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
11
- exports.detectEmployees = detectEmployees;
12
- exports.prepareCodexBrowserHome = prepareCodexBrowserHome;
13
- exports.sharedBrowserHostConfig = sharedBrowserHostConfig;
14
- exports.buildStartPlan = buildStartPlan;
15
- exports.buildContinuePlan = buildContinuePlan;
16
- exports.supportsDirectPath = supportsDirectPath;
17
- exports.buildDirectStartPlan = buildDirectStartPlan;
18
- exports.buildDirectContinuePlan = buildDirectContinuePlan;
19
- exports.parseHostLine = parseHostLine;
20
- exports.findGeminiSessionIdForPrompt = findGeminiSessionIdForPrompt;
21
- const crypto_1 = require("crypto");
22
- const child_process_1 = require("child_process");
23
- const fs_1 = __importDefault(require("fs"));
24
- const os_1 = __importDefault(require("os"));
25
- const path_1 = __importDefault(require("path"));
26
- const manager_turns_1 = require("./manager-turns");
27
- const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
28
- const mcp_config_generator_1 = require("../cli/setup/mcp-config-generator");
29
- const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
30
- // Parse a single line of host stdout looking for a seekMentoring tool-use
31
- // signal. Returns null if the line does not contain one. Supports both
32
- // hosts FRAIM ships against today:
33
- // - Claude Code: tool_use blocks inside message.content with name
34
- // 'mcp__fraim__seekMentoring' (MCP names are prefixed in Claude's
35
- // stream-json output).
36
- // - Codex: top-level events of type 'item.started' / 'item.completed'
37
- // whose item.type === 'mcp_tool_call' and item.tool === 'seekMentoring'
38
- // (Codex separates the MCP server name from the tool name).
39
- function parseSeekMentoringSignal(line) {
40
- if (!line.includes('seekMentoring'))
41
- return null;
42
- let parsed;
43
- try {
44
- parsed = JSON.parse(line);
45
- }
46
- catch {
47
- return null;
48
- }
49
- if (typeof parsed !== 'object' || parsed === null)
50
- return null;
51
- const obj = parsed;
52
- // Codex shape: { type: 'item.started' | 'item.completed', item: { type: 'mcp_tool_call', tool: 'seekMentoring', arguments: {...} } }.
53
- // Match both started + completed; the call carries the same arguments
54
- // either way, so applying the signal at the earliest is fine.
55
- if ((obj.type === 'item.started' || obj.type === 'item.completed') &&
56
- typeof obj.item === 'object' && obj.item !== null) {
57
- const item = obj.item;
58
- const itemType = item.type;
59
- const tool = typeof item.tool === 'string' ? item.tool : null;
60
- if (itemType === 'mcp_tool_call' &&
61
- (tool === 'seekMentoring' || tool === 'mcp__fraim__seekMentoring')) {
62
- const args = item.arguments;
63
- const sig = extractSignalFromArgs(args);
64
- if (sig)
65
- return sig;
66
- }
67
- }
68
- // Claude Code shape: tool_use blocks live inside parsed.message.content.
69
- // The name is MCP-prefixed (e.g. 'mcp__fraim__seekMentoring').
70
- const candidates = [obj];
71
- if (Array.isArray(obj.content))
72
- candidates.push(...obj.content);
73
- if (typeof obj.message === 'object' && obj.message !== null) {
74
- const msg = obj.message;
75
- if (Array.isArray(msg.content))
76
- candidates.push(...msg.content);
77
- }
78
- for (const candidate of candidates) {
79
- if (typeof candidate !== 'object' || candidate === null)
80
- continue;
81
- const c = candidate;
82
- const isToolUse = c.type === 'tool_use' || c.type === 'function_call';
83
- const nameField = typeof c.name === 'string' ? c.name : (typeof c.tool_name === 'string' ? c.tool_name : '');
84
- const isSeekMentoring = nameField === 'seekMentoring' ||
85
- nameField === 'mcp__fraim__seekMentoring' ||
86
- nameField.endsWith('seekMentoring');
87
- if (!isToolUse || !isSeekMentoring)
88
- continue;
89
- const input = (c.input || c.arguments || c.parameters);
90
- const sig = extractSignalFromArgs(input);
91
- if (sig)
92
- return sig;
93
- }
94
- return null;
95
- }
96
- // Issue #710: extract the job name from a get_fraim_job tool call. This
97
- // is structured known-job evidence; the server still validates the job id
98
- // against the catalog before promoting any run.
99
- function parseFraimJobLoadSignal(line) {
100
- if (!line.includes('get_fraim_job'))
101
- return null;
102
- let parsed;
103
- try {
104
- parsed = JSON.parse(line);
105
- }
106
- catch {
107
- return null;
108
- }
109
- if (typeof parsed !== 'object' || parsed === null)
110
- return null;
111
- const obj = parsed;
112
- // Codex shape: { type: 'item.started' | 'item.completed', item:
113
- // { type: 'mcp_tool_call', tool: 'get_fraim_job', arguments: { job } } }.
114
- if ((obj.type === 'item.started' || obj.type === 'item.completed') &&
115
- typeof obj.item === 'object' && obj.item !== null) {
116
- const item = obj.item;
117
- const tool = typeof item.tool === 'string' ? item.tool : '';
118
- if (item.type === 'mcp_tool_call' && isGetFraimJobTool(tool)) {
119
- const sig = readFraimJobFromArgs(item.arguments);
120
- if (sig)
121
- return sig;
122
- }
123
- }
124
- // Claude Code shape: tool_use blocks live inside message.content with
125
- // an MCP-prefixed name such as mcp__fraim__get_fraim_job.
126
- const candidates = [obj];
127
- if (Array.isArray(obj.content))
128
- candidates.push(...obj.content);
129
- if (typeof obj.message === 'object' && obj.message !== null) {
130
- const msg = obj.message;
131
- if (Array.isArray(msg.content))
132
- candidates.push(...msg.content);
133
- }
134
- for (const candidate of candidates) {
135
- if (typeof candidate !== 'object' || candidate === null)
136
- continue;
137
- const c = candidate;
138
- const isToolUse = c.type === 'tool_use' || c.type === 'function_call';
139
- const nameField = typeof c.name === 'string' ? c.name : (typeof c.tool_name === 'string' ? c.tool_name : '');
140
- if (!isToolUse || !isGetFraimJobTool(nameField))
141
- continue;
142
- const sig = readFraimJobFromArgs(c.input || c.arguments || c.parameters);
143
- if (sig)
144
- return sig;
145
- }
146
- return null;
147
- }
148
- // Issue #347 — extract per-turn usage from the host's JSON stream.
149
- // Codex: `{"type":"turn.completed","usage":{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens}}`.
150
- // Claude Code: `{"type":"result", ..., "usage":{input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens}, "total_cost_usd": ...}`.
151
- // Gemini CLI: `{"stats":{"models":{"model-id":{"tokens":{input,prompt,cached,candidates,thoughts,tool}}}}}`.
152
- function parseUsageSignal(line) {
153
- if (!line.includes('usage') && !line.includes('"stats"'))
154
- return null;
155
- let parsed;
156
- try {
157
- parsed = JSON.parse(line);
158
- }
159
- catch {
160
- return null;
161
- }
162
- if (typeof parsed !== 'object' || parsed === null)
163
- return null;
164
- const obj = parsed;
165
- // Codex turn.completed shape. Codex's `input_tokens` is cumulative
166
- // and INCLUDES the cached portion, so normalize by subtracting.
167
- if (obj.type === 'turn.completed' && typeof obj.usage === 'object' && obj.usage !== null) {
168
- const u = obj.usage;
169
- const totalInput = numberOrNull(u.input_tokens);
170
- const cachedInput = numberOrNull(u.cached_input_tokens) ?? 0;
171
- const outputTokens = numberOrNull(u.output_tokens);
172
- if (totalInput === null && outputTokens === null)
173
- return null;
174
- const nonCached = Math.max(0, (totalInput ?? 0) - cachedInput);
175
- return {
176
- nonCachedInputTokens: nonCached,
177
- cachedInputTokens: cachedInput,
178
- cacheCreationTokens: 0, // Codex does not bill cache creation separately
179
- outputTokens: outputTokens ?? 0,
180
- reasoningTokens: numberOrNull(u.reasoning_output_tokens) ?? undefined,
181
- };
182
- }
183
- // Claude Code result shape. Anthropic semantics: input_tokens,
184
- // cache_read_input_tokens, and cache_creation_input_tokens are
185
- // mutually exclusive — pass each through to its own bucket.
186
- if (obj.type === 'result' && typeof obj.usage === 'object' && obj.usage !== null) {
187
- const u = obj.usage;
188
- const inputTokens = numberOrNull(u.input_tokens);
189
- const outputTokens = numberOrNull(u.output_tokens);
190
- if (inputTokens === null && outputTokens === null)
191
- return null;
192
- const costUsd = numberOrNull(obj.total_cost_usd);
193
- return {
194
- nonCachedInputTokens: inputTokens ?? 0,
195
- cachedInputTokens: numberOrNull(u.cache_read_input_tokens) ?? 0,
196
- cacheCreationTokens: numberOrNull(u.cache_creation_input_tokens) ?? 0,
197
- outputTokens: outputTokens ?? 0,
198
- costUsd: costUsd ?? undefined,
199
- };
200
- }
201
- // Gemini CLI JSON output reports per-model stats. Its `input` bucket is
202
- // fresh prompt input, `cached` is prompt-cache reads, and `thoughts` are
203
- // billable output/reasoning tokens.
204
- if (typeof obj.stats === 'object' && obj.stats !== null) {
205
- const stats = obj.stats;
206
- const models = stats.models;
207
- if (models && typeof models === 'object') {
208
- let sawTokens = false;
209
- let nonCachedInputTokens = 0;
210
- let cachedInputTokens = 0;
211
- let outputTokens = 0;
212
- let reasoningTokens = 0;
213
- let costUsd = 0;
214
- let hasCompletePricing = true;
215
- for (const [modelId, modelStats] of Object.entries(models)) {
216
- if (typeof modelStats !== 'object' || modelStats === null)
217
- continue;
218
- const tokens = modelStats.tokens;
219
- if (!tokens || typeof tokens !== 'object')
220
- continue;
221
- const freshInput = numberOrNull(tokens.input) ?? 0;
222
- const cachedInput = numberOrNull(tokens.cached) ?? 0;
223
- const candidates = numberOrNull(tokens.candidates) ?? 0;
224
- const thoughts = numberOrNull(tokens.thoughts) ?? 0;
225
- const toolTokens = numberOrNull(tokens.tool) ?? 0;
226
- const modelOutput = candidates + thoughts + toolTokens;
227
- if (freshInput === 0 && cachedInput === 0 && modelOutput === 0)
228
- continue;
229
- sawTokens = true;
230
- nonCachedInputTokens += freshInput;
231
- cachedInputTokens += cachedInput;
232
- outputTokens += modelOutput;
233
- reasoningTokens += thoughts;
234
- const price = (0, agent_token_prices_1.lookupPrice)('gemini', modelId.toLowerCase());
235
- if (price) {
236
- costUsd +=
237
- (freshInput / 1_000_000) * price.inputPerMTok +
238
- (cachedInput / 1_000_000) * price.cacheReadPerMTok +
239
- (modelOutput / 1_000_000) * price.outputPerMTok;
240
- }
241
- else {
242
- hasCompletePricing = false;
243
- }
244
- }
245
- if (!sawTokens)
246
- return null;
247
- return {
248
- nonCachedInputTokens,
249
- cachedInputTokens,
250
- cacheCreationTokens: 0,
251
- outputTokens,
252
- reasoningTokens: reasoningTokens || undefined,
253
- costUsd: hasCompletePricing ? costUsd : undefined,
254
- };
255
- }
256
- }
257
- return null;
258
- }
259
- // Issue #347 — extract agent identity from the fraim_connect tool call.
260
- // Both hosts pass `agent: { name, model }` in the call's arguments.
261
- function parseAgentIdentitySignal(line) {
262
- if (!line.includes('fraim_connect'))
263
- return null;
264
- let parsed;
265
- try {
266
- parsed = JSON.parse(line);
267
- }
268
- catch {
269
- return null;
270
- }
271
- if (typeof parsed !== 'object' || parsed === null)
272
- return null;
273
- const obj = parsed;
274
- // Codex shape.
275
- if ((obj.type === 'item.started' || obj.type === 'item.completed') && typeof obj.item === 'object' && obj.item !== null) {
276
- const item = obj.item;
277
- if (item.type === 'mcp_tool_call' && item.tool === 'fraim_connect') {
278
- return readAgentFromArgs(item.arguments);
279
- }
280
- }
281
- // Claude Code shape.
282
- const candidates = [obj];
283
- if (typeof obj.message === 'object' && obj.message !== null) {
284
- const msg = obj.message;
285
- if (Array.isArray(msg.content))
286
- candidates.push(...msg.content);
287
- }
288
- for (const candidate of candidates) {
289
- if (typeof candidate !== 'object' || candidate === null)
290
- continue;
291
- const c = candidate;
292
- if (c.type !== 'tool_use' && c.type !== 'function_call')
293
- continue;
294
- const name = typeof c.name === 'string' ? c.name : '';
295
- if (!name.endsWith('fraim_connect'))
296
- continue;
297
- const sig = readAgentFromArgs((c.input || c.arguments));
298
- if (sig)
299
- return sig;
300
- }
301
- return null;
302
- }
303
- function readAgentFromArgs(args) {
304
- if (!args || typeof args.agent !== 'object' || args.agent === null)
305
- return null;
306
- const agent = args.agent;
307
- const agentName = typeof agent.name === 'string' ? agent.name : null;
308
- const agentModel = typeof agent.model === 'string' ? agent.model : null;
309
- if (!agentName || !agentModel)
310
- return null;
311
- return { agentName, agentModel };
312
- }
313
- function isGetFraimJobTool(toolName) {
314
- return toolName === 'get_fraim_job' ||
315
- toolName === 'mcp__fraim__get_fraim_job' ||
316
- toolName.endsWith('get_fraim_job');
317
- }
318
- function readFraimJobFromArgs(rawArgs) {
319
- const args = normalizeToolArgs(rawArgs);
320
- if (!args)
321
- return null;
322
- const rawJob = stringValue(args.job) ||
323
- stringValue(args.jobName) ||
324
- stringValue(args.job_id) ||
325
- stringValue(args.name);
326
- const jobId = rawJob.trim().toLowerCase();
327
- if (!jobId)
328
- return null;
329
- return { jobId, source: 'get_fraim_job' };
330
- }
331
- function normalizeToolArgs(rawArgs) {
332
- if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
333
- return rawArgs;
334
- }
335
- if (typeof rawArgs !== 'string' || rawArgs.trim().length === 0)
336
- return null;
337
- try {
338
- const parsed = JSON.parse(rawArgs);
339
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
340
- return parsed;
341
- }
342
- }
343
- catch {
344
- return null;
345
- }
346
- return null;
347
- }
348
- function numberOrNull(v) {
349
- return typeof v === 'number' && Number.isFinite(v) ? v : null;
350
- }
351
- function extractSignalFromArgs(args) {
352
- if (!args)
353
- return null;
354
- const phaseId = typeof args.currentPhase === 'string' ? args.currentPhase : null;
355
- const rawStatus = typeof args.status === 'string' ? args.status : null;
356
- if (!phaseId || !rawStatus)
357
- return null;
358
- const phaseStatus = ['starting', 'complete', 'incomplete', 'failure'].includes(rawStatus)
359
- ? rawStatus
360
- : 'starting';
361
- const findings = args.findings;
362
- const findingsText = findings && typeof findings.summary === 'string' ? findings.summary : undefined;
363
- const discriminant = typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined;
364
- const jobName = typeof args.jobName === 'string' ? args.jobName : undefined;
365
- const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
366
- const reviewHandoff = extractReviewHandoffFromArgs(args);
367
- const delegationLedger = extractDelegationLedgerFromArgs(args);
368
- return {
369
- phaseId,
370
- phaseStatus,
371
- findingsText,
372
- discriminant,
373
- jobName,
374
- jobId,
375
- ...(reviewHandoff ? { reviewHandoff } : {}),
376
- ...(delegationLedger ? { delegationLedger } : {}),
377
- };
378
- }
379
- function extractDelegationLedgerFromArgs(args) {
380
- const direct = readDelegationLedgerCandidate(args.delegationGraph) ||
381
- readDelegationLedgerCandidate(args.delegationLedger) ||
382
- readDelegationLedgerCandidate(args.delegation_ledger);
383
- if (direct)
384
- return direct;
385
- const evidence = args.evidence;
386
- if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence))
387
- return null;
388
- const e = evidence;
389
- const nested = readDelegationLedgerCandidate(e.delegationGraph) ||
390
- readDelegationLedgerCandidate(e.delegationLedger) ||
391
- readDelegationLedgerCandidate(e.delegation_ledger);
392
- if (nested)
393
- return nested;
394
- const objective = stringValue(e.goal) || stringValue(e.objective) || stringValue(e.parentObjective) || stringValue(args.jobName) || 'Delegated manager objective';
395
- return ledgerFromExecutionPlan(e.execution_plan, objective) || ledgerFromApprovedPlan(e.approved_plan, objective);
396
- }
397
- function readDelegationLedgerCandidate(value) {
398
- let candidate = value;
399
- if (typeof candidate === 'string') {
400
- try {
401
- candidate = JSON.parse(candidate);
402
- }
403
- catch {
404
- return null;
405
- }
406
- }
407
- if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
408
- return null;
409
- const obj = candidate;
410
- const rawJobs = Array.isArray(obj.jobs) ? obj.jobs : Array.isArray(obj.tasks) ? obj.tasks : [];
411
- if (obj.delegationRequired !== true || !rawJobs.length)
412
- return null;
413
- const objective = stringValue(obj.objective) || 'Delegated manager objective';
414
- const tasks = rawJobs.map((task, index) => normalizeDelegationTaskCandidate(task, index)).filter(Boolean);
415
- if (!tasks.length)
416
- return null;
417
- return {
418
- delegationRequired: true,
419
- objective,
420
- orchestratorPersonaKey: stringValue(obj.orchestratorPersonaKey) || 'mandy',
421
- latestSummary: stringValue(obj.latestSummary) || undefined,
422
- tasks,
423
- };
424
- }
425
- function ledgerFromExecutionPlan(value, objective) {
426
- if (!value || typeof value !== 'object' || Array.isArray(value))
427
- return null;
428
- const plan = value;
429
- const tasks = [];
430
- for (const [groupKey, groupValue] of Object.entries(plan)) {
431
- if (!Array.isArray(groupValue))
432
- continue;
433
- for (const rawTask of groupValue) {
434
- const task = normalizeExecutionPlanTask(rawTask, tasks.length, groupKey);
435
- if (task)
436
- tasks.push(task);
437
- }
438
- }
439
- if (!tasks.length)
440
- return null;
441
- return {
442
- delegationRequired: true,
443
- objective,
444
- orchestratorPersonaKey: 'mandy',
445
- latestSummary: stringValue(plan.dependencies) || undefined,
446
- tasks,
447
- };
448
- }
449
- function ledgerFromApprovedPlan(value, objective) {
450
- if (!Array.isArray(value))
451
- return null;
452
- const tasks = value.map((entry, index) => normalizeApprovedPlanEntry(entry, index)).filter(Boolean);
453
- if (!tasks.length)
454
- return null;
455
- return {
456
- delegationRequired: true,
457
- objective,
458
- orchestratorPersonaKey: 'mandy',
459
- tasks,
460
- };
461
- }
462
- function normalizeExecutionPlanTask(value, index, groupKey) {
463
- if (!value || typeof value !== 'object' || Array.isArray(value))
464
- return null;
465
- const obj = value;
466
- const personaKey = normalizePersonaKey(stringValue(obj.persona) || stringValue(obj.personaKey));
467
- const jobId = normalizeJobId(stringValue(obj.job) || stringValue(obj.jobId));
468
- const scope = stringValue(obj.scope) || stringValue(obj.instructions) || stringValue(obj.summary);
469
- const title = stringValue(obj.title) || humanizeJobId(jobId || `workstream-${index + 1}`);
470
- if (!jobId)
471
- return null;
472
- return {
473
- taskId: stringValue(obj.jobKey) || stringValue(obj.taskId) || uniqueTaskId(`${personaKey || 'job'}-${jobId}`, index),
474
- title,
475
- status: 'planned',
476
- personaKey,
477
- jobId,
478
- dependsOn: dependenciesForGroup(groupKey),
479
- reviewJobId: normalizeJobId(stringValue(obj.reviewJobId) || stringValue(obj.review?.jobId || '')),
480
- reviewType: stringValue(obj.reviewType) || stringValue(obj.review?.type || '') || null,
481
- instructions: scope || `Complete ${title} and return a concise deliverable to Mandy for review.`,
482
- };
483
- }
484
- function normalizeApprovedPlanEntry(value, index) {
485
- const text = stringValue(value);
486
- if (!text)
487
- return null;
488
- const match = text.match(/^\s*(?:(\w+)\s*[/]\s*)?([a-z0-9][a-z0-9-]*)\s*:?\s*(.*)$/i);
489
- if (!match)
490
- return null;
491
- const personaKey = normalizePersonaKey(match[1] || '');
492
- const jobId = normalizeJobId(match[2]);
493
- const scope = match[3]?.trim() || '';
494
- if (!jobId)
495
- return null;
496
- const title = scope ? titleFromScope(scope, jobId) : humanizeJobId(jobId);
497
- return {
498
- taskId: uniqueTaskId(`${personaKey || 'job'}-${jobId}`, index),
499
- title,
500
- status: 'planned',
501
- personaKey,
502
- jobId,
503
- dependsOn: [],
504
- instructions: scope || `Complete ${title} and return a concise deliverable to Mandy for review.`,
505
- };
506
- }
507
- function normalizeDelegationTaskCandidate(value, index) {
508
- if (!value || typeof value !== 'object' || Array.isArray(value))
509
- return null;
510
- const obj = value;
511
- const title = stringValue(obj.title) || humanizeJobId(stringValue(obj.jobId) || stringValue(obj.job) || `workstream-${index + 1}`);
512
- const personaKey = normalizePersonaKey(stringValue(obj.personaKey) || stringValue(obj.persona));
513
- const jobId = normalizeJobId(stringValue(obj.jobId) || stringValue(obj.job));
514
- if (!title || !jobId)
515
- return null;
516
- const review = obj.review && typeof obj.review === 'object' && !Array.isArray(obj.review)
517
- ? obj.review
518
- : {};
519
- return {
520
- taskId: stringValue(obj.jobKey) || stringValue(obj.taskId) || uniqueTaskId(`${personaKey || 'job'}-${jobId}`, index),
521
- title,
522
- status: 'planned',
523
- personaKey,
524
- jobId,
525
- reviewJobId: normalizeJobId(stringValue(obj.reviewJobId) || stringValue(review.jobId)),
526
- reviewType: stringValue(obj.reviewType) || stringValue(review.type) || null,
527
- instructions: stringValue(obj.instructions) || stringValue(obj.scope) || undefined,
528
- latestSummary: stringValue(obj.latestSummary) || undefined,
529
- dependsOn: Array.isArray(obj.dependsOn) ? obj.dependsOn.map(stringValue).filter(Boolean) : [],
530
- };
531
- }
532
- function stringValue(value) {
533
- return typeof value === 'string' ? value.trim() : '';
534
- }
535
- function normalizePersonaKey(value) {
536
- const cleaned = value.trim().replace(/^\[|\]$/g, '').toLowerCase();
537
- if (!cleaned)
538
- return null;
539
- const aliases = {
540
- beza: 'beza',
541
- pam: 'pam',
542
- gautam: 'gautam',
543
- swen: 'swen',
544
- qasm: 'qasm',
545
- huxley: 'huxley',
546
- recardo: 'recardo',
547
- hari: 'hari',
548
- mandy: 'mandy',
549
- };
550
- return aliases[cleaned] || cleaned.replace(/[^a-z0-9-]/g, '');
551
- }
552
- function normalizeJobId(value) {
553
- const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '');
554
- return cleaned || null;
555
- }
556
- function uniqueTaskId(base, index) {
557
- const cleaned = normalizeJobId(base) || `task-${index + 1}`;
558
- return `${cleaned}-${index + 1}`;
559
- }
560
- function humanizeJobId(value) {
561
- return value
562
- .split(/[-_\s]+/)
563
- .filter(Boolean)
564
- .map((part) => `${part[0]?.toUpperCase() || ''}${part.slice(1)}`)
565
- .join(' ') || 'Delegated workstream';
566
- }
567
- function titleFromScope(scope, jobId) {
568
- const trimmed = scope.trim();
569
- if (!trimmed)
570
- return humanizeJobId(jobId);
571
- const sentence = trimmed.split(/[.;]/)[0]?.trim();
572
- if (!sentence)
573
- return humanizeJobId(jobId);
574
- return sentence.length <= 80 ? sentence : sentence.slice(0, 77).trimEnd() + '...';
575
- }
576
- function dependenciesForGroup(groupKey) {
577
- const match = groupKey.match(/group[_-\s]*(\d+)/i);
578
- if (!match)
579
- return [];
580
- const group = Number(match[1]);
581
- if (!Number.isFinite(group) || group <= 1)
582
- return [];
583
- return [`group-${group - 1}`];
584
- }
585
- function extractReviewHandoffFromArgs(args) {
586
- const direct = readReviewHandoffCandidate(args.reviewHandoff);
587
- if (direct)
588
- return direct;
589
- const evidence = args.evidence;
590
- if (evidence && typeof evidence === 'object') {
591
- return readReviewHandoffCandidate(evidence.reviewHandoff);
592
- }
593
- return null;
594
- }
595
- function readReviewHandoffCandidate(value) {
596
- let candidate = value;
597
- if (typeof candidate === 'string') {
598
- try {
599
- candidate = JSON.parse(candidate);
600
- }
601
- catch {
602
- return null;
603
- }
604
- }
605
- if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
606
- return null;
607
- const obj = candidate;
608
- if (typeof obj.reviewRequired !== 'boolean')
609
- return null;
610
- const artifacts = Array.isArray(obj.artifacts)
611
- ? obj.artifacts.filter((artifact) => artifact && typeof artifact === 'object')
612
- : [];
613
- return {
614
- reviewRequired: obj.reviewRequired,
615
- reviewTarget: obj.reviewTarget && typeof obj.reviewTarget === 'object'
616
- ? obj.reviewTarget
617
- : null,
618
- artifacts,
619
- ...(typeof obj.summary === 'string' ? { summary: obj.summary } : {}),
620
- ...(typeof obj.feedbackMode === 'string' ? { feedbackMode: obj.feedbackMode } : {}),
621
- };
622
- }
623
- const EMPLOYEE_LABELS = {
624
- codex: 'Codex',
625
- claude: 'Claude Code',
626
- gemini: 'Gemini CLI',
627
- copilot: 'GitHub Copilot CLI',
628
- };
629
- // GitHub Copilot CLI binary name after `npm install -g @github/copilot`.
630
- // The @github/copilot package installs a binary named `copilot` on PATH.
631
- // Note: the package name is @github/copilot (NOT @github/copilot-cli which
632
- // does not exist on npm). The binary is `copilot` (NOT `github-copilot-cli`).
633
- const COPILOT_BINARY = 'copilot';
634
- const executableName = (command) => command;
635
- function quoteWindowsArg(value) {
636
- if (value.length === 0) {
637
- return '""';
638
- }
639
- return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`;
640
- }
641
- function escapeWindowsArg(value) {
642
- return /[\s"]/u.test(value) ? quoteWindowsArg(value) : value;
643
- }
644
- function resolveHostInvocation(plan) {
645
- if (process.platform !== 'win32') {
646
- return plan;
647
- }
648
- const [command, ...args] = [plan.command, ...plan.args];
649
- return {
650
- command: 'cmd.exe',
651
- args: ['/d', '/s', '/c', [command, ...args.map(escapeWindowsArg)].join(' ')],
652
- };
653
- }
654
- const availableByVersionProbe = (command) => {
655
- const invocation = resolveHostInvocation({ command, args: ['--version'] });
656
- const result = (0, child_process_1.spawnSync)(invocation.command, invocation.args, {
657
- encoding: 'utf8',
658
- env: { ...process.env, PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH) },
659
- });
660
- return result.status === 0;
661
- };
662
- // Resolve the binary name for each agent tool.
663
- function agentBinaryName(id) {
664
- if (id === 'copilot')
665
- return COPILOT_BINARY;
666
- return executableName(id);
667
- }
668
- function detectEmployees() {
669
- return Object.keys(EMPLOYEE_LABELS).map((id) => {
670
- const available = availableByVersionProbe(agentBinaryName(id));
671
- return {
672
- id,
673
- label: EMPLOYEE_LABELS[id],
674
- available,
675
- detail: available ? 'Installed and ready on this machine.' : 'CLI not detected on this machine.',
676
- supportsRaw: supportsDirectPath(id),
677
- };
678
- });
679
- }
680
- function parseFraimInvocation(message) {
681
- const trimmed = message.trim();
682
- if (!trimmed)
683
- return null;
684
- const match = trimmed.match(/^[$/@]fraim(?:\s+(\S+))?\s*([\s\S]*)$/);
685
- if (!match)
686
- return null;
687
- return {
688
- jobId: match[1] || null,
689
- remainder: (match[2] || '').trim(),
690
- };
691
- }
692
- // Rewrite UI-facing /fraim or $fraim invocations into direct MCP tool
693
- // instructions for headless hosts. The Hub still shows the familiar
694
- // invocation in the manager timeline, but the actual CLI child process
695
- // receives an explicit instruction that works in non-interactive mode.
696
- //
697
- // Important: continue turns must preserve the named FRAIM job. Manager
698
- // coaching such as `/fraim follow-your-mentor` is not generic advice for
699
- // the active employee job; it is a new FRAIM workflow that must survive
700
- // the headless transform.
701
- function transformHeadlessFraimMessage(message, kind) {
702
- const parsed = parseFraimInvocation(message);
703
- if (!parsed)
704
- return message;
705
- if (!parsed.jobId) {
706
- // Issue #732: single-source the same-job continue wording via
707
- // buildSameJobContinueMessage so the server (prepareContinueMessage) and
708
- // this headless transform stay in lock-step.
709
- if (kind === 'continue') {
710
- return (0, manager_turns_1.buildSameJobContinueMessage)(parsed.remainder);
711
- }
712
- return message;
713
- }
714
- const parts = [
715
- `Call the get_fraim_job MCP tool with job "${parsed.jobId}" to get the full job instructions, then follow them exactly.`,
716
- ];
717
- if (kind === 'continue') {
718
- parts.push('\n\nThis is a manager follow-up inside an existing FRAIM session. Continue by running that named FRAIM job, not by freeforming or by resuming a different FRAIM workflow.');
719
- }
720
- if (parsed.remainder) {
721
- parts.push(`\n\nManager instructions: ${parsed.remainder}`);
722
- }
723
- const storageGuard = machineLevelStorageGuard(parsed.jobId);
724
- if (storageGuard) {
725
- parts.push(`\n\n${storageGuard}`);
726
- }
727
- return parts.join('');
728
- }
729
- function machineLevelStorageGuard(jobId) {
730
- const normalized = jobId.toLowerCase();
731
- const userFraim = path_1.default.join(os_1.default.homedir(), '.fraim');
732
- if (normalized === 'manager-agreements') {
733
- const managerContext = path_1.default.join(userFraim, 'personalized-employee', 'context', 'manager_context.md');
734
- const managerRules = path_1.default.join(userFraim, 'personalized-employee', 'rules', 'manager_rules.md');
735
- return [
736
- 'Storage scope guardrail:',
737
- '- Manager agreements artifacts are machine-level, not repo-level.',
738
- `- Required write targets: ${managerContext} and ${managerRules}.`,
739
- '- 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.',
740
- '- 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.',
741
- '- If the exact machine-level paths cannot be written, fail the phase and report the concrete filesystem error.',
742
- ].join('\n');
743
- }
744
- // organization-onboarding intentionally has no guardrail here: the job's
745
- // submit phase is backend-aware and owns the write-path procedure (git PR /
746
- // cloud publish / machine-level fallback), so duplicating it in runtime
747
- // prompt text would be redundant (issue #563 review).
748
- return null;
749
- }
750
- // If ~/.gemini/settings.json has a wrong/test FRAIM_API_KEY, patch it with the
751
- // real key from ~/.fraim/config.json so the FRAIM MCP server can authenticate.
752
- // This self-heals when a test run accidentally writes a test key to global config.
753
- function ensureGeminiApiKey() {
754
- try {
755
- const home = os_1.default.homedir();
756
- const fraimConfigPath = path_1.default.join(home, '.fraim', 'config.json');
757
- const geminiSettingsPath = path_1.default.join(home, '.gemini', 'settings.json');
758
- if (!fs_1.default.existsSync(fraimConfigPath) || !fs_1.default.existsSync(geminiSettingsPath))
759
- return;
760
- const fraim = JSON.parse(fs_1.default.readFileSync(fraimConfigPath, 'utf8'));
761
- const realKey = fraim.apiKey;
762
- if (!realKey)
763
- return;
764
- const settings = JSON.parse(fs_1.default.readFileSync(geminiSettingsPath, 'utf8'));
765
- const fraimServer = settings?.mcpServers?.fraim;
766
- if (!fraimServer?.env)
767
- return;
768
- if (fraimServer.env.FRAIM_API_KEY !== realKey) {
769
- fraimServer.env.FRAIM_API_KEY = realKey;
770
- fs_1.default.writeFileSync(geminiSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
771
- }
772
- }
773
- catch { /* best-effort: never crash the Hub over a config patch */ }
774
- }
775
- // Build (idempotently) a Hub-managed CODEX_HOME so `codex exec` drives the shared
776
- // browser. config.toml = user's real config + a playwright→cdp override; auth is
777
- // copied; the sessions dir is junctioned to the real one so resume + new sessions
778
- // keep working. The user's real ~/.codex is never modified. Returns the temp home
779
- // path, or null on any failure (caller then leaves Codex on its own browser).
780
- function prepareCodexBrowserHome(cdp, env = process.env) {
781
- try {
782
- const real = env['CODEX_HOME'] || path_1.default.join(os_1.default.homedir(), '.codex');
783
- // Derive the temp-home path from the real home so different real homes (e.g.
784
- // a test's fake home vs the user's ~/.codex) never share — and pollute — one
785
- // temp home (a stale sessions junction breaks resume).
786
- const homeKey = (0, crypto_1.createHash)('sha1').update(real).digest('hex').slice(0, 12);
787
- const home = path_1.default.join(os_1.default.tmpdir(), 'fraim-codex-home-' + homeKey);
788
- fs_1.default.mkdirSync(home, { recursive: true });
789
- // config.toml = real config with the playwright server redirected to the CDP endpoint.
790
- const realConfig = path_1.default.join(real, 'config.toml');
791
- const existing = fs_1.default.existsSync(realConfig) ? fs_1.default.readFileSync(realConfig, 'utf8') : '';
792
- const pwBlock = `[mcp_servers.playwright]\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "${cdp}"]\n`;
793
- const merged = (0, mcp_config_generator_1.mergeTomlMCPServers)(existing, pwBlock, ['playwright']).content;
794
- fs_1.default.writeFileSync(path_1.default.join(home, 'config.toml'), merged, 'utf8');
795
- // Auth + the session index (so resume can find existing rollouts by thread id).
796
- for (const f of ['auth.json', 'session_index.jsonl', 'history.jsonl']) {
797
- const src = path_1.default.join(real, f);
798
- if (fs_1.default.existsSync(src)) {
799
- try {
800
- fs_1.default.copyFileSync(src, path_1.default.join(home, f));
801
- }
802
- catch { /* best effort */ }
803
- }
804
- }
805
- // Sessions: junction temp/sessions -> real/sessions so resume (existing sessions)
806
- // works and new sessions persist alongside the user's. Re-point if the existing
807
- // junction targets the wrong dir.
808
- const realSessions = path_1.default.join(real, 'sessions');
809
- fs_1.default.mkdirSync(realSessions, { recursive: true });
810
- const tmpSessions = path_1.default.join(home, 'sessions');
811
- try {
812
- let ok = false;
813
- if (fs_1.default.existsSync(tmpSessions)) {
814
- const st = fs_1.default.lstatSync(tmpSessions);
815
- if (st.isSymbolicLink()) {
816
- ok = path_1.default.resolve(fs_1.default.readlinkSync(tmpSessions)) === path_1.default.resolve(realSessions);
817
- if (!ok)
818
- fs_1.default.unlinkSync(tmpSessions); // wrong target → drop the junction (not the target)
819
- }
820
- }
821
- if (!ok && !fs_1.default.existsSync(tmpSessions))
822
- fs_1.default.symlinkSync(realSessions, tmpSessions, 'junction');
823
- }
824
- catch { /* junction best-effort; new runs still work without resuming old sessions */ }
825
- return home;
826
- }
827
- catch {
828
- return null;
829
- }
830
- }
831
- function sharedBrowserHostConfig(hostId, env = process.env) {
832
- const cdp = env['FRAIM_BROWSER_CDP_ENDPOINT'];
833
- if (!cdp)
834
- return { args: [] };
835
- const pwArgs = ['-y', '@playwright/mcp@latest', '--cdp-endpoint', cdp];
836
- const mcpServers = { playwright: { command: 'npx', args: pwArgs } };
837
- if (hostId === 'claude') {
838
- // Claude Code's --mcp-config takes a FILE path (inline JSON is rejected by the
839
- // CLI's schema). Write the ephemeral config to a temp file and pass its path —
840
- // per-invocation; never touches the user's persisted ~/.claude.json.
841
- const file = path_1.default.join(os_1.default.tmpdir(), 'fraim-shared-browser-mcp.json');
842
- try {
843
- fs_1.default.writeFileSync(file, JSON.stringify({ mcpServers }), 'utf8');
844
- }
845
- catch {
846
- return { args: [] };
847
- }
848
- return { args: ['--mcp-config', file] };
849
- }
850
- if (hostId === 'codex') {
851
- // Codex `exec` ignores -c overrides of mcp_servers, so we point CODEX_HOME at a
852
- // Hub-managed temp dir whose config.toml merges the user's real config with a
853
- // playwright→cdp override. Auth is copied and the sessions dir is junctioned to
854
- // ~/.codex/sessions, so resume (incl. existing sessions) still works and new
855
- // sessions persist there. The user's real ~/.codex/config.toml is never touched.
856
- const home = prepareCodexBrowserHome(cdp, env);
857
- return home ? { args: [], env: { CODEX_HOME: home } } : { args: [] };
858
- }
859
- if (hostId === 'gemini') {
860
- // Gemini CLI has no per-invocation MCP flag, but it loads a SYSTEM settings
861
- // file from GEMINI_CLI_SYSTEM_SETTINGS_PATH which overrides the same-named
862
- // server. Point it at an ephemeral temp file — per-invocation via env; the
863
- // user's ~/.gemini/settings.json is untouched.
864
- const file = path_1.default.join(os_1.default.tmpdir(), 'fraim-gemini-browser-settings.json');
865
- try {
866
- fs_1.default.writeFileSync(file, JSON.stringify({ mcpServers }), 'utf8');
867
- }
868
- catch {
869
- return { args: [] };
870
- }
871
- return { args: [], env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: file } };
872
- }
873
- if (hostId === 'copilot') {
874
- // GitHub Copilot CLI does not yet publish a documented per-invocation
875
- // settings-file env var analogous to GEMINI_CLI_SYSTEM_SETTINGS_PATH.
876
- // If one is discovered in a future release, write the ephemeral file here
877
- // and return { args: [], env: { <COPILOT_SETTINGS_ENV_VAR>: file } }.
878
- // Until then, return the Option-B no-op per spec R5.2 — the Hub's
879
- // start-payload builder will inject a browser-guidance note instead.
880
- return { args: [] };
881
- }
882
- return { args: [] };
883
- }
884
- function buildStartPlan(hostId, message, sessionId) {
885
- if (hostId === 'codex') {
886
- const browser = sharedBrowserHostConfig('codex');
887
- return {
888
- command: executableName('codex'),
889
- args: ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', ...browser.args],
890
- stdin: transformHeadlessFraimMessage(message, 'start'),
891
- env: browser.env,
892
- };
893
- }
894
- if (hostId === 'gemini') {
895
- ensureGeminiApiKey();
896
- const prompt = transformHeadlessFraimMessage(message, 'start');
897
- const browser = sharedBrowserHostConfig('gemini');
898
- return {
899
- command: executableName('gemini'),
900
- // Gemini CLI creates the durable session id itself. Hub captures
901
- // that real id from Gemini's chat log after start; pre-seeded UUIDs
902
- // are not reliably accepted by `gemini --resume`.
903
- args: ['--yolo', '--skip-trust', '-p', 'stdin', ...browser.args],
904
- stdin: prompt,
905
- env: browser.env,
906
- };
907
- }
908
- if (hostId === 'copilot') {
909
- // GitHub Copilot CLI headless invocation.
910
- // --yolo auto-approves all tool permissions (analogous to
911
- // --dangerously-skip-permissions for Claude Code). The task is provided
912
- // via stdin; -p/--prompt requires inline text which is cumbersome for
913
- // multi-line FRAIM instructions. The session id is self-assigned by the
914
- // binary on first run; Hub captures it from the stream output
915
- // (parseHostLine 'copilot' branch).
916
- const browser = sharedBrowserHostConfig('copilot');
917
- return {
918
- command: COPILOT_BINARY,
919
- args: ['--yolo', ...browser.args],
920
- stdin: transformHeadlessFraimMessage(message, 'start'),
921
- env: browser.env,
922
- };
923
- }
924
- const browser = sharedBrowserHostConfig('claude');
925
- return {
926
- command: executableName('claude'),
927
- args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', ...browser.args],
928
- stdin: transformHeadlessFraimMessage(message, 'start'),
929
- env: browser.env,
930
- };
931
- }
932
- function buildContinuePlan(hostId, sessionId, message) {
933
- if (hostId === 'codex') {
934
- const browser = sharedBrowserHostConfig('codex');
935
- return {
936
- command: executableName('codex'),
937
- args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', sessionId, ...browser.args],
938
- stdin: transformHeadlessFraimMessage(message, 'continue'),
939
- env: browser.env,
940
- };
941
- }
942
- if (hostId === 'gemini') {
943
- ensureGeminiApiKey();
944
- const prompt = transformHeadlessFraimMessage(message, 'continue');
945
- const browser = sharedBrowserHostConfig('gemini');
946
- return {
947
- command: executableName('gemini'),
948
- args: ['--resume', sessionId, '--yolo', '--skip-trust', '-p', 'stdin', ...browser.args],
949
- stdin: prompt,
950
- env: browser.env,
951
- };
952
- }
953
- if (hostId === 'copilot') {
954
- // Resume an existing GitHub Copilot CLI session.
955
- // --resume <sessionId> accepts the session id returned on the first run.
956
- const browser = sharedBrowserHostConfig('copilot');
957
- return {
958
- command: COPILOT_BINARY,
959
- args: ['--yolo', '--resume', sessionId, ...browser.args],
960
- stdin: transformHeadlessFraimMessage(message, 'continue'),
961
- env: browser.env,
962
- };
963
- }
964
- const browser = sharedBrowserHostConfig('claude');
965
- return {
966
- command: executableName('claude'),
967
- args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', '-r', sessionId, ...browser.args],
968
- stdin: transformHeadlessFraimMessage(message, 'continue'),
969
- env: browser.env,
970
- };
971
- }
972
- // Issue #442: all agents support a direct-path invocation (no FRAIM, no
973
- // transformHeadlessFraimMessage). Claude uses --strict-mcp-config for isolation;
974
- // Codex and Gemini simply omit the FRAIM preamble and run raw.
975
- function supportsDirectPath(_hostId) {
976
- return true;
977
- }
978
- // Empty MCP config for Direct runs. Always overwritten on each use so a
979
- // pre-existing file in tmp cannot inject MCP server definitions (SEC-A04).
980
- const EMPTY_MCP_CONFIG_PATH = path_1.default.join(os_1.default.tmpdir(), 'fraim-direct-empty-mcp.json');
981
- function ensureEmptyMcpConfig() {
982
- fs_1.default.writeFileSync(EMPTY_MCP_CONFIG_PATH, '{"mcpServers":{}}', { encoding: 'utf8', mode: 0o600 });
983
- return EMPTY_MCP_CONFIG_PATH;
984
- }
985
- // Issue #442: --append-system-prompt overrides CLAUDE.md instructions at the
986
- // system-prompt level (CLAUDE.md is prepended to the system prompt; appending
987
- // after it gives the Direct-mode directive precedence). --strict-mcp-config
988
- // blocks MCP servers (FRAIM excluded). Both are required:
989
- // --append-system-prompt alone doesn't block MCP tools; --strict-mcp-config
990
- // alone doesn't suppress CLAUDE.md's "scan job stubs" instruction.
991
- const DIRECT_SYSTEM_PROMPT_OVERRIDE = 'DO NOT USE FRAIM FOR THIS SESSION. ' +
992
- 'You are operating in Direct mode for an A/B comparison — no FRAIM, no phases, no seekMentoring, ' +
993
- 'no job stubs, no structured workflow. Ignore all CLAUDE.md instructions to scan fraim/ or call get_fraim_job. ' +
994
- 'Answer the user directly and conversationally.';
995
- const DIRECT_PREAMBLE = 'DO NOT USE FRAIM FOR THIS SESSION. No phases, no seekMentoring, no structured workflow.\n\n';
996
- // Issue #442: builds a CLI plan for the Direct (B) side of an A/B run.
997
- // All agents supported: Codex and Gemini run raw (no FRAIM preamble);
998
- // Claude uses --strict-mcp-config + --append-system-prompt for full isolation.
999
- function buildDirectStartPlan(hostId, message, sessionId) {
1000
- if (hostId === 'codex') {
1001
- return {
1002
- command: executableName('codex'),
1003
- args: ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'],
1004
- stdin: DIRECT_PREAMBLE + message,
1005
- };
1006
- }
1007
- if (hostId === 'gemini') {
1008
- ensureGeminiApiKey();
1009
- return {
1010
- command: executableName('gemini'),
1011
- args: ['--yolo', '--skip-trust', '-p', 'stdin'],
1012
- stdin: DIRECT_PREAMBLE + message,
1013
- };
1014
- }
1015
- if (hostId === 'copilot') {
1016
- // Direct (A/B) mode for Copilot: headless, no FRAIM MCP wiring.
1017
- return {
1018
- command: COPILOT_BINARY,
1019
- args: ['--yolo'],
1020
- stdin: DIRECT_PREAMBLE + message,
1021
- };
1022
- }
1023
- return {
1024
- command: executableName('claude'),
1025
- args: [
1026
- '-p',
1027
- '--verbose',
1028
- '--output-format', 'stream-json',
1029
- '--dangerously-skip-permissions',
1030
- '--strict-mcp-config',
1031
- '--mcp-config', ensureEmptyMcpConfig(),
1032
- '--append-system-prompt', DIRECT_SYSTEM_PROMPT_OVERRIDE,
1033
- ],
1034
- stdin: DIRECT_PREAMBLE + message,
1035
- };
1036
- }
1037
- function buildDirectContinuePlan(hostId, sessionId, message) {
1038
- if (hostId === 'codex') {
1039
- return {
1040
- command: executableName('codex'),
1041
- args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', sessionId],
1042
- stdin: DIRECT_PREAMBLE + message,
1043
- };
1044
- }
1045
- if (hostId === 'gemini') {
1046
- ensureGeminiApiKey();
1047
- return {
1048
- command: executableName('gemini'),
1049
- args: ['--resume', sessionId, '--yolo', '--skip-trust', '-p', 'stdin'],
1050
- stdin: DIRECT_PREAMBLE + message,
1051
- };
1052
- }
1053
- if (hostId === 'copilot') {
1054
- // Direct continue mode for Copilot: resume session, no FRAIM MCP wiring.
1055
- return {
1056
- command: COPILOT_BINARY,
1057
- args: ['--yolo', '--resume', sessionId],
1058
- stdin: DIRECT_PREAMBLE + message,
1059
- };
1060
- }
1061
- return {
1062
- command: executableName('claude'),
1063
- args: [
1064
- '-p',
1065
- '--verbose',
1066
- '--output-format', 'stream-json',
1067
- '--dangerously-skip-permissions',
1068
- '--resume', sessionId,
1069
- '--strict-mcp-config',
1070
- '--mcp-config', ensureEmptyMcpConfig(),
1071
- ],
1072
- stdin: message,
1073
- };
1074
- }
1075
- function parseHostLine(hostId, line) {
1076
- const trimmed = line.trim();
1077
- if (!trimmed)
1078
- return {};
1079
- // Scan every line for structured signals the Hub UI cares about:
1080
- // seekMentoring (tracker), get_fraim_job (job identity promotion),
1081
- // turn-level usage (totals), and fraim_connect agent identity
1082
- // (cost lookup when the host doesn't emit it).
1083
- const seekMentoring = parseSeekMentoringSignal(trimmed);
1084
- const fraimJob = parseFraimJobLoadSignal(trimmed);
1085
- const usage = parseUsageSignal(trimmed);
1086
- const agentIdentity = parseAgentIdentitySignal(trimmed);
1087
- const withSignal = (event) => {
1088
- if (!seekMentoring && !fraimJob && !usage && !agentIdentity)
1089
- return event;
1090
- return {
1091
- ...event,
1092
- ...(seekMentoring ? { seekMentoring } : {}),
1093
- ...(fraimJob ? { fraimJob } : {}),
1094
- ...(usage ? { usage } : {}),
1095
- ...(agentIdentity ? { agentIdentity } : {}),
1096
- };
1097
- };
1098
- if (hostId === 'codex') {
1099
- try {
1100
- const parsed = JSON.parse(trimmed);
1101
- if (parsed.type === 'thread.started' && parsed.thread_id) {
1102
- return withSignal({ sessionId: parsed.thread_id, raw: trimmed });
1103
- }
1104
- if (parsed.type === 'item.completed' && parsed.item?.type === 'agent_message' && parsed.item.text) {
1105
- return withSignal({ message: parsed.item.text, raw: trimmed });
1106
- }
1107
- // turn_context carries the active model — capture it as agentIdentity so
1108
- // direct runs (which never call fraim_connect) can still compute cost.
1109
- if (parsed.type === 'turn_context' && typeof parsed.payload?.model === 'string') {
1110
- return withSignal({ raw: trimmed, agentIdentity: { agentName: 'codex', agentModel: parsed.payload.model } });
1111
- }
1112
- return withSignal({ raw: trimmed });
1113
- }
1114
- catch {
1115
- return withSignal({ raw: trimmed });
1116
- }
1117
- }
1118
- // Gemini CLI output: if the line is valid JSON, apply signal scanning and
1119
- // capture the raw line. Plain-text output (non-JSON) is captured as a raw
1120
- // message so it still surfaces in the Hub timeline.
1121
- if (hostId === 'gemini') {
1122
- try {
1123
- const parsed = JSON.parse(trimmed);
1124
- if (typeof parsed.session_id === 'string' && parsed.session_id.trim().length > 0) {
1125
- return withSignal({ sessionId: parsed.session_id.trim(), raw: trimmed });
1126
- }
1127
- return withSignal({ raw: trimmed });
1128
- }
1129
- catch {
1130
- if (isGeminiCliNotice(trimmed)) {
1131
- return withSignal({ raw: trimmed });
1132
- }
1133
- const message = scrubGeminiAssistantLine(trimmed);
1134
- return withSignal(message ? { message, raw: trimmed } : { raw: trimmed });
1135
- }
1136
- }
1137
- // GitHub Copilot CLI output: JSON stream where each event carries a `type`
1138
- // field. Known event shapes (from the agentic CLI stream):
1139
- // { "type": "session.started", "session_id": "..." } — session id
1140
- // { "type": "message", "role": "assistant", "content": "..." } — reply text
1141
- // { "type": "turn.completed", "usage": { ... } } — token usage (same shape as Codex)
1142
- // For any JSON event not matching the above, signal scanning (seekMentoring,
1143
- // agent identity) still runs because withSignal is applied to every parsed result.
1144
- // Non-JSON lines from Copilot are treated as plain-text employee messages.
1145
- if (hostId === 'copilot') {
1146
- try {
1147
- const parsed = JSON.parse(trimmed);
1148
- if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
1149
- return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1150
- }
1151
- if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
1152
- return withSignal({ message: parsed.content, raw: trimmed });
1153
- }
1154
- // All other JSON events: apply signal scanning and surface as raw.
1155
- return withSignal({ raw: trimmed });
1156
- }
1157
- catch {
1158
- // Non-JSON line from Copilot: treat as a plain-text employee message,
1159
- // same pattern as Gemini CLI's non-JSON output.
1160
- return withSignal({ message: trimmed, raw: trimmed });
1161
- }
1162
- }
1163
- try {
1164
- const parsed = JSON.parse(trimmed);
1165
- if (parsed.type === 'system' && parsed.session_id) {
1166
- return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1167
- }
1168
- if (parsed.type === 'assistant') {
1169
- const text = parsed.message?.content?.find((entry) => entry.type === 'text')?.text;
1170
- if (text) {
1171
- return withSignal({ message: text, sessionId: parsed.session_id, raw: trimmed });
1172
- }
1173
- }
1174
- if (parsed.type === 'result') {
1175
- // Don't emit message — the 'assistant' event already captured the turn text.
1176
- // result carries usage data (parsed by parseUsageSignal above via withSignal).
1177
- return withSignal({ sessionId: parsed.session_id, raw: trimmed });
1178
- }
1179
- return withSignal({ raw: trimmed });
1180
- }
1181
- catch {
1182
- return withSignal({ raw: trimmed });
1183
- }
1184
- }
1185
- function isGeminiCliNotice(line) {
1186
- return line === 'YOLO mode is enabled. All tool calls will be automatically approved.' ||
1187
- line === 'Ripgrep is not available. Falling back to GrepTool.';
1188
- }
1189
- function scrubGeminiAssistantLine(line) {
1190
- const scrubbed = line.replace(/\[Thought:\s*true\]\s*/gi, '').trim();
1191
- return scrubbed.length > 0 ? scrubbed : null;
1192
- }
1193
- function wireHostProcess(hostId, child, handlers) {
1194
- const wire = (buffer, channel) => {
1195
- let pending = '';
1196
- buffer.on('data', (chunk) => {
1197
- pending += chunk.toString();
1198
- const lines = pending.split(/\r?\n/);
1199
- pending = lines.pop() || '';
1200
- for (const line of lines) {
1201
- const parsed = parseHostLine(hostId, line);
1202
- if (parsed.message || parsed.sessionId || parsed.raw) {
1203
- handlers.onEvent(parsed, channel);
1204
- }
1205
- }
1206
- });
1207
- buffer.on('end', () => {
1208
- if (pending.trim().length > 0) {
1209
- const parsed = parseHostLine(hostId, pending);
1210
- if (parsed.message || parsed.sessionId || parsed.raw) {
1211
- handlers.onEvent(parsed, channel);
1212
- }
1213
- }
1214
- });
1215
- };
1216
- wire(child.stdout, 'stdout');
1217
- wire(child.stderr, 'stderr');
1218
- child.on('close', (code) => handlers.onExit(code));
1219
- return child;
1220
- }
1221
- function spawnHostProcess(hostId, plan, projectPath, handlers) {
1222
- const invocation = resolveHostInvocation(plan);
1223
- const startedAtMs = Date.now();
1224
- const child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
1225
- cwd: projectPath,
1226
- stdio: ['pipe', 'pipe', 'pipe'],
1227
- env: plan.env ? { ...process.env, ...plan.env } : process.env,
1228
- });
1229
- if (typeof plan.stdin === 'string') {
1230
- child.stdin.write(plan.stdin);
1231
- }
1232
- child.stdin.end();
1233
- if (typeof plan.stdin === 'string' && !plan.args.includes('--resume')) {
1234
- child.once('close', () => {
1235
- const sessionId = discoverSessionIdAfterStart(hostId, projectPath, plan.stdin || '', startedAtMs);
1236
- if (sessionId) {
1237
- handlers.onEvent({ sessionId, raw: `${hostId}-session:${sessionId}` }, 'system');
1238
- }
1239
- });
1240
- }
1241
- return wireHostProcess(hostId, child, handlers);
1242
- }
1243
- function discoverSessionIdAfterStart(hostId, projectPath, prompt, startedAtMs) {
1244
- if (hostId !== 'gemini')
1245
- return null;
1246
- return findGeminiSessionIdForPrompt(projectPath, prompt, startedAtMs);
1247
- }
1248
- function findGeminiSessionIdForPrompt(_projectPath, prompt, startedAtMs) {
1249
- const promptNeedle = normalizeGeminiPromptForMatch(prompt).slice(0, 160);
1250
- const records = readGeminiSessionRecords();
1251
- const recent = records
1252
- .filter((record) => record.updatedAtMs >= startedAtMs - 10_000)
1253
- .filter((record) => {
1254
- if (!promptNeedle)
1255
- return true;
1256
- const userText = normalizeGeminiPromptForMatch(record.userText);
1257
- return userText.includes(promptNeedle) || promptNeedle.includes(userText.slice(0, 80));
1258
- })
1259
- .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
1260
- if (recent[0])
1261
- return recent[0].sessionId;
1262
- const fallback = records
1263
- .filter((record) => record.updatedAtMs >= startedAtMs - 10_000)
1264
- .sort((a, b) => b.updatedAtMs - a.updatedAtMs)[0];
1265
- return fallback?.sessionId || null;
1266
- }
1267
- function readGeminiSessionRecords() {
1268
- const root = path_1.default.join(os_1.default.homedir(), '.gemini', 'tmp');
1269
- if (!fs_1.default.existsSync(root))
1270
- return [];
1271
- const records = [];
1272
- for (const filePath of collectGeminiSessionFiles(root)) {
1273
- try {
1274
- const stat = fs_1.default.statSync(filePath);
1275
- const lines = fs_1.default.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
1276
- const metadata = JSON.parse(lines[0] || '{}');
1277
- const sessionId = typeof metadata.sessionId === 'string' ? metadata.sessionId.trim() : '';
1278
- if (!sessionId)
1279
- continue;
1280
- const userText = lines
1281
- .slice(1, 8)
1282
- .map((line) => {
1283
- try {
1284
- const entry = JSON.parse(line);
1285
- if (entry.type !== 'user' || !Array.isArray(entry.content))
1286
- return '';
1287
- return entry.content.map((part) => typeof part.text === 'string' ? part.text : '').join('\n');
1288
- }
1289
- catch {
1290
- return '';
1291
- }
1292
- })
1293
- .filter(Boolean)
1294
- .join('\n');
1295
- records.push({ sessionId, filePath, updatedAtMs: stat.mtimeMs, userText });
1296
- }
1297
- catch {
1298
- // Ignore malformed or concurrently-written session files.
1299
- }
1300
- }
1301
- return records;
1302
- }
1303
- function collectGeminiSessionFiles(root) {
1304
- const files = [];
1305
- const visit = (dir) => {
1306
- let entries;
1307
- try {
1308
- entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
1309
- }
1310
- catch {
1311
- return;
1312
- }
1313
- for (const entry of entries) {
1314
- const fullPath = path_1.default.join(dir, entry.name);
1315
- if (entry.isDirectory()) {
1316
- visit(fullPath);
1317
- }
1318
- else if (/^session-.*\.jsonl$/i.test(entry.name)) {
1319
- files.push(fullPath);
1320
- }
1321
- }
1322
- };
1323
- visit(root);
1324
- return files;
1325
- }
1326
- function normalizeGeminiPromptForMatch(value) {
1327
- return String(value || '').replace(/\s+/g, ' ').trim();
1328
- }
1329
- class CliHostRuntime {
1330
- detectEmployees() {
1331
- return detectEmployees();
1332
- }
1333
- startRun(hostId, projectPath, message, handlers, sessionId) {
1334
- return spawnHostProcess(hostId, buildStartPlan(hostId, message, sessionId), projectPath, handlers);
1335
- }
1336
- continueRun(hostId, projectPath, sessionId, message, handlers) {
1337
- return spawnHostProcess(hostId, buildContinuePlan(hostId, sessionId, message), projectPath, handlers);
1338
- }
1339
- startDirectRun(hostId, message, projectPath, handlers, sessionId) {
1340
- return spawnHostProcess(hostId, buildDirectStartPlan(hostId, message, sessionId), projectPath, handlers);
1341
- }
1342
- continueDirectRun(hostId, sessionId, message, projectPath, handlers) {
1343
- return spawnHostProcess(hostId, buildDirectContinuePlan(hostId, sessionId, message), projectPath, handlers);
1344
- }
1345
- }
1346
- exports.CliHostRuntime = CliHostRuntime;
1347
- class FakeHostRuntime {
1348
- constructor() {
1349
- this.employees = [
1350
- { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
1351
- { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
1352
- { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
1353
- { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true },
1354
- ];
1355
- // Remembered across turns like a resumed agent session: the job label from the
1356
- // start turn. Issue #732 — a same-job continue no longer carries a /fraim <job>
1357
- // invocation, so the fake falls back to this to stay job-aware on continue.
1358
- this.lastJobLabel = null;
1359
- }
1360
- detectEmployees() {
1361
- return this.employees;
1362
- }
1363
- startRun(hostId, _projectPath, message, handlers, _sessionId) {
1364
- this.lastStartMessage = message;
1365
- return this.fakeProcess(hostId, this.fakeEmployeeReply('start', message), handlers);
1366
- }
1367
- continueRun(hostId, _projectPath, sessionId, message, handlers) {
1368
- this.lastContinueMessage = message;
1369
- return this.fakeProcess(hostId, this.fakeEmployeeReply('continue', message), handlers);
1370
- }
1371
- startDirectRun(hostId, _message, _projectPath, handlers, _sessionId) {
1372
- return this.fakeProcess(hostId, 'Understood. Working directly on that now.', handlers);
1373
- }
1374
- continueDirectRun(hostId, _sessionId, _message, _projectPath, handlers) {
1375
- return this.fakeProcess(hostId, 'Got it. Continuing directly.', handlers);
1376
- }
1377
- fakeProcess(_hostId, text, handlers) {
1378
- handlers.onEvent({ sessionId: (0, crypto_1.randomUUID)() }, 'system');
1379
- handlers.onEvent({ message: text }, 'stdout');
1380
- setTimeout(() => handlers.onExit(0), 25);
1381
- return {
1382
- stdout: process.stdout,
1383
- stderr: process.stderr,
1384
- stdin: process.stdin,
1385
- kill: () => true,
1386
- on: () => ({}),
1387
- once: () => ({}),
1388
- emit: () => true,
1389
- addListener: () => ({}),
1390
- removeListener: () => ({}),
1391
- removeAllListeners: () => ({}),
1392
- setMaxListeners: () => ({}),
1393
- getMaxListeners: () => 0,
1394
- listeners: () => [],
1395
- rawListeners: () => [],
1396
- listenerCount: () => 0,
1397
- prependListener: () => ({}),
1398
- prependOnceListener: () => ({}),
1399
- eventNames: () => [],
1400
- pid: 0,
1401
- connected: false,
1402
- disconnect: () => undefined,
1403
- exitCode: 0,
1404
- killed: false,
1405
- signalCode: null,
1406
- spawnargs: [],
1407
- spawnfile: '',
1408
- stdio: [],
1409
- unref: () => undefined,
1410
- ref: () => undefined,
1411
- };
1412
- }
1413
- fakeEmployeeReply(kind, message) {
1414
- const parsed = parseFraimInvocation(message);
1415
- const parsedLabel = parsed?.jobId
1416
- ? parsed.jobId.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')
1417
- : null;
1418
- if (parsedLabel)
1419
- this.lastJobLabel = parsedLabel;
1420
- const label = parsedLabel || (kind === 'continue' ? this.lastJobLabel : null);
1421
- if (kind === 'continue') {
1422
- return label
1423
- ? `Understood. I'm taking another pass with ${label}.`
1424
- : 'Understood. I am incorporating your coaching now.';
1425
- }
1426
- return label
1427
- ? `Understood. I'm starting ${label} now.`
1428
- : 'Understood. I am working on that now.';
1429
- }
1430
- }
1431
- exports.FakeHostRuntime = FakeHostRuntime;
1432
- // Issue #347 — test-only host that lets a test inject seekMentoring
1433
- // signals on a runId-by-runId basis. The Hub server treats it like any
1434
- // HostRuntime; the test holds a reference and calls emitPhase() to
1435
- // drive deterministic phase transitions. Outside tests, prefer
1436
- // FakeHostRuntime (smaller surface, no seekMentoring).
1437
- class ScriptedHostRuntime {
1438
- constructor() {
1439
- this.employees = [
1440
- { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
1441
- { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
1442
- { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1443
- { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1444
- ];
1445
- // Track each active run so the test can emit signals at it. The Hub
1446
- // passes the run id as the requested start session id in test/demo paths;
1447
- // real resume flows still use the host session id. Resolving by the
1448
- // supplied id first lets concurrent scripted runs receive targeted events.
1449
- this.handlersBySession = new Map();
1450
- // Tracks runDiscriminant per sessionId so consecutive emitPhase calls
1451
- // can resolve onSuccess routing without each test having to repeat it.
1452
- this.discriminantBySession = new Map();
1453
- // Issue #732: capture the exact message the Hub sent on the most recent
1454
- // continue turn so tests can assert whether it would re-trigger get_fraim_job.
1455
- this.lastContinueMessage = null;
1456
- }
1457
- detectEmployees() {
1458
- return this.employees;
1459
- }
1460
- startRun(_hostId, _projectPath, _message, handlers, requestedSessionId) {
1461
- const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
1462
- this.handlersBySession.set(sessionId, handlers);
1463
- handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
1464
- return this.spawnDouble();
1465
- }
1466
- continueRun(_hostId, _projectPath, sessionId, message, handlers) {
1467
- this.lastContinueMessage = message;
1468
- this.handlersBySession.set(sessionId, handlers);
1469
- handlers.onEvent({ sessionId, raw: 'scripted-session-resume' }, 'system');
1470
- return this.spawnDouble();
1471
- }
1472
- startDirectRun(_hostId, _message, _projectPath, handlers, requestedSessionId) {
1473
- const sessionId = requestedSessionId || (0, crypto_1.randomUUID)();
1474
- this.handlersBySession.set(sessionId, handlers);
1475
- handlers.onEvent({ sessionId, raw: 'scripted-direct-session-start' }, 'system');
1476
- return this.spawnDouble();
1477
- }
1478
- continueDirectRun(_hostId, sessionId, _message, _projectPath, handlers) {
1479
- this.handlersBySession.set(sessionId, handlers);
1480
- handlers.onEvent({ sessionId, raw: 'scripted-direct-session-resume' }, 'system');
1481
- return this.spawnDouble();
1482
- }
1483
- // Test API — fire a seekMentoring tool-use event for the most recent
1484
- // active run. The phaseId is the raw FSM phase id (e.g.,
1485
- // 'implement-validate'); the parser will turn it into a friendly
1486
- // tracker label downstream.
1487
- emitPhase(runId, phaseId, status, findingsText) {
1488
- const target = this.resolveSession(runId);
1489
- if (!target)
1490
- return;
1491
- target.handlers.onEvent({
1492
- sessionId: target.sessionId,
1493
- raw: `scripted-seekMentoring:${phaseId}:${status}`,
1494
- seekMentoring: { phaseId, phaseStatus: status, findingsText },
1495
- }, 'stdout');
1496
- }
1497
- // Test API — set the runDiscriminant for a run so onSuccess routing
1498
- // resolves correctly (e.g., 'feature' vs 'bug' for feature-implementation).
1499
- emitDiscriminant(runId, discriminant) {
1500
- const target = this.resolveSession(runId);
1501
- if (!target)
1502
- return;
1503
- this.discriminantBySession.set(target.sessionId, discriminant);
1504
- target.handlers.onEvent({
1505
- sessionId: target.sessionId,
1506
- raw: `scripted-discriminant:${discriminant}`,
1507
- seekMentoring: {
1508
- phaseId: '__discriminant__',
1509
- phaseStatus: 'starting',
1510
- discriminant,
1511
- },
1512
- }, 'system');
1513
- }
1514
- // Test API — emit a seekMentoring signal for a job *other* than the
1515
- // one this run is tracking. Used to verify the cross-job pollution
1516
- // filter in applySeekMentoringSignal.
1517
- emitForeignPhase(runId, foreignJobId, phaseId, status) {
1518
- const target = this.resolveSession(runId);
1519
- if (!target)
1520
- return;
1521
- target.handlers.onEvent({
1522
- sessionId: target.sessionId,
1523
- raw: `scripted-foreign-seekMentoring:${foreignJobId}:${phaseId}`,
1524
- seekMentoring: {
1525
- phaseId,
1526
- phaseStatus: status,
1527
- jobId: foreignJobId,
1528
- jobName: foreignJobId,
1529
- },
1530
- }, 'stdout');
1531
- }
1532
- // Test API — emit a same-job seekMentoring signal that carries BOTH the
1533
- // stable jobName slug and a per-invocation UUID jobId, exactly as a real
1534
- // agent does after get_fraim_job (which mints a fresh randomUUID each call).
1535
- // Issue #732: the tracker must key off jobName, not the UUID jobId.
1536
- emitPhaseWithJobIds(runId, phaseId, status, jobName, jobId, findingsText) {
1537
- const target = this.resolveSession(runId);
1538
- if (!target)
1539
- return;
1540
- target.handlers.onEvent({
1541
- sessionId: target.sessionId,
1542
- raw: `scripted-seekMentoring-ids:${phaseId}:${status}`,
1543
- seekMentoring: { phaseId, phaseStatus: status, jobName, jobId, findingsText },
1544
- }, 'stdout');
1545
- }
1546
- // Test API — emit the structured job-load signal that get_fraim_job
1547
- // produces before the first seekMentoring phase call.
1548
- emitFraimJobLoad(runId, jobId) {
1549
- const target = this.resolveSession(runId);
1550
- if (!target)
1551
- return;
1552
- target.handlers.onEvent({
1553
- sessionId: target.sessionId,
1554
- raw: `scripted-get_fraim_job:${jobId}`,
1555
- fraimJob: { jobId, source: 'get_fraim_job' },
1556
- }, 'stdout');
1557
- }
1558
- // Test API — emit a per-turn usage signal in the normalized shape
1559
- // the server expects. Mirrors what parseUsageSignal would produce
1560
- // from a real host's stream.
1561
- emitUsage(runId, usage) {
1562
- const target = this.resolveSession(runId);
1563
- if (!target)
1564
- return;
1565
- target.handlers.onEvent({
1566
- sessionId: target.sessionId,
1567
- raw: `scripted-usage:${JSON.stringify(usage)}`,
1568
- usage: {
1569
- nonCachedInputTokens: usage.nonCachedInputTokens ?? 0,
1570
- cachedInputTokens: usage.cachedInputTokens ?? 0,
1571
- cacheCreationTokens: usage.cacheCreationTokens ?? 0,
1572
- outputTokens: usage.outputTokens ?? 0,
1573
- costUsd: usage.costUsd,
1574
- },
1575
- }, 'stdout');
1576
- }
1577
- // Test API — emit the agent-identity signal that fraim_connect would
1578
- // produce, so applyUsageSignal's price-table lookup has identity to
1579
- // work with.
1580
- emitAgentIdentity(runId, agentName, agentModel) {
1581
- const target = this.resolveSession(runId);
1582
- if (!target)
1583
- return;
1584
- target.handlers.onEvent({
1585
- sessionId: target.sessionId,
1586
- raw: `scripted-agent-identity:${agentName}:${agentModel}`,
1587
- agentIdentity: { agentName, agentModel },
1588
- }, 'stdout');
1589
- }
1590
- // Test API — emit a message from the employee (appears in the thread as an employee bubble).
1591
- emitEmployeeMessage(runId, text) {
1592
- const target = this.resolveSession(runId);
1593
- if (!target)
1594
- return;
1595
- target.handlers.onEvent({ sessionId: target.sessionId, message: text, raw: `scripted-employee:${text.slice(0, 40)}` }, 'stdout');
1596
- }
1597
- // Reset between tests (called from beforeEach).
1598
- reset() {
1599
- this.handlersBySession.clear();
1600
- this.discriminantBySession.clear();
1601
- }
1602
- // The Hub passes run.id as the requested session id for scripted starts,
1603
- // so tests/demo controls can target a specific run even when multiple runs
1604
- // are active. Fall back to the latest handler for older callers that do not
1605
- // provide a known id.
1606
- resolveSession(runId) {
1607
- if (this.handlersBySession.size === 0)
1608
- return null;
1609
- const direct = this.handlersBySession.get(runId);
1610
- if (direct)
1611
- return { sessionId: runId, handlers: direct };
1612
- const entries = [...this.handlersBySession.entries()];
1613
- const [sessionId, handlers] = entries[entries.length - 1];
1614
- return { sessionId, handlers };
1615
- }
1616
- spawnDouble() {
1617
- return {
1618
- stdout: process.stdout,
1619
- stderr: process.stderr,
1620
- stdin: process.stdin,
1621
- kill: () => true,
1622
- on: () => ({}),
1623
- once: () => ({}),
1624
- emit: () => true,
1625
- addListener: () => ({}),
1626
- removeListener: () => ({}),
1627
- removeAllListeners: () => ({}),
1628
- setMaxListeners: () => ({}),
1629
- getMaxListeners: () => 0,
1630
- listeners: () => [],
1631
- rawListeners: () => [],
1632
- listenerCount: () => 0,
1633
- prependListener: () => ({}),
1634
- prependOnceListener: () => ({}),
1635
- eventNames: () => [],
1636
- pid: 0,
1637
- connected: false,
1638
- disconnect: () => undefined,
1639
- exitCode: 0,
1640
- killed: false,
1641
- signalCode: null,
1642
- spawnargs: [],
1643
- spawnfile: '',
1644
- stdio: [],
1645
- unref: () => undefined,
1646
- ref: () => undefined,
1647
- };
1648
- }
1649
- }
1650
- exports.ScriptedHostRuntime = ScriptedHostRuntime;
1651
- const createHubMessage = (role, text) => ({
1652
- id: (0, crypto_1.randomUUID)(),
1653
- role,
1654
- text,
1655
- createdAt: new Date().toISOString(),
1656
- });
1657
- exports.createHubMessage = createHubMessage;
1658
- const createHubEvent = (channel, text) => ({
1659
- id: (0, crypto_1.randomUUID)(),
1660
- channel,
1661
- text,
1662
- createdAt: new Date().toISOString(),
1663
- });
1664
- exports.createHubEvent = createHubEvent;