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.
- package/README.md +1 -1
- package/dist/src/ai-hub/cert-store.js +70 -0
- package/dist/src/ai-hub/desktop-main.js +225 -50
- package/dist/src/ai-hub/hosts.js +135 -8
- package/dist/src/ai-hub/manager-turns.js +38 -0
- package/dist/src/ai-hub/office-sideload.js +138 -0
- package/dist/src/ai-hub/openclaw-bridge.js +239 -0
- package/dist/src/ai-hub/server.js +479 -48
- package/dist/src/ai-hub/word-sideload.js +95 -0
- package/dist/src/cli/commands/add-ide.js +9 -0
- package/dist/src/cli/commands/init-project.js +46 -34
- package/dist/src/cli/commands/login.js +1 -2
- package/dist/src/cli/commands/setup.js +0 -2
- package/dist/src/cli/commands/sync.js +41 -11
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
- package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
- package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
- package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +2 -2
- package/dist/src/cli/utils/fraim-gitignore.js +11 -0
- package/dist/src/cli/utils/github-workflow-sync.js +231 -0
- package/dist/src/cli/utils/managed-agent-paths.js +1 -1
- package/dist/src/cli/utils/project-bootstrap.js +6 -3
- package/dist/src/cli/utils/remote-sync.js +1 -1
- package/dist/src/core/ai-mentor.js +46 -37
- package/dist/src/core/config-loader.js +69 -2
- package/dist/src/core/fraim-config-schema.generated.js +267 -6
- package/dist/src/core/types.js +0 -1
- package/dist/src/core/utils/fraim-labels.js +182 -0
- package/dist/src/core/utils/git-utils.js +22 -1
- package/dist/src/core/utils/project-fraim-paths.js +58 -0
- package/dist/src/first-run/session-service.js +3 -3
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +77 -52
- package/dist/src/local-mcp-server/stdio-server.js +212 -13
- package/package.json +6 -2
- package/public/ai-hub/index.html +289 -229
- package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
- package/public/ai-hub/script.js +1155 -586
- package/public/ai-hub/styles.css +1226 -722
- package/public/first-run/index.html +35 -35
- 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
|
|
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
|
|
20
|
-
const workArea = primaryDisplay.workAreaSize;
|
|
34
|
+
const { workAreaSize } = electron_1.screen.getPrimaryDisplay();
|
|
21
35
|
return {
|
|
22
|
-
width: Math.max(1440, Math.min(1680,
|
|
23
|
-
height: Math.max(980, Math.min(1180,
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 (
|
|
37
|
-
preferredPort = Number(argv[
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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:
|
|
64
|
-
minHeight:
|
|
169
|
+
minWidth: 1200,
|
|
170
|
+
minHeight: 800,
|
|
65
171
|
useContentSize: true,
|
|
66
172
|
autoHideMenuBar: true,
|
|
67
|
-
backgroundColor: '#
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
mainWindow.on('
|
|
84
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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('
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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'
|
|
428
|
-
|
|
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
|
+
}
|