fraim 2.0.154 → 2.0.160

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 (45) hide show
  1. package/README.md +1 -1
  2. package/dist/src/ai-hub/cert-store.js +70 -0
  3. package/dist/src/ai-hub/desktop-main.js +225 -50
  4. package/dist/src/ai-hub/hosts.js +135 -8
  5. package/dist/src/ai-hub/manager-turns.js +38 -0
  6. package/dist/src/ai-hub/office-sideload.js +138 -0
  7. package/dist/src/ai-hub/openclaw-bridge.js +239 -0
  8. package/dist/src/ai-hub/server.js +479 -48
  9. package/dist/src/ai-hub/word-sideload.js +95 -0
  10. package/dist/src/cli/commands/add-ide.js +9 -0
  11. package/dist/src/cli/commands/init-project.js +46 -34
  12. package/dist/src/cli/commands/login.js +1 -2
  13. package/dist/src/cli/commands/setup.js +0 -2
  14. package/dist/src/cli/commands/sync.js +41 -11
  15. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
  16. package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
  17. package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
  18. package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
  19. package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
  20. package/dist/src/cli/setup/ide-invocation-surfaces.js +2 -2
  21. package/dist/src/cli/utils/fraim-gitignore.js +11 -0
  22. package/dist/src/cli/utils/github-workflow-sync.js +231 -0
  23. package/dist/src/cli/utils/managed-agent-paths.js +1 -1
  24. package/dist/src/cli/utils/project-bootstrap.js +6 -3
  25. package/dist/src/cli/utils/remote-sync.js +1 -1
  26. package/dist/src/core/ai-mentor.js +46 -37
  27. package/dist/src/core/config-loader.js +69 -2
  28. package/dist/src/core/fraim-config-schema.generated.js +267 -6
  29. package/dist/src/core/types.js +0 -1
  30. package/dist/src/core/utils/fraim-labels.js +182 -0
  31. package/dist/src/core/utils/git-utils.js +22 -1
  32. package/dist/src/core/utils/project-fraim-paths.js +58 -0
  33. package/dist/src/first-run/session-service.js +3 -3
  34. package/dist/src/first-run/types.js +1 -1
  35. package/dist/src/local-mcp-server/learning-context-builder.js +77 -52
  36. package/dist/src/local-mcp-server/stdio-server.js +212 -13
  37. package/package.json +6 -2
  38. package/public/ai-hub/index.html +289 -229
  39. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  40. package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
  41. package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
  42. package/public/ai-hub/script.js +1155 -586
  43. package/public/ai-hub/styles.css +1226 -722
  44. package/public/first-run/index.html +35 -35
  45. package/public/first-run/script.js +667 -667
package/README.md CHANGED
@@ -231,7 +231,7 @@ R - Retrospectives: Continuous learning from experience
231
231
 
232
232
  **Recommended: Use npx (no installation needed)**
