fraim 2.0.202 → 2.0.204
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/README.md +3 -3
- package/bin/fraim.js +8 -18
- package/dist/src/cli/commands/first-run.js +2 -37
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +1 -1
- package/dist/src/cli/fraim.js +0 -2
- package/dist/src/cli/setup/ide-invocation-surfaces.js +6 -6
- package/dist/src/cli/utils/managed-agent-paths.js +76 -6
- package/dist/src/cli/utils/version-utils.js +2 -2
- package/dist/src/config/persona-capability-bundles.js +3 -3
- package/dist/src/core/job-phases.js +168 -0
- package/dist/src/core/utils/ports.js +32 -0
- package/dist/src/core/utils/stub-generator.js +24 -2
- package/dist/src/first-run/session-service.js +106 -24
- package/dist/src/local-mcp-server/learning-context-builder.js +1 -1
- package/dist/src/local-mcp-server/stdio-server.js +41 -9
- package/dist/src/routes/analytics.js +2 -2
- package/dist/src/services/registry-service.js +2 -2
- package/index.js +1 -1
- package/package.json +61 -181
- package/dist/src/ai-hub/catalog.js +0 -406
- package/dist/src/ai-hub/cert-store.js +0 -70
- package/dist/src/ai-hub/conversation-store.js +0 -359
- package/dist/src/ai-hub/desktop-main.js +0 -449
- package/dist/src/ai-hub/hosts.js +0 -1664
- package/dist/src/ai-hub/hub-latest-version.js +0 -52
- package/dist/src/ai-hub/hub-launch-decision.js +0 -57
- package/dist/src/ai-hub/hub-runtime-file.js +0 -43
- package/dist/src/ai-hub/managed-browser.js +0 -269
- package/dist/src/ai-hub/manager-turns.js +0 -64
- package/dist/src/ai-hub/office-sideload.js +0 -156
- package/dist/src/ai-hub/openclaw-bridge.js +0 -250
- package/dist/src/ai-hub/preferences.js +0 -201
- package/dist/src/ai-hub/remote-hub-gateway.js +0 -88
- package/dist/src/ai-hub/server.js +0 -3827
- package/dist/src/ai-hub/types.js +0 -2
- package/dist/src/ai-hub/word-sideload.js +0 -95
- package/dist/src/cli/commands/hub.js +0 -238
- package/dist/src/cli/commands/test-mcp.js +0 -171
- package/dist/src/cli/setup/first-run.js +0 -242
- package/dist/src/core/config-writer.js +0 -75
- package/dist/src/core/utils/job-aliases.js +0 -47
- package/dist/src/core/utils/workflow-parser.js +0 -174
- package/dist/src/services/email-service-clean.js +0 -782
- package/extensions/office-word/favicon.ico +0 -0
- package/extensions/office-word/icon-64.png +0 -0
- package/extensions/office-word/manifest.xml +0 -33
- package/extensions/office-word/taskpane.html +0 -242
- package/public/ai-hub/index.html +0 -894
- package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +0 -236
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +0 -30
- package/public/ai-hub/review.css +0 -381
- package/public/ai-hub/script.js +0 -11024
- package/public/ai-hub/styles.css +0 -4873
- package/public/portfolio/ashley.html +0 -523
- package/public/portfolio/auditya.html +0 -83
- package/public/portfolio/banke.html +0 -83
- package/public/portfolio/beza.html +0 -659
- package/public/portfolio/careena.html +0 -632
- package/public/portfolio/casey.html +0 -568
- package/public/portfolio/celia.html +0 -490
- package/public/portfolio/deidre.html +0 -642
- package/public/portfolio/gautam.html +0 -597
- package/public/portfolio/hari.html +0 -469
- package/public/portfolio/huxley.html +0 -1354
- package/public/portfolio/index.html +0 -741
- package/public/portfolio/maestro.html +0 -518
- package/public/portfolio/mandy.html +0 -590
- package/public/portfolio/mona.html +0 -597
- package/public/portfolio/pam.html +0 -887
- package/public/portfolio/procella.html +0 -107
- package/public/portfolio/qasm.html +0 -569
- package/public/portfolio/ricardo.html +0 -489
- package/public/portfolio/sade.html +0 -560
- package/public/portfolio/sam.html +0 -654
- package/public/portfolio/sechar.html +0 -580
- package/public/portfolio/sreya.html +0 -599
- package/public/portfolio/swen.html +0 -601
- /package/dist/src/{ai-hub → core}/brand-store.js +0 -0
package/dist/src/ai-hub/hosts.js
DELETED
|
@@ -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;
|