fraim 2.0.135 → 2.0.136
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/ai-hub/hosts.js +32 -3
- package/dist/src/ai-hub/server.js +137 -1
- package/package.json +1 -1
- package/public/ai-hub/index.html +1 -0
- package/public/ai-hub/script.js +149 -1
- package/public/ai-hub/styles.css +21 -0
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -207,6 +207,7 @@ function extractSignalFromArgs(args) {
|
|
|
207
207
|
const EMPLOYEE_LABELS = {
|
|
208
208
|
codex: 'Codex',
|
|
209
209
|
claude: 'Claude Code',
|
|
210
|
+
gemini: 'Gemini CLI',
|
|
210
211
|
};
|
|
211
212
|
const executableName = (command) => command;
|
|
212
213
|
function quoteWindowsArg(value) {
|
|
@@ -237,9 +238,7 @@ const availableByVersionProbe = (command) => {
|
|
|
237
238
|
};
|
|
238
239
|
function detectEmployees() {
|
|
239
240
|
return Object.keys(EMPLOYEE_LABELS).map((id) => {
|
|
240
|
-
const available = id
|
|
241
|
-
? availableByVersionProbe(executableName('codex'))
|
|
242
|
-
: availableByVersionProbe(executableName('claude'));
|
|
241
|
+
const available = availableByVersionProbe(executableName(id));
|
|
243
242
|
return {
|
|
244
243
|
id,
|
|
245
244
|
label: EMPLOYEE_LABELS[id],
|
|
@@ -256,6 +255,13 @@ function buildStartPlan(hostId, message) {
|
|
|
256
255
|
stdin: message,
|
|
257
256
|
};
|
|
258
257
|
}
|
|
258
|
+
if (hostId === 'gemini') {
|
|
259
|
+
return {
|
|
260
|
+
command: executableName('gemini'),
|
|
261
|
+
args: ['--yolo'],
|
|
262
|
+
stdin: message,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
259
265
|
return {
|
|
260
266
|
command: executableName('claude'),
|
|
261
267
|
args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions'],
|
|
@@ -270,6 +276,15 @@ function buildContinuePlan(hostId, sessionId, message) {
|
|
|
270
276
|
stdin: message,
|
|
271
277
|
};
|
|
272
278
|
}
|
|
279
|
+
if (hostId === 'gemini') {
|
|
280
|
+
// Gemini CLI does not have a native session-resume flag; each message
|
|
281
|
+
// is sent as a fresh invocation. The Hub still tracks state client-side.
|
|
282
|
+
return {
|
|
283
|
+
command: executableName('gemini'),
|
|
284
|
+
args: ['--yolo'],
|
|
285
|
+
stdin: message,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
273
288
|
return {
|
|
274
289
|
command: executableName('claude'),
|
|
275
290
|
args: ['-p', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', '-r', sessionId],
|
|
@@ -307,6 +322,18 @@ function parseHostLine(hostId, line) {
|
|
|
307
322
|
return withSignal({ raw: trimmed });
|
|
308
323
|
}
|
|
309
324
|
}
|
|
325
|
+
// Gemini CLI output: if the line is valid JSON, apply signal scanning and
|
|
326
|
+
// capture the raw line. Plain-text output (non-JSON) is captured as a raw
|
|
327
|
+
// message so it still surfaces in the Hub timeline.
|
|
328
|
+
if (hostId === 'gemini') {
|
|
329
|
+
try {
|
|
330
|
+
JSON.parse(trimmed); // validate JSON — if this throws, fall through to plain text
|
|
331
|
+
return withSignal({ raw: trimmed });
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return withSignal({ message: trimmed, raw: trimmed });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
310
337
|
try {
|
|
311
338
|
const parsed = JSON.parse(trimmed);
|
|
312
339
|
if (parsed.type === 'system' && parsed.session_id) {
|
|
@@ -385,6 +412,7 @@ class FakeHostRuntime {
|
|
|
385
412
|
this.employees = [
|
|
386
413
|
{ id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.' },
|
|
387
414
|
{ id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.' },
|
|
415
|
+
{ id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.' },
|
|
388
416
|
];
|
|
389
417
|
}
|
|
390
418
|
detectEmployees() {
|
|
@@ -444,6 +472,7 @@ class ScriptedHostRuntime {
|
|
|
444
472
|
this.employees = [
|
|
445
473
|
{ id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.' },
|
|
446
474
|
{ id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.' },
|
|
475
|
+
{ id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.' },
|
|
447
476
|
];
|
|
448
477
|
// Track each active run so the test can emit signals at it. Key is the
|
|
449
478
|
// sessionId we hand back on startRun; mapping sessionId → handlers
|
|
@@ -9,8 +9,10 @@ const express_1 = __importDefault(require("express"));
|
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const fs_1 = __importDefault(require("fs"));
|
|
11
11
|
const net_1 = __importDefault(require("net"));
|
|
12
|
+
const os_1 = __importDefault(require("os"));
|
|
12
13
|
const crypto_1 = require("crypto");
|
|
13
14
|
const child_process_1 = require("child_process");
|
|
15
|
+
const types_1 = require("../first-run/types");
|
|
14
16
|
const catalog_1 = require("./catalog");
|
|
15
17
|
const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
16
18
|
const hosts_1 = require("./hosts");
|
|
@@ -233,6 +235,77 @@ function deriveStages(run, projectPath) {
|
|
|
233
235
|
return { phaseId: phase.id, label: phase.label, state };
|
|
234
236
|
});
|
|
235
237
|
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Agent install helpers (shared with first-run; duplicated here to avoid
|
|
240
|
+
// the session-key dependency in that module's public API).
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
const HUB_TO_FIRST_RUN_ID = {
|
|
243
|
+
claude: 'claude-code',
|
|
244
|
+
codex: 'codex',
|
|
245
|
+
gemini: 'gemini-cli',
|
|
246
|
+
};
|
|
247
|
+
function hubAgentOption(hubId) {
|
|
248
|
+
const frId = HUB_TO_FIRST_RUN_ID[hubId];
|
|
249
|
+
return frId ? types_1.FIRST_RUN_AGENT_OPTIONS.find((o) => o.id === frId) : undefined;
|
|
250
|
+
}
|
|
251
|
+
function hubCommandVersion(command) {
|
|
252
|
+
const executable = process.platform === 'win32' ? 'cmd.exe' : command;
|
|
253
|
+
const args = process.platform === 'win32'
|
|
254
|
+
? ['/d', '/s', '/c', `${command} --version`]
|
|
255
|
+
: ['--version'];
|
|
256
|
+
const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000 });
|
|
257
|
+
if (result.status !== 0 || result.error)
|
|
258
|
+
return null;
|
|
259
|
+
const raw = (result.stdout || result.stderr || '').trim();
|
|
260
|
+
return raw || null;
|
|
261
|
+
}
|
|
262
|
+
function hubRunProcess(command, args, env) {
|
|
263
|
+
return new Promise((resolve, reject) => {
|
|
264
|
+
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
|
|
265
|
+
const child = (0, child_process_1.spawn)(executable, args, {
|
|
266
|
+
env: { ...process.env, ...env },
|
|
267
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
268
|
+
});
|
|
269
|
+
let stdout = '';
|
|
270
|
+
let stderr = '';
|
|
271
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
272
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
273
|
+
child.on('close', (code) => {
|
|
274
|
+
if (code === 0)
|
|
275
|
+
resolve({ stdout, stderr });
|
|
276
|
+
else
|
|
277
|
+
reject(new Error(stderr || `Process exited with code ${code}`));
|
|
278
|
+
});
|
|
279
|
+
child.on('error', reject);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
function hubOpenTerminal(command) {
|
|
283
|
+
if (process.platform === 'win32') {
|
|
284
|
+
(0, child_process_1.spawn)('cmd.exe', ['/c', 'start', 'cmd.exe', '/k', command], { detached: true, stdio: 'ignore' }).unref();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (process.platform === 'darwin') {
|
|
288
|
+
const script = `tell application "Terminal" to do script "${command.replace(/"/g, '\\"')}"`;
|
|
289
|
+
(0, child_process_1.spawn)('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const linux = [
|
|
293
|
+
['gnome-terminal', ['--', 'bash', '-c', `${command}; exec bash`]],
|
|
294
|
+
['xterm', ['-e', `bash -c '${command}; exec bash'`]],
|
|
295
|
+
['konsole', ['--noclose', '-e', 'bash', '-c', command]],
|
|
296
|
+
['x-terminal-emulator', ['-e', `bash -c '${command}; exec bash'`]],
|
|
297
|
+
];
|
|
298
|
+
for (const [term, args] of linux) {
|
|
299
|
+
if ((0, child_process_1.spawnSync)('which', [term], { encoding: 'utf8' }).status === 0) {
|
|
300
|
+
(0, child_process_1.spawn)(term, args, { detached: true, stdio: 'ignore' }).unref();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
(0, child_process_1.spawn)('bash', ['-c', command], { detached: true, stdio: 'ignore' }).unref();
|
|
305
|
+
}
|
|
306
|
+
function getUserHubDir() {
|
|
307
|
+
return path_1.default.join(os_1.default.homedir(), '.fraim');
|
|
308
|
+
}
|
|
236
309
|
function ensureDirectoryPath(projectPath) {
|
|
237
310
|
const trimmed = (projectPath || '').trim();
|
|
238
311
|
if (!trimmed) {
|
|
@@ -335,13 +408,76 @@ class AiHubServer {
|
|
|
335
408
|
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open the folder picker.' });
|
|
336
409
|
}
|
|
337
410
|
});
|
|
411
|
+
this.app.post('/api/ai-hub/install-agent', async (req, res) => {
|
|
412
|
+
const { hubId } = req.body;
|
|
413
|
+
if (!hubId)
|
|
414
|
+
return res.status(400).json({ error: 'hubId is required.' });
|
|
415
|
+
const option = hubAgentOption(hubId);
|
|
416
|
+
if (!option)
|
|
417
|
+
return res.status(400).json({ error: `Unknown agent: ${hubId}` });
|
|
418
|
+
try {
|
|
419
|
+
const prefix = path_1.default.join(getUserHubDir(), 'node');
|
|
420
|
+
fs_1.default.mkdirSync(prefix, { recursive: true });
|
|
421
|
+
await hubRunProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
|
|
422
|
+
return res.json({
|
|
423
|
+
ok: true,
|
|
424
|
+
message: `${option.label} installed successfully.`,
|
|
425
|
+
needsLogin: true,
|
|
426
|
+
loginCommand: option.loginCommand,
|
|
427
|
+
loginHint: `Sign in to ${option.label} to activate it. A terminal window will open — complete sign-in there, then click "Check if Ready".`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
const detail = error instanceof Error ? error.message : 'Unknown error';
|
|
432
|
+
return res.status(500).json({ ok: false, error: `Failed to install ${option.label}: ${detail}` });
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
this.app.post('/api/ai-hub/trigger-agent-login', (req, res) => {
|
|
436
|
+
const { hubId } = req.body;
|
|
437
|
+
if (!hubId)
|
|
438
|
+
return res.status(400).json({ error: 'hubId is required.' });
|
|
439
|
+
const option = hubAgentOption(hubId);
|
|
440
|
+
if (!option)
|
|
441
|
+
return res.status(400).json({ error: `Unknown agent: ${hubId}` });
|
|
442
|
+
try {
|
|
443
|
+
hubOpenTerminal(option.loginCommand);
|
|
444
|
+
return res.json({
|
|
445
|
+
ok: true,
|
|
446
|
+
message: `A terminal window opened with the ${option.label} sign-in command. Complete sign-in there, then return here.`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
const detail = error instanceof Error ? error.message : 'Unknown error';
|
|
451
|
+
return res.json({
|
|
452
|
+
ok: false,
|
|
453
|
+
message: `Could not open a terminal automatically: ${detail}. Run \`${option.loginCommand}\` in a terminal to sign in.`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
this.app.post('/api/ai-hub/check-agent', (req, res) => {
|
|
458
|
+
const { hubId } = req.body;
|
|
459
|
+
if (!hubId)
|
|
460
|
+
return res.status(400).json({ error: 'hubId is required.' });
|
|
461
|
+
const option = hubAgentOption(hubId);
|
|
462
|
+
if (!option)
|
|
463
|
+
return res.status(400).json({ error: `Unknown agent: ${hubId}` });
|
|
464
|
+
const ver = hubCommandVersion(option.launchCommand);
|
|
465
|
+
if (ver) {
|
|
466
|
+
return res.json({ ok: true, ready: true, message: `${option.label} is ready.` });
|
|
467
|
+
}
|
|
468
|
+
return res.json({
|
|
469
|
+
ok: true,
|
|
470
|
+
ready: false,
|
|
471
|
+
message: `${option.label} is not detected yet. Make sure sign-in is complete and try again.`,
|
|
472
|
+
});
|
|
473
|
+
});
|
|
338
474
|
this.app.post('/api/ai-hub/runs', (req, res) => {
|
|
339
475
|
try {
|
|
340
476
|
const projectPath = ensureDirectoryPath(req.body.projectPath || this.projectPath);
|
|
341
477
|
const hostId = req.body.hostId;
|
|
342
478
|
const jobId = req.body.jobId;
|
|
343
479
|
const message = (req.body.message || '').trim();
|
|
344
|
-
if (hostId !== 'codex' && hostId !== 'claude') {
|
|
480
|
+
if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
|
|
345
481
|
throw new Error('Choose an available employee before starting a job.');
|
|
346
482
|
}
|
|
347
483
|
if (!jobId) {
|
package/package.json
CHANGED
package/public/ai-hub/index.html
CHANGED
|
@@ -159,6 +159,7 @@
|
|
|
159
159
|
<span class="employee-label">Employee:</span>
|
|
160
160
|
<select id="employee-select" class="employee-select"></select>
|
|
161
161
|
</div>
|
|
162
|
+
<div id="agent-install-panel"></div>
|
|
162
163
|
</div>
|
|
163
164
|
<div class="modal-footer">
|
|
164
165
|
<span class="left">You can coach the employee with more detail after they start.</span>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -40,7 +40,7 @@ function gatherElements() {
|
|
|
40
40
|
'cancel1', 'next1', 'back2', 'start',
|
|
41
41
|
'job-search', 'job-catalog', 'job-pick-status',
|
|
42
42
|
'picked-name', 'picked-desc', 'instructions',
|
|
43
|
-
'employee-select',
|
|
43
|
+
'employee-select', 'agent-install-panel',
|
|
44
44
|
// Issue #347 additions: tracker, template picker, totals.
|
|
45
45
|
'tracker', 'tracker-rows', 'tracker-note',
|
|
46
46
|
'template-picker-btn', 'template-popover',
|
|
@@ -891,6 +891,153 @@ function renderEmployeeSelect() {
|
|
|
891
891
|
if (state.selectedEmployeeId === emp.id) opt.selected = true;
|
|
892
892
|
els['employee-select'].appendChild(opt);
|
|
893
893
|
}
|
|
894
|
+
renderAgentInstallPanel();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
// Agent install panel — shown in Step 2 for unavailable agents
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
// Per-agent install state tracked within the modal session.
|
|
902
|
+
const agentInstallState = {};
|
|
903
|
+
|
|
904
|
+
function renderAgentInstallPanel() {
|
|
905
|
+
const panel = els['agent-install-panel'];
|
|
906
|
+
if (!panel) return;
|
|
907
|
+
panel.innerHTML = '';
|
|
908
|
+
|
|
909
|
+
const employees = state.bootstrap?.employees || [];
|
|
910
|
+
const unavailable = employees.filter((e) => !e.available);
|
|
911
|
+
if (unavailable.length === 0) return;
|
|
912
|
+
|
|
913
|
+
const heading = document.createElement('div');
|
|
914
|
+
heading.className = 'install-panel-heading';
|
|
915
|
+
heading.textContent = 'Install missing agents';
|
|
916
|
+
panel.appendChild(heading);
|
|
917
|
+
|
|
918
|
+
for (const emp of unavailable) {
|
|
919
|
+
const row = document.createElement('div');
|
|
920
|
+
row.className = 'install-row';
|
|
921
|
+
row.id = `install-row-${emp.id}`;
|
|
922
|
+
|
|
923
|
+
const label = document.createElement('span');
|
|
924
|
+
label.className = 'install-label';
|
|
925
|
+
label.textContent = emp.label;
|
|
926
|
+
row.appendChild(label);
|
|
927
|
+
|
|
928
|
+
const status = document.createElement('span');
|
|
929
|
+
status.className = 'install-status';
|
|
930
|
+
status.id = `install-status-${emp.id}`;
|
|
931
|
+
status.textContent = agentInstallState[emp.id]?.statusText || '';
|
|
932
|
+
row.appendChild(status);
|
|
933
|
+
|
|
934
|
+
const btn = document.createElement('button');
|
|
935
|
+
btn.className = 'secondary small';
|
|
936
|
+
btn.id = `install-btn-${emp.id}`;
|
|
937
|
+
btn.dataset.hubId = emp.id;
|
|
938
|
+
|
|
939
|
+
const st = agentInstallState[emp.id] || {};
|
|
940
|
+
if (!st.phase) {
|
|
941
|
+
btn.textContent = `Install ${emp.label}`;
|
|
942
|
+
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
943
|
+
} else if (st.phase === 'installing') {
|
|
944
|
+
btn.textContent = 'Installing…';
|
|
945
|
+
btn.disabled = true;
|
|
946
|
+
} else if (st.phase === 'needs-login') {
|
|
947
|
+
btn.textContent = 'Sign In';
|
|
948
|
+
btn.addEventListener('click', () => triggerAgentLogin(emp.id));
|
|
949
|
+
} else if (st.phase === 'login-triggered') {
|
|
950
|
+
const checkBtn = document.createElement('button');
|
|
951
|
+
checkBtn.className = 'secondary small';
|
|
952
|
+
checkBtn.textContent = 'Check if Ready';
|
|
953
|
+
checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
|
|
954
|
+
row.appendChild(checkBtn);
|
|
955
|
+
|
|
956
|
+
const skipBtn = document.createElement('button');
|
|
957
|
+
skipBtn.className = 'ghost small';
|
|
958
|
+
skipBtn.textContent = 'Skip for now';
|
|
959
|
+
skipBtn.style.marginLeft = '6px';
|
|
960
|
+
skipBtn.addEventListener('click', () => {
|
|
961
|
+
delete agentInstallState[emp.id];
|
|
962
|
+
renderAgentInstallPanel();
|
|
963
|
+
});
|
|
964
|
+
row.appendChild(skipBtn);
|
|
965
|
+
panel.appendChild(row);
|
|
966
|
+
continue;
|
|
967
|
+
} else if (st.phase === 'ready') {
|
|
968
|
+
btn.textContent = '✓ Ready';
|
|
969
|
+
btn.disabled = true;
|
|
970
|
+
btn.style.color = 'var(--accent)';
|
|
971
|
+
} else if (st.phase === 'error') {
|
|
972
|
+
btn.textContent = 'Retry';
|
|
973
|
+
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
row.appendChild(btn);
|
|
977
|
+
panel.appendChild(row);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function setInstallState(hubId, phase, statusText) {
|
|
982
|
+
agentInstallState[hubId] = { phase, statusText: statusText || '' };
|
|
983
|
+
renderAgentInstallPanel();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function startAgentInstall(hubId) {
|
|
987
|
+
setInstallState(hubId, 'installing', 'Installing…');
|
|
988
|
+
try {
|
|
989
|
+
const result = await requestJson('/api/ai-hub/install-agent', {
|
|
990
|
+
method: 'POST',
|
|
991
|
+
headers: { 'Content-Type': 'application/json' },
|
|
992
|
+
body: JSON.stringify({ hubId }),
|
|
993
|
+
});
|
|
994
|
+
if (result.ok) {
|
|
995
|
+
setInstallState(hubId, 'needs-login', result.loginHint || 'Installed. Sign in to activate.');
|
|
996
|
+
} else {
|
|
997
|
+
setInstallState(hubId, 'error', result.message || 'Install failed.');
|
|
998
|
+
}
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
setInstallState(hubId, 'error', err.message || 'Install failed.');
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function triggerAgentLogin(hubId) {
|
|
1005
|
+
try {
|
|
1006
|
+
const result = await requestJson('/api/ai-hub/trigger-agent-login', {
|
|
1007
|
+
method: 'POST',
|
|
1008
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1009
|
+
body: JSON.stringify({ hubId }),
|
|
1010
|
+
});
|
|
1011
|
+
setInstallState(hubId, 'login-triggered', result.message || 'Sign-in terminal opened.');
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
setInstallState(hubId, 'error', err.message || 'Could not open terminal.');
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function checkAgentReady(hubId) {
|
|
1018
|
+
try {
|
|
1019
|
+
const result = await requestJson('/api/ai-hub/check-agent', {
|
|
1020
|
+
method: 'POST',
|
|
1021
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1022
|
+
body: JSON.stringify({ hubId }),
|
|
1023
|
+
});
|
|
1024
|
+
if (result.ready) {
|
|
1025
|
+
setInstallState(hubId, 'ready', `${result.message}`);
|
|
1026
|
+
await refreshEmployees();
|
|
1027
|
+
} else {
|
|
1028
|
+
setInstallState(hubId, 'login-triggered', result.message || 'Not ready yet — complete sign-in and try again.');
|
|
1029
|
+
}
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
setInstallState(hubId, 'error', err.message || 'Check failed.');
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function refreshEmployees() {
|
|
1036
|
+
try {
|
|
1037
|
+
const bootstrap = await requestJson(`/api/ai-hub/bootstrap?projectPath=${encodeURIComponent(state.projectPath)}`);
|
|
1038
|
+
state.bootstrap = bootstrap;
|
|
1039
|
+
renderEmployeeSelect();
|
|
1040
|
+
} catch { /* best-effort */ }
|
|
894
1041
|
}
|
|
895
1042
|
|
|
896
1043
|
// ---------------------------------------------------------------------------
|
|
@@ -937,6 +1084,7 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
937
1084
|
const FRAIM_INVOCATION_SYMBOL = {
|
|
938
1085
|
codex: '$fraim',
|
|
939
1086
|
claude: '/fraim',
|
|
1087
|
+
gemini: '/fraim',
|
|
940
1088
|
};
|
|
941
1089
|
|
|
942
1090
|
function fraimInvocationFor(employeeId, jobId, kind) {
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -662,6 +662,27 @@ button { font: inherit; cursor: pointer; }
|
|
|
662
662
|
}
|
|
663
663
|
.employee-select:focus { outline: none; border-color: var(--accent); }
|
|
664
664
|
|
|
665
|
+
/* Agent install panel */
|
|
666
|
+
#agent-install-panel { margin-top: 10px; }
|
|
667
|
+
.install-panel-heading {
|
|
668
|
+
font-size: 12px;
|
|
669
|
+
font-weight: 600;
|
|
670
|
+
color: var(--muted);
|
|
671
|
+
text-transform: uppercase;
|
|
672
|
+
letter-spacing: .04em;
|
|
673
|
+
margin-bottom: 6px;
|
|
674
|
+
}
|
|
675
|
+
.install-row {
|
|
676
|
+
display: flex;
|
|
677
|
+
align-items: center;
|
|
678
|
+
gap: 8px;
|
|
679
|
+
padding: 6px 0;
|
|
680
|
+
font-size: 13px;
|
|
681
|
+
}
|
|
682
|
+
.install-label { font-weight: 500; min-width: 90px; }
|
|
683
|
+
.install-status { color: var(--muted); flex: 1; font-size: 12px; }
|
|
684
|
+
button.small { padding: 4px 10px; font-size: 12px; }
|
|
685
|
+
|
|
665
686
|
@media (max-width: 820px) {
|
|
666
687
|
/* Single-column reflow — the rigid 100vh layout doesn't make sense at
|
|
667
688
|
mobile width because the rail stacks above the conversation. Let the
|