233
233
  ```bash
234
- npx fraim-framework@latest setup --key=<your-fraim-key>
234
+ npx fraim@latest setup --key=<your-fraim-key>
235
235
 
236
236
  # Optional: Create alias for convenience
237
237
  echo 'alias fraim="npx fraim-framework"' >> ~/.bashrc
@@ -0,0 +1,70 @@
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.loadOrCreateCert = loadOrCreateCert;
7
+ exports.trustCert = trustCert;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const os_1 = __importDefault(require("os"));
11
+ const child_process_1 = require("child_process");
12
+ function certDir() {
13
+ const base = process.env.APPDATA ||
14
+ (process.platform === 'darwin'
15
+ ? path_1.default.join(os_1.default.homedir(), 'Library', 'Application Support')
16
+ : path_1.default.join(os_1.default.homedir(), '.config'));
17
+ return path_1.default.join(base, 'FRAIM', 'certs');
18
+ }
19
+ function certPaths() {
20
+ const dir = certDir();
21
+ return { keyPath: path_1.default.join(dir, 'localhost.key'), certPath: path_1.default.join(dir, 'localhost.crt') };
22
+ }
23
+ async function loadOrCreateCert() {
24
+ const { keyPath, certPath } = certPaths();
25
+ if (fs_1.default.existsSync(keyPath) && fs_1.default.existsSync(certPath)) {
26
+ return { key: fs_1.default.readFileSync(keyPath, 'utf8'), cert: fs_1.default.readFileSync(certPath, 'utf8') };
27
+ }
28
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ const selfsigned = require('selfsigned');
30
+ const pem = await selfsigned.generate([{ name: 'commonName', value: 'localhost' }], {
31
+ keySize: 2048,
32
+ days: 3650,
33
+ algorithm: 'sha256',
34
+ extensions: [
35
+ { name: 'subjectAltName', altNames: [{ type: 2, value: 'localhost' }, { type: 7, ip: '127.0.0.1' }] },
36
+ { name: 'keyUsage', keyCertSign: true, digitalSignature: true, keyEncipherment: true },
37
+ { name: 'extKeyUsage', serverAuth: true },
38
+ ],
39
+ });
40
+ fs_1.default.mkdirSync(path_1.default.dirname(keyPath), { recursive: true });
41
+ fs_1.default.writeFileSync(keyPath, pem.private, { mode: 0o600 });
42
+ fs_1.default.writeFileSync(certPath, pem.cert, { mode: 0o644 });
43
+ // NOTE: we deliberately do NOT install this into the OS trusted-root store here.
44
+ // Desktop Word/PowerPoint reach the pane over plain HTTP loopback and need no cert.
45
+ // Trusting a root CA triggers a Windows security popup on every add/remove and is
46
+ // invasive, so it is reserved for explicit Word-Online opt-in via trustCert() below.
47
+ return { key: pem.private, cert: pem.cert };
48
+ }
49
+ /**
50
+ * Installs the cert as a trusted root CA in the user's OS certificate store.
51
+ * ONLY call this for explicit Word-Online support — it prompts a Windows security
52
+ * dialog. It is idempotent: it no-ops if a matching localhost cert is already
53
+ * trusted, so it never produces the delete/add popup churn.
54
+ * Windows: certutil -addstore -user Root (skipped if already present)
55
+ * macOS: security add-trusted-cert -r trustRoot -k login.keychain
56
+ */
57
+ function trustCert(certPath) {
58
+ if (process.platform === 'win32') {
59
+ // Skip if a localhost cert is already trusted — avoids the confirmation popup.
60
+ const check = (0, child_process_1.spawnSync)('certutil', ['-user', '-store', 'Root', 'localhost'], { encoding: 'utf8' });
61
+ if (check.status === 0 && /localhost/i.test(check.stdout))
62
+ return;
63
+ (0, child_process_1.spawnSync)('certutil', ['-addstore', '-user', 'Root', certPath], { stdio: 'ignore' });
64
+ return;
65
+ }
66
+ if (process.platform === 'darwin') {
67
+ const keychain = path_1.default.join(os_1.default.homedir(), 'Library', 'Keychains', 'login.keychain-db');
68
+ (0, child_process_1.spawnSync)('security', ['add-trusted-cert', '-r', 'trustRoot', '-k', keychain, certPath], { stdio: 'ignore' });
69
+ }
70
+ }
@@ -1,9 +1,27 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.launchDesktopShell = launchDesktopShell;
4
7
  const electron_1 = require("electron");
8
+ const path_1 = __importDefault(require("path"));
9
+ const fs_1 = __importDefault(require("fs"));
5
10
  const server_1 = require("./server");
6
11
  const db_service_1 = require("../fraim/db-service");
12
+ const cert_store_1 = require("./cert-store");
13
+ const office_sideload_1 = require("./office-sideload");
14
+ // ---------------------------------------------------------------------------
15
+ // State
16
+ // ---------------------------------------------------------------------------
17
+ let server = null;
18
+ let mainWindow = null;
19
+ let tray = null;
20
+ let stopping = null;
21
+ let isQuitting = false; // distinguishes window-close (→ tray) from app-quit
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
7
25
  function tryCreateDbService() {
8
26
  try {
9
27
  return new db_service_1.FraimDbService();
@@ -12,102 +30,259 @@ function tryCreateDbService() {
12
30
  return undefined;
13
31
  }
14
32
  }
15
- let server = null;
16
- let mainWindow = null;
17
- let stopping = null;
18
33
  function preferredWindowSize() {
19
- const primaryDisplay = electron_1.screen.getPrimaryDisplay();
20
- const workArea = primaryDisplay.workAreaSize;
34
+ const { workAreaSize } = electron_1.screen.getPrimaryDisplay();
21
35
  return {
22
- width: Math.max(1440, Math.min(1680, workArea.width)),
23
- height: Math.max(980, Math.min(1180, workArea.height)),
36
+ width: Math.max(1440, Math.min(1680, workAreaSize.width)),
37
+ height: Math.max(980, Math.min(1180, workAreaSize.height)),
24
38
  };
25
39
  }
26
40
  function parseArgs(argv) {
27
41
  let projectPath = process.cwd();
28
42
  let preferredPort = 43091;
29
- for (let index = 0; index < argv.length; index += 1) {
30
- const value = argv[index];
31
- if (value === '--project-path' && argv[index + 1]) {
32
- projectPath = argv[index + 1];
33
- index += 1;
34
- continue;
43
+ for (let i = 0; i < argv.length; i += 1) {
44
+ if (argv[i] === '--project-path' && argv[i + 1]) {
45
+ projectPath = argv[i + 1];
46
+ i += 1;
35
47
  }
36
- if (value === '--port' && argv[index + 1]) {
37
- preferredPort = Number(argv[index + 1]) || preferredPort;
38
- index += 1;
48
+ if (argv[i] === '--port' && argv[i + 1]) {
49
+ preferredPort = Number(argv[i + 1]) || preferredPort;
50
+ i += 1;
39
51
  }
40
52
  }
41
53
  return { projectPath, preferredPort };
42
54
  }
43
- async function stopServer() {
44
- if (!server)
55
+ // ---------------------------------------------------------------------------
56
+ // Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
57
+ // so the app never crashes if assets aren't present.
58
+ // ---------------------------------------------------------------------------
59
+ function resolveTrayIcon() {
60
+ const candidates = [
61
+ path_1.default.resolve(process.cwd(), 'extensions/office-word/icon-64.png'),
62
+ path_1.default.resolve(__dirname, '..', '..', 'extensions/office-word/icon-64.png'),
63
+ path_1.default.resolve(__dirname, '..', '..', '..', 'extensions/office-word/icon-64.png'),
64
+ ];
65
+ for (const c of candidates) {
66
+ if (fs_1.default.existsSync(c))
67
+ return electron_1.nativeImage.createFromPath(c).resize({ width: 16, height: 16 });
68
+ }
69
+ return electron_1.nativeImage.createEmpty();
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // Login-item (auto-start on boot)
73
+ // ---------------------------------------------------------------------------
74
+ function ensureLoginItem() {
75
+ const flagPath = path_1.default.join(electron_1.app.getPath('userData'), 'login-item-set.flag');
76
+ if (fs_1.default.existsSync(flagPath))
45
77
  return;
46
- await server.stop();
47
- server = null;
78
+ electron_1.app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true });
79
+ fs_1.default.mkdirSync(path_1.default.dirname(flagPath), { recursive: true });
80
+ fs_1.default.writeFileSync(flagPath, '1');
48
81
  }
49
- function stopServerOnce() {
50
- if (!stopping) {
51
- stopping = stopServer().finally(() => {
52
- stopping = null;
53
- });
82
+ // ---------------------------------------------------------------------------
83
+ // Word manifest sideload (runs once on first launch)
84
+ // ---------------------------------------------------------------------------
85
+ function ensureWordSideload(projectPath) {
86
+ // Flag version bump: bump this string when new manifests are added so all
87
+ // users get re-sideloaded on their next launch.
88
+ const FLAG_VERSION = 'v3-filepath';
89
+ const flagPath = path_1.default.join(electron_1.app.getPath('userData'), 'word-sideloaded.flag');
90
+ if (fs_1.default.existsSync(flagPath) && fs_1.default.readFileSync(flagPath, 'utf8').trim() === FLAG_VERSION)
91
+ return;
92
+ if (!(0, office_sideload_1.isSideloaded)()) {
93
+ const result = (0, office_sideload_1.sideloadManifest)(projectPath);
94
+ if (!result.ok) {
95
+ console.warn('[fraim] Office sideload skipped:', result.reason);
96
+ return; // don't write flag — retry next launch
97
+ }
54
98
  }
55
- return stopping;
99
+ fs_1.default.mkdirSync(path_1.default.dirname(flagPath), { recursive: true });
100
+ fs_1.default.writeFileSync(flagPath, FLAG_VERSION);
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Tray setup
104
+ // ---------------------------------------------------------------------------
105
+ function buildTrayMenu(hubUrl) {
106
+ return electron_1.Menu.buildFromTemplate([
107
+ {
108
+ label: 'Open FRAIM Hub',
109
+ click: () => {
110
+ if (mainWindow) {
111
+ mainWindow.show();
112
+ mainWindow.focus();
113
+ }
114
+ else {
115
+ void createWindow(hubUrl);
116
+ }
117
+ },
118
+ },
119
+ { type: 'separator' },
120
+ {
121
+ label: 'Word Add-in',
122
+ enabled: false,
123
+ sublabel: (0, office_sideload_1.isSideloaded)() ? 'Active in Word' : 'Not sideloaded',
124
+ },
125
+ { type: 'separator' },
126
+ {
127
+ label: 'Start at Login',
128
+ type: 'checkbox',
129
+ checked: electron_1.app.getLoginItemSettings().openAtLogin,
130
+ click: (item) => {
131
+ electron_1.app.setLoginItemSettings({ openAtLogin: item.checked, openAsHidden: true });
132
+ },
133
+ },
134
+ { type: 'separator' },
135
+ {
136
+ label: 'Quit FRAIM',
137
+ click: () => {
138
+ isQuitting = true;
139
+ electron_1.app.quit();
140
+ },
141
+ },
142
+ ]);
56
143
  }
144
+ function createTray(hubUrl) {
145
+ tray = new electron_1.Tray(resolveTrayIcon());
146
+ tray.setToolTip('FRAIM AI Hub');
147
+ tray.setContextMenu(buildTrayMenu(hubUrl));
148
+ tray.on('double-click', () => {
149
+ if (mainWindow) {
150
+ mainWindow.show();
151
+ mainWindow.focus();
152
+ }
153
+ else {
154
+ void createWindow(hubUrl);
155
+ }
156
+ });
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // BrowserWindow
160
+ // ---------------------------------------------------------------------------
57
161
  async function createWindow(url) {
58
162
  const { width, height } = preferredWindowSize();
163
+ const isMac = process.platform === 'darwin';
164
+ const isWin = process.platform === 'win32';
59
165
  mainWindow = new electron_1.BrowserWindow({
60
- title: 'AI Hub',
166
+ title: 'FRAIM AI Hub',
61
167
  width,
62
168
  height,
63
- minWidth: 1400,
64
- minHeight: 960,
169
+ minWidth: 1200,
170
+ minHeight: 800,
65
171
  useContentSize: true,
66
172
  autoHideMenuBar: true,
67
- backgroundColor: '#f2f5fb',
173
+ backgroundColor: (isMac || isWin) ? '#00000000' : '#ECECEC',
174
+ titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
175
+ ...(isWin && {
176
+ titleBarOverlay: { color: 'rgba(0,0,0,0)', symbolColor: '#1A1A1A', height: 36 },
177
+ }),
178
+ ...(isWin && { backgroundMaterial: 'mica' }),
179
+ ...(isMac && { vibrancy: 'sidebar' }),
68
180
  show: false,
69
- webPreferences: {
70
- contextIsolation: true,
71
- nodeIntegration: false,
72
- sandbox: true,
73
- },
181
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
74
182
  });
75
183
  electron_1.Menu.setApplicationMenu(null);
76
184
  mainWindow.webContents.setWindowOpenHandler(({ url: targetUrl }) => {
77
185
  void electron_1.shell.openExternal(targetUrl);
78
186
  return { action: 'deny' };
79
187
  });
80
- mainWindow.once('ready-to-show', () => {
81
- mainWindow?.show();
82
- });
83
- mainWindow.on('closed', () => {
84
- mainWindow = null;
188
+ mainWindow.once('ready-to-show', () => mainWindow?.show());
189
+ // Closing the window hides it to the tray rather than quitting the app.
190
+ // The server keeps running so Word can still reach the task pane.
191
+ mainWindow.on('close', (event) => {
192
+ if (!isQuitting) {
193
+ event.preventDefault();
194
+ mainWindow?.hide();
195
+ }
85
196
  });
197
+ mainWindow.on('closed', () => { mainWindow = null; });
86
198
  await mainWindow.loadURL(url);
87
199
  mainWindow.webContents.setZoomFactor(1);
88
200
  }
201
+ // ---------------------------------------------------------------------------
202
+ // Server lifecycle
203
+ // ---------------------------------------------------------------------------
204
+ async function stopServer() {
205
+ if (!server)
206
+ return;
207
+ await server.stop();
208
+ server = null;
209
+ }
210
+ function stopServerOnce() {
211
+ if (!stopping) {
212
+ stopping = stopServer().finally(() => { stopping = null; });
213
+ }
214
+ return stopping;
215
+ }
216
+ // ---------------------------------------------------------------------------
217
+ // Launch
218
+ // ---------------------------------------------------------------------------
89
219
  async function launchDesktopShell(options) {
90
- const port = await (0, server_1.findAvailablePort)(options.preferredPort);
91
- server = new server_1.AiHubServer({ projectPath: options.projectPath, dbService: tryCreateDbService() });
92
- await server.start(port);
93
- await createWindow(`http://127.0.0.1:${port}/ai-hub/`);
220
+ const [httpPort, httpsPort] = await Promise.all([
221
+ (0, server_1.findAvailablePort)(options.preferredPort),
222
+ (0, server_1.findAvailablePort)(43092),
223
+ ]);
224
+ // Generate (or load cached) self-signed cert for HTTPS.
225
+ // Fast on subsequent launches (file read); ~200ms on first launch (key gen).
226
+ const certBundle = await (0, cert_store_1.loadOrCreateCert)();
227
+ server = new server_1.AiHubServer({
228
+ projectPath: options.projectPath,
229
+ dbService: tryCreateDbService(),
230
+ httpsPort,
231
+ certBundle,
232
+ folderPicker: async () => {
233
+ const result = await electron_1.dialog.showOpenDialog(mainWindow, {
234
+ title: 'Select a FRAIM project folder',
235
+ properties: ['openDirectory', 'createDirectory'],
236
+ buttonLabel: 'Select Folder',
237
+ });
238
+ return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
239
+ },
240
+ });
241
+ await server.start(httpPort);
242
+ ensureWordSideload(options.projectPath);
243
+ const hubUrl = `http://127.0.0.1:${httpPort}/ai-hub/`;
244
+ createTray(hubUrl);
245
+ await createWindow(hubUrl);
94
246
  }
247
+ // ---------------------------------------------------------------------------
248
+ // Bootstrap
249
+ // ---------------------------------------------------------------------------
95
250
  async function bootstrap() {
96
251
  const options = parseArgs(process.argv.slice(2));
252
+ // Single-instance lock — if another instance is already running, focus it
253
+ // and exit rather than spawning a second server + window.
254
+ const gotLock = electron_1.app.requestSingleInstanceLock();
255
+ if (!gotLock) {
256
+ electron_1.app.quit();
257
+ return;
258
+ }
259
+ electron_1.app.on('second-instance', () => {
260
+ if (mainWindow) {
261
+ mainWindow.show();
262
+ mainWindow.focus();
263
+ }
264
+ });
97
265
  await electron_1.app.whenReady();
98
- electron_1.app.setName('AI Hub');
266
+ electron_1.app.setName('FRAIM Hub');
267
+ // First-launch housekeeping (idempotent, fast on subsequent runs)
268
+ ensureLoginItem();
99
269
  electron_1.app.on('activate', () => {
100
- if (electron_1.BrowserWindow.getAllWindows().length === 0) {
101
- void launchDesktopShell(options);
270
+ // macOS: clicking dock icon re-shows the window
271
+ if (mainWindow) {
272
+ mainWindow.show();
273
+ mainWindow.focus();
102
274
  }
103
275
  });
104
276
  electron_1.app.on('before-quit', () => {
277
+ isQuitting = true;
105
278
  void stopServerOnce();
106
279
  });
280
+ // Keep app alive when all windows are closed — server must keep serving
281
+ // for Word to reach the task pane. Only quit on explicit tray → Quit.
107
282
  electron_1.app.on('window-all-closed', () => {
108
- void stopServerOnce().finally(() => {
109
- electron_1.app.quit();
110
- });
283
+ if (process.platform !== 'darwin' && isQuitting) {
284
+ void stopServerOnce().finally(() => electron_1.app.quit());
285
+ }
111
286
  });
112
287
  await launchDesktopShell(options);
113
288
  }
@@ -10,6 +10,9 @@ exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
10
10
  exports.detectEmployees = detectEmployees;
11
11
  exports.buildStartPlan = buildStartPlan;
12
12
  exports.buildContinuePlan = buildContinuePlan;
13
+ exports.supportsDirectPath = supportsDirectPath;
14
+ exports.buildDirectStartPlan = buildDirectStartPlan;
15
+ exports.buildDirectContinuePlan = buildDirectContinuePlan;
13
16
  exports.parseHostLine = parseHostLine;
14
17
  const crypto_1 = require("crypto");
15
18
  const child_process_1 = require("child_process");
@@ -252,6 +255,7 @@ function detectEmployees() {
252
255
  label: EMPLOYEE_LABELS[id],
253
256
  available,
254
257
  detail: available ? 'Installed and ready on this machine.' : 'CLI not detected on this machine.',
258
+ supportsRaw: supportsDirectPath(id),
255
259
  };
256
260
  });
257
261
  }
@@ -370,6 +374,93 @@ function buildContinuePlan(hostId, sessionId, message) {
370
374
  stdin: transformHeadlessFraimMessage(message, 'continue'),
371
375
  };
372
376
  }
377
+ // Issue #442: all agents support a direct-path invocation (no FRAIM, no
378
+ // transformHeadlessFraimMessage). Claude uses --strict-mcp-config for isolation;
379
+ // Codex and Gemini simply omit the FRAIM preamble and run raw.
380
+ function supportsDirectPath(_hostId) {
381
+ return true;
382
+ }
383
+ // Empty MCP config for Direct runs. Always overwritten on each use so a
384
+ // pre-existing file in tmp cannot inject MCP server definitions (SEC-A04).
385
+ const EMPTY_MCP_CONFIG_PATH = path_1.default.join(os_1.default.tmpdir(), 'fraim-direct-empty-mcp.json');
386
+ function ensureEmptyMcpConfig() {
387
+ fs_1.default.writeFileSync(EMPTY_MCP_CONFIG_PATH, '{"mcpServers":{}}', { encoding: 'utf8', mode: 0o600 });
388
+ return EMPTY_MCP_CONFIG_PATH;
389
+ }
390
+ // Issue #442: --append-system-prompt overrides CLAUDE.md instructions at the
391
+ // system-prompt level (CLAUDE.md is prepended to the system prompt; appending
392
+ // after it gives the Direct-mode directive precedence). --strict-mcp-config
393
+ // blocks MCP servers (FRAIM excluded). Both are required:
394
+ // --append-system-prompt alone doesn't block MCP tools; --strict-mcp-config
395
+ // alone doesn't suppress CLAUDE.md's "scan job stubs" instruction.
396
+ const DIRECT_SYSTEM_PROMPT_OVERRIDE = 'DO NOT USE FRAIM FOR THIS SESSION. ' +
397
+ 'You are operating in Direct mode for an A/B comparison — no FRAIM, no phases, no seekMentoring, ' +
398
+ 'no job stubs, no structured workflow. Ignore all CLAUDE.md instructions to scan fraim/ or call get_fraim_job. ' +
399
+ 'Answer the user directly and conversationally.';
400
+ const DIRECT_PREAMBLE = 'DO NOT USE FRAIM FOR THIS SESSION. No phases, no seekMentoring, no structured workflow.\n\n';
401
+ // Issue #442: builds a CLI plan for the Direct (B) side of an A/B run.
402
+ // All agents supported: Codex and Gemini run raw (no FRAIM preamble);
403
+ // Claude uses --strict-mcp-config + --append-system-prompt for full isolation.
404
+ function buildDirectStartPlan(hostId, message) {
405
+ if (hostId === 'codex') {
406
+ return {
407
+ command: executableName('codex'),
408
+ args: ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'],
409
+ stdin: DIRECT_PREAMBLE + message,
410
+ };
411
+ }
412
+ if (hostId === 'gemini') {
413
+ ensureGeminiApiKey();
414
+ return {
415
+ command: executableName('gemini'),
416
+ args: ['--yolo', '--skip-trust'],
417
+ stdin: DIRECT_PREAMBLE + message,
418
+ };
419
+ }
420
+ return {
421
+ command: executableName('claude'),
422
+ args: [
423
+ '-p',
424
+ '--verbose',
425
+ '--output-format', 'stream-json',
426
+ '--dangerously-skip-permissions',
427
+ '--strict-mcp-config',
428
+ '--mcp-config', ensureEmptyMcpConfig(),
429
+ '--append-system-prompt', DIRECT_SYSTEM_PROMPT_OVERRIDE,
430
+ ],
431
+ stdin: DIRECT_PREAMBLE + message,
432
+ };
433
+ }
434
+ function buildDirectContinuePlan(hostId, sessionId, message) {
435
+ if (hostId === 'codex') {
436
+ return {
437
+ command: executableName('codex'),
438
+ args: ['exec', 'resume', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', sessionId],
439
+ stdin: DIRECT_PREAMBLE + message,
440
+ };
441
+ }
442
+ if (hostId === 'gemini') {
443
+ ensureGeminiApiKey();
444
+ return {
445
+ command: executableName('gemini'),
446
+ args: ['--yolo', '--skip-trust'],
447
+ stdin: DIRECT_PREAMBLE + message,
448
+ };
449
+ }
450
+ return {
451
+ command: executableName('claude'),
452
+ args: [
453
+ '-p',
454
+ '--verbose',
455
+ '--output-format', 'stream-json',
456
+ '--dangerously-skip-permissions',
457
+ '--resume', sessionId,
458
+ '--strict-mcp-config',
459
+ '--mcp-config', ensureEmptyMcpConfig(),
460
+ ],
461
+ stdin: message,
462
+ };
463
+ }
373
464
  function parseHostLine(hostId, line) {
374
465
  const trimmed = line.trim();
375
466
  if (!trimmed)
@@ -395,6 +486,11 @@ function parseHostLine(hostId, line) {
395
486
  if (parsed.type === 'item.completed' && parsed.item?.type === 'agent_message' && parsed.item.text) {
396
487
  return withSignal({ message: parsed.item.text, raw: trimmed });
397
488
  }
489
+ // turn_context carries the active model — capture it as agentIdentity so
490
+ // direct runs (which never call fraim_connect) can still compute cost.
491
+ if (parsed.type === 'turn_context' && typeof parsed.payload?.model === 'string') {
492
+ return withSignal({ raw: trimmed, agentIdentity: { agentName: 'codex', agentModel: parsed.payload.model } });
493
+ }
398
494
  return withSignal({ raw: trimmed });
399
495
  }
400
496
  catch {
@@ -424,8 +520,10 @@ function parseHostLine(hostId, line) {
424
520
  return withSignal({ message: text, sessionId: parsed.session_id, raw: trimmed });
425
521
  }
426
522
  }
427
- if (parsed.type === 'result' && parsed.result) {
428
- return withSignal({ message: parsed.result, sessionId: parsed.session_id, raw: trimmed });
523
+ if (parsed.type === 'result') {
524
+ // Don't emit message the 'assistant' event already captured the turn text.
525
+ // result carries usage data (parsed by parseUsageSignal above via withSignal).
526
+ return withSignal({ sessionId: parsed.session_id, raw: trimmed });
429
527
  }
430
528
  return withSignal({ raw: trimmed });
431
529
  }
@@ -484,14 +582,20 @@ class CliHostRuntime {
484
582
  continueRun(hostId, projectPath, sessionId, message, handlers) {
485
583
  return spawnHostProcess(hostId, buildContinuePlan(hostId, sessionId, message), projectPath, handlers);
486
584
  }
585
+ startDirectRun(hostId, message, projectPath, handlers) {
586
+ return spawnHostProcess(hostId, buildDirectStartPlan(hostId, message), projectPath, handlers);
587
+ }
588
+ continueDirectRun(hostId, sessionId, message, projectPath, handlers) {
589
+ return spawnHostProcess(hostId, buildDirectContinuePlan(hostId, sessionId, message), projectPath, handlers);
590
+ }
487
591
  }
488
592
  exports.CliHostRuntime = CliHostRuntime;
489
593
  class FakeHostRuntime {
490
594
  constructor() {
491
595
  this.employees = [
492
- { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.' },
493
- { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.' },
494
- { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.' },
596
+ { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
597
+ { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
598
+ { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
495
599
  ];
496
600
  }
497
601
  detectEmployees() {
@@ -503,6 +607,12 @@ class FakeHostRuntime {
503
607
  continueRun(hostId, _projectPath, sessionId, message, handlers) {
504
608
  return this.fakeProcess(hostId, this.fakeEmployeeReply('continue', message), handlers);
505
609
  }
610
+ startDirectRun(hostId, _message, _projectPath, handlers) {
611
+ return this.fakeProcess(hostId, 'Understood. Working directly on that now.', handlers);
612
+ }
613
+ continueDirectRun(hostId, _sessionId, _message, _projectPath, handlers) {
614
+ return this.fakeProcess(hostId, 'Got it. Continuing directly.', handlers);
615
+ }
506
616
  fakeProcess(_hostId, text, handlers) {
507
617
  handlers.onEvent({ sessionId: (0, crypto_1.randomUUID)() }, 'system');
508
618
  handlers.onEvent({ message: text }, 'stdout');
@@ -563,9 +673,9 @@ exports.FakeHostRuntime = FakeHostRuntime;
563
673
  class ScriptedHostRuntime {
564
674
  constructor() {
565
675
  this.employees = [
566
- { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.' },
567
- { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.' },
568
- { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.' },
676
+ { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
677
+ { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
678
+ { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
569
679
  ];
570
680
  // Track each active run so the test can emit signals at it. Key is the
571
681
  // sessionId we hand back on startRun; mapping sessionId → handlers
@@ -589,6 +699,16 @@ class ScriptedHostRuntime {
589
699
  handlers.onEvent({ sessionId, raw: 'scripted-session-resume' }, 'system');
590
700
  return this.spawnDouble();
591
701
  }
702
+ startDirectRun(_hostId, _message, _projectPath, handlers) {
703
+ const sessionId = (0, crypto_1.randomUUID)();
704
+ handlers.onEvent({ sessionId, raw: 'scripted-direct-session-start' }, 'system');
705
+ return this.spawnDouble();
706
+ }
707
+ continueDirectRun(_hostId, sessionId, _message, _projectPath, handlers) {
708
+ this.handlersBySession.set(sessionId, handlers);
709
+ handlers.onEvent({ sessionId, raw: 'scripted-direct-session-resume' }, 'system');
710
+ return this.spawnDouble();
711
+ }
592
712
  // Test API — fire a seekMentoring tool-use event for the most recent
593
713
  // active run. The phaseId is the raw FSM phase id (e.g.,
594
714
  // 'implement-validate'); the parser will turn it into a friendly
@@ -670,6 +790,13 @@ class ScriptedHostRuntime {
670
790
  agentIdentity: { agentName, agentModel },
671
791
  }, 'stdout');
672
792
  }
793
+ // Test API — emit a message from the employee (appears in the thread as an employee bubble).
794
+ emitEmployeeMessage(runId, text) {
795
+ const target = this.resolveSession(runId);
796
+ if (!target)
797
+ return;
798
+ target.handlers.onEvent({ sessionId: target.sessionId, message: text, raw: `scripted-employee:${text.slice(0, 40)}` }, 'stdout');
799
+ }
673
800
  // Reset between tests (called from beforeEach).
674
801
  reset() {
675
802
  this.handlersBySession.clear();
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractExplicitFraimInvocation = extractExplicitFraimInvocation;
4
+ exports.fraimInvocationFor = fraimInvocationFor;
5
+ exports.buildManagerMessage = buildManagerMessage;
6
+ function extractExplicitFraimInvocation(text) {
7
+ const raw = String(text || '');
8
+ const match = raw.match(/(?:^|\n|\s)([$/]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
9
+ if (!match || match.index == null)
10
+ return null;
11
+ const before = raw.slice(0, match.index).trim();
12
+ const after = raw.slice(match.index + match[0].length).trim();
13
+ const remainder = [before, after].filter(Boolean).join('\n\n').trim();
14
+ return {
15
+ symbol: match[1].toLowerCase() === '$fraim' ? '$fraim' : '/fraim',
16
+ jobId: match[2].toLowerCase(),
17
+ remainder,
18
+ };
19
+ }
20
+ function fraimInvocationFor(employeeId, jobId) {
21
+ if (jobId === '__freeform__')
22
+ return null;
23
+ const symbol = employeeId === 'codex' ? '$fraim' : '/fraim';
24
+ return `${symbol} ${jobId}`;
25
+ }
26
+ function buildManagerMessage(employeeId, jobId, kind, instructions, stubPath) {
27
+ const trimmed = String(instructions || '').trim();
28
+ const explicit = extractExplicitFraimInvocation(trimmed);
29
+ const effectiveJobId = explicit?.jobId || jobId;
30
+ const invocation = fraimInvocationFor(employeeId, effectiveJobId);
31
+ if (!invocation)
32
+ return explicit?.remainder || trimmed;
33
+ const remainder = explicit ? explicit.remainder : trimmed;
34
+ const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
35
+ if (!remainder)
36
+ return `${invocation}${stub}`;
37
+ return `${invocation}${stub}\n\n${remainder}`;
38
+ }