fraim-framework 2.0.162 → 2.0.163
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 +93 -1
- package/dist/src/ai-hub/preferences.js +1 -1
- package/dist/src/ai-hub/server.js +1 -0
- package/dist/src/cli/doctor/check-runner.js +3 -1
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +261 -2
- package/dist/src/first-run/types.js +8 -0
- package/dist/src/local-mcp-server/agent-token-prices.js +30 -0
- package/package.json +2 -2
- package/public/ai-hub/index.html +3 -3
- package/public/ai-hub/script.js +184 -117
- package/public/ai-hub/styles.css +33 -0
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -319,7 +319,13 @@ const EMPLOYEE_LABELS = {
|
|
|
319
319
|
codex: 'Codex',
|
|
320
320
|
claude: 'Claude Code',
|
|
321
321
|
gemini: 'Gemini CLI',
|
|
322
|
+
copilot: 'GitHub Copilot CLI',
|
|
322
323
|
};
|
|
324
|
+
// GitHub Copilot CLI binary name after `npm install -g @github/copilot`.
|
|
325
|
+
// The @github/copilot package installs a binary named `copilot` on PATH.
|
|
326
|
+
// Note: the package name is @github/copilot (NOT @github/copilot-cli which
|
|
327
|
+
// does not exist on npm). The binary is `copilot` (NOT `github-copilot-cli`).
|
|
328
|
+
const COPILOT_BINARY = 'copilot';
|
|
323
329
|
const executableName = (command) => command;
|
|
324
330
|
function quoteWindowsArg(value) {
|
|
325
331
|
if (value.length === 0) {
|
|
@@ -348,9 +354,15 @@ const availableByVersionProbe = (command) => {
|
|
|
348
354
|
});
|
|
349
355
|
return result.status === 0;
|
|
350
356
|
};
|
|
357
|
+
// Resolve the binary name for each agent tool.
|
|
358
|
+
function agentBinaryName(id) {
|
|
359
|
+
if (id === 'copilot')
|
|
360
|
+
return COPILOT_BINARY;
|
|
361
|
+
return executableName(id);
|
|
362
|
+
}
|
|
351
363
|
function detectEmployees() {
|
|
352
364
|
return Object.keys(EMPLOYEE_LABELS).map((id) => {
|
|
353
|
-
const available = availableByVersionProbe(
|
|
365
|
+
const available = availableByVersionProbe(agentBinaryName(id));
|
|
354
366
|
return {
|
|
355
367
|
id,
|
|
356
368
|
label: EMPLOYEE_LABELS[id],
|
|
@@ -560,6 +572,15 @@ function sharedBrowserHostConfig(hostId, env = process.env) {
|
|
|
560
572
|
}
|
|
561
573
|
return { args: [], env: { GEMINI_CLI_SYSTEM_SETTINGS_PATH: file } };
|
|
562
574
|
}
|
|
575
|
+
if (hostId === 'copilot') {
|
|
576
|
+
// GitHub Copilot CLI does not yet publish a documented per-invocation
|
|
577
|
+
// settings-file env var analogous to GEMINI_CLI_SYSTEM_SETTINGS_PATH.
|
|
578
|
+
// If one is discovered in a future release, write the ephemeral file here
|
|
579
|
+
// and return { args: [], env: { <COPILOT_SETTINGS_ENV_VAR>: file } }.
|
|
580
|
+
// Until then, return the Option-B no-op per spec R5.2 — the Hub's
|
|
581
|
+
// start-payload builder will inject a browser-guidance note instead.
|
|
582
|
+
return { args: [] };
|
|
583
|
+
}
|
|
563
584
|
return { args: [] };
|
|
564
585
|
}
|
|
565
586
|
function buildStartPlan(hostId, message, sessionId) {
|
|
@@ -586,6 +607,22 @@ function buildStartPlan(hostId, message, sessionId) {
|
|
|
586
607
|
env: browser.env,
|
|
587
608
|
};
|
|
588
609
|
}
|
|
610
|
+
if (hostId === 'copilot') {
|
|
611
|
+
// GitHub Copilot CLI headless invocation.
|
|
612
|
+
// --yolo auto-approves all tool permissions (analogous to
|
|
613
|
+
// --dangerously-skip-permissions for Claude Code). The task is provided
|
|
614
|
+
// via stdin; -p/--prompt requires inline text which is cumbersome for
|
|
615
|
+
// multi-line FRAIM instructions. The session id is self-assigned by the
|
|
616
|
+
// binary on first run; Hub captures it from the stream output
|
|
617
|
+
// (parseHostLine 'copilot' branch).
|
|
618
|
+
const browser = sharedBrowserHostConfig('copilot');
|
|
619
|
+
return {
|
|
620
|
+
command: COPILOT_BINARY,
|
|
621
|
+
args: ['--yolo', ...browser.args],
|
|
622
|
+
stdin: transformHeadlessFraimMessage(message, 'start'),
|
|
623
|
+
env: browser.env,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
589
626
|
const browser = sharedBrowserHostConfig('claude');
|
|
590
627
|
return {
|
|
591
628
|
command: executableName('claude'),
|
|
@@ -615,6 +652,17 @@ function buildContinuePlan(hostId, sessionId, message) {
|
|
|
615
652
|
env: browser.env,
|
|
616
653
|
};
|
|
617
654
|
}
|
|
655
|
+
if (hostId === 'copilot') {
|
|
656
|
+
// Resume an existing GitHub Copilot CLI session.
|
|
657
|
+
// --resume <sessionId> accepts the session id returned on the first run.
|
|
658
|
+
const browser = sharedBrowserHostConfig('copilot');
|
|
659
|
+
return {
|
|
660
|
+
command: COPILOT_BINARY,
|
|
661
|
+
args: ['--yolo', '--resume', sessionId, ...browser.args],
|
|
662
|
+
stdin: transformHeadlessFraimMessage(message, 'continue'),
|
|
663
|
+
env: browser.env,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
618
666
|
const browser = sharedBrowserHostConfig('claude');
|
|
619
667
|
return {
|
|
620
668
|
command: executableName('claude'),
|
|
@@ -666,6 +714,14 @@ function buildDirectStartPlan(hostId, message, sessionId) {
|
|
|
666
714
|
stdin: DIRECT_PREAMBLE + message,
|
|
667
715
|
};
|
|
668
716
|
}
|
|
717
|
+
if (hostId === 'copilot') {
|
|
718
|
+
// Direct (A/B) mode for Copilot: headless, no FRAIM MCP wiring.
|
|
719
|
+
return {
|
|
720
|
+
command: COPILOT_BINARY,
|
|
721
|
+
args: ['--yolo'],
|
|
722
|
+
stdin: DIRECT_PREAMBLE + message,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
669
725
|
return {
|
|
670
726
|
command: executableName('claude'),
|
|
671
727
|
args: [
|
|
@@ -696,6 +752,14 @@ function buildDirectContinuePlan(hostId, sessionId, message) {
|
|
|
696
752
|
stdin: DIRECT_PREAMBLE + message,
|
|
697
753
|
};
|
|
698
754
|
}
|
|
755
|
+
if (hostId === 'copilot') {
|
|
756
|
+
// Direct continue mode for Copilot: resume session, no FRAIM MCP wiring.
|
|
757
|
+
return {
|
|
758
|
+
command: COPILOT_BINARY,
|
|
759
|
+
args: ['--yolo', '--resume', sessionId],
|
|
760
|
+
stdin: DIRECT_PREAMBLE + message,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
699
763
|
return {
|
|
700
764
|
command: executableName('claude'),
|
|
701
765
|
args: [
|
|
@@ -764,6 +828,32 @@ function parseHostLine(hostId, line) {
|
|
|
764
828
|
return withSignal({ message: trimmed, raw: trimmed });
|
|
765
829
|
}
|
|
766
830
|
}
|
|
831
|
+
// GitHub Copilot CLI output: JSON stream where each event carries a `type`
|
|
832
|
+
// field. Known event shapes (from the agentic CLI stream):
|
|
833
|
+
// { "type": "session.started", "session_id": "..." } — session id
|
|
834
|
+
// { "type": "message", "role": "assistant", "content": "..." } — reply text
|
|
835
|
+
// { "type": "turn.completed", "usage": { ... } } — token usage (same shape as Codex)
|
|
836
|
+
// For any JSON event not matching the above, signal scanning (seekMentoring,
|
|
837
|
+
// agent identity) still runs because withSignal is applied to every parsed result.
|
|
838
|
+
// Non-JSON lines from Copilot are treated as plain-text employee messages.
|
|
839
|
+
if (hostId === 'copilot') {
|
|
840
|
+
try {
|
|
841
|
+
const parsed = JSON.parse(trimmed);
|
|
842
|
+
if (parsed.type === 'session.started' && typeof parsed.session_id === 'string' && parsed.session_id.length > 0) {
|
|
843
|
+
return withSignal({ sessionId: parsed.session_id, raw: trimmed });
|
|
844
|
+
}
|
|
845
|
+
if (parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string') {
|
|
846
|
+
return withSignal({ message: parsed.content, raw: trimmed });
|
|
847
|
+
}
|
|
848
|
+
// All other JSON events: apply signal scanning and surface as raw.
|
|
849
|
+
return withSignal({ raw: trimmed });
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
// Non-JSON line from Copilot: treat as a plain-text employee message,
|
|
853
|
+
// same pattern as Gemini CLI's non-JSON output.
|
|
854
|
+
return withSignal({ message: trimmed, raw: trimmed });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
767
857
|
try {
|
|
768
858
|
const parsed = JSON.parse(trimmed);
|
|
769
859
|
if (parsed.type === 'system' && parsed.session_id) {
|
|
@@ -950,6 +1040,7 @@ class FakeHostRuntime {
|
|
|
950
1040
|
{ id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
|
|
951
1041
|
{ id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
|
|
952
1042
|
{ id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
|
|
1043
|
+
{ id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true },
|
|
953
1044
|
];
|
|
954
1045
|
}
|
|
955
1046
|
detectEmployees() {
|
|
@@ -1032,6 +1123,7 @@ class ScriptedHostRuntime {
|
|
|
1032
1123
|
{ id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1033
1124
|
{ id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1034
1125
|
{ id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1126
|
+
{ id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
|
|
1035
1127
|
];
|
|
1036
1128
|
// Track each active run so the test can emit signals at it. Key is the
|
|
1037
1129
|
// sessionId we hand back on startRun; mapping sessionId → handlers
|
|
@@ -29,7 +29,7 @@ class AiHubPreferencesStore {
|
|
|
29
29
|
const raw = JSON.parse(fs_1.default.readFileSync(this.stateFilePath, 'utf8'));
|
|
30
30
|
return {
|
|
31
31
|
projectPath: raw.projectPath || projectPath,
|
|
32
|
-
employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex') ? raw.employeeId : DEFAULT_EMPLOYEE,
|
|
32
|
+
employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot') ? raw.employeeId : DEFAULT_EMPLOYEE,
|
|
33
33
|
categoryId: typeof raw.categoryId === 'string' && raw.categoryId.length > 0 ? raw.categoryId : DEFAULT_CATEGORY,
|
|
34
34
|
recentJobIds: Array.isArray(raw.recentJobIds) ? raw.recentJobIds.filter((value) => typeof value === 'string') : [],
|
|
35
35
|
recentJobInstructions: (typeof raw.recentJobInstructions === 'object' && raw.recentJobInstructions !== null && !Array.isArray(raw.recentJobInstructions))
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.runChecks = runChecks;
|
|
9
9
|
const CHECK_TIMEOUT = 2000; // 2 seconds per check
|
|
10
|
-
|
|
10
|
+
// Issue #532: stdio MCP servers need up to 15s for handshake + npm version resolution
|
|
11
|
+
// on first call via fraim-mcp-latest-launcher; 20s gives a 5s buffer.
|
|
12
|
+
const MCP_CHECK_TIMEOUT = 20000; // 20 seconds for MCP connectivity checks
|
|
11
13
|
const TOTAL_TIMEOUT = 30000; // 30 seconds total
|
|
12
14
|
// Simple logger for doctor command (optional, falls back to no-op)
|
|
13
15
|
const logger = {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* MCP connectivity checks for FRAIM doctor command
|
|
4
4
|
* Tests FRAIM server connectivity and validates IDE MCP configurations
|
|
5
5
|
* Issue #144: Enhanced doctor command
|
|
6
|
+
* Issue #532: Stdio MCP runtime connectivity check
|
|
6
7
|
*/
|
|
7
8
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
9
|
if (k2 === undefined) k2 = k;
|
|
@@ -43,6 +44,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
43
44
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
45
|
exports.getNpmMajorVersion = getNpmMajorVersion;
|
|
45
46
|
exports.diagnoseFraimMcpLaunchPlan = diagnoseFraimMcpLaunchPlan;
|
|
47
|
+
exports.spawnAndHandshake = spawnAndHandshake;
|
|
48
|
+
exports.getStdioMCPRuntimeChecks = getStdioMCPRuntimeChecks;
|
|
46
49
|
exports.getMCPConnectivityChecks = getMCPConnectivityChecks;
|
|
47
50
|
const fs_1 = __importDefault(require("fs"));
|
|
48
51
|
const path_1 = __importDefault(require("path"));
|
|
@@ -52,7 +55,16 @@ const axios_1 = __importDefault(require("axios"));
|
|
|
52
55
|
const toml = __importStar(require("toml"));
|
|
53
56
|
const ide_detector_1 = require("../../setup/ide-detector");
|
|
54
57
|
const fraim_mcp_latest_launcher_1 = require("../../mcp/fraim-mcp-latest-launcher");
|
|
58
|
+
const command_resolution_1 = require("../../mcp/command-resolution");
|
|
59
|
+
// Cache the npm major version so execFileSync is called at most once per process.
|
|
60
|
+
// Without caching, each IDE config check calls diagnoseFraimMcpLaunchPlan which calls
|
|
61
|
+
// getNpmMajorVersion, and execFileSync blocks the event loop for ~2s on Windows.
|
|
62
|
+
// With 8+ IDE checks running via Promise.all the blocking stacks sequentially.
|
|
63
|
+
let _npmMajorVersionCache = undefined;
|
|
55
64
|
function getNpmMajorVersion() {
|
|
65
|
+
if (_npmMajorVersionCache !== undefined) {
|
|
66
|
+
return _npmMajorVersionCache;
|
|
67
|
+
}
|
|
56
68
|
try {
|
|
57
69
|
const command = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'npm';
|
|
58
70
|
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'npm --version'] : ['--version'];
|
|
@@ -61,11 +73,12 @@ function getNpmMajorVersion() {
|
|
|
61
73
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
62
74
|
});
|
|
63
75
|
const major = Number.parseInt(output.trim().split('.')[0], 10);
|
|
64
|
-
|
|
76
|
+
_npmMajorVersionCache = Number.isFinite(major) ? major : null;
|
|
65
77
|
}
|
|
66
78
|
catch {
|
|
67
|
-
|
|
79
|
+
_npmMajorVersionCache = null;
|
|
68
80
|
}
|
|
81
|
+
return _npmMajorVersionCache;
|
|
69
82
|
}
|
|
70
83
|
function diagnoseFraimMcpLaunchPlan(fraimServer, platform = process.platform, npmMajorVersion = getNpmMajorVersion()) {
|
|
71
84
|
const command = String(fraimServer?.command || '');
|
|
@@ -419,6 +432,248 @@ async function validateIDEMCPConfig(ide) {
|
|
|
419
432
|
};
|
|
420
433
|
}
|
|
421
434
|
}
|
|
435
|
+
// ============================================================
|
|
436
|
+
// Issue #532: Stdio MCP Runtime Connectivity Check
|
|
437
|
+
// ============================================================
|
|
438
|
+
const STDIO_HANDSHAKE_TIMEOUT_MS = 15000;
|
|
439
|
+
/**
|
|
440
|
+
* Spawn a stdio MCP server, perform a two-phase JSON-RPC handshake
|
|
441
|
+
* (initialize then tools/list), and return a structured result.
|
|
442
|
+
*
|
|
443
|
+
* On Windows, wraps the command in cmd.exe /d /s /c so that .cmd wrappers
|
|
444
|
+
* resolve correctly. The same pattern is used by resolveManagedCommand and
|
|
445
|
+
* fraim-mcp-latest-launcher.
|
|
446
|
+
*/
|
|
447
|
+
async function spawnAndHandshake(command, args, timeoutMs = STDIO_HANDSHAKE_TIMEOUT_MS) {
|
|
448
|
+
return new Promise((resolve) => {
|
|
449
|
+
const startTime = Date.now();
|
|
450
|
+
let spawnCommand;
|
|
451
|
+
let spawnArgs;
|
|
452
|
+
// spawnOptions is typed loosely so we can set windowsVerbatimArguments below.
|
|
453
|
+
let spawnOptions = {
|
|
454
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
455
|
+
env: process.env
|
|
456
|
+
};
|
|
457
|
+
if (process.platform === 'win32') {
|
|
458
|
+
// On Windows, wrap in cmd.exe so .cmd wrappers are resolved.
|
|
459
|
+
// cmd.exe /d /s /c requires special handling when the command path contains spaces:
|
|
460
|
+
// cmd /c ""path with spaces" arg1 arg2"
|
|
461
|
+
// The outer pair of double-quotes is mandatory when the first token is quoted.
|
|
462
|
+
// We must also pass windowsVerbatimArguments: true so Node's CreateProcess call
|
|
463
|
+
// does not re-escape our already-quoted command string.
|
|
464
|
+
const comSpec = process.env.ComSpec || 'cmd.exe';
|
|
465
|
+
const quotedTokens = [command, ...args].map((a) => {
|
|
466
|
+
const v = String(a);
|
|
467
|
+
return /[\s"&|<>^]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
468
|
+
});
|
|
469
|
+
const innerCmd = quotedTokens.join(' ');
|
|
470
|
+
// Wrap in outer quotes only when the command contains spaces (i.e. was itself quoted).
|
|
471
|
+
const needsOuterQuote = /[\s"&|<>^]/.test(String(command));
|
|
472
|
+
const cmdString = needsOuterQuote ? `"${innerCmd}"` : innerCmd;
|
|
473
|
+
spawnCommand = comSpec;
|
|
474
|
+
spawnArgs = ['/d', '/s', '/c', cmdString];
|
|
475
|
+
spawnOptions = { ...spawnOptions, windowsVerbatimArguments: true };
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
spawnCommand = command;
|
|
479
|
+
spawnArgs = args;
|
|
480
|
+
}
|
|
481
|
+
let child;
|
|
482
|
+
try {
|
|
483
|
+
child = (0, child_process_1.spawn)(spawnCommand, spawnArgs, spawnOptions);
|
|
484
|
+
}
|
|
485
|
+
catch (spawnErr) {
|
|
486
|
+
resolve({
|
|
487
|
+
status: 'error',
|
|
488
|
+
phase: 'spawn',
|
|
489
|
+
message: `Failed to spawn MCP server: ${spawnErr.message}`,
|
|
490
|
+
suggestion: 'Verify that npx is installed and accessible on PATH.',
|
|
491
|
+
details: { command, args, error: spawnErr.message }
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const timer = setTimeout(() => {
|
|
496
|
+
try {
|
|
497
|
+
child.kill('SIGKILL');
|
|
498
|
+
}
|
|
499
|
+
catch { /* best effort */ }
|
|
500
|
+
resolve({
|
|
501
|
+
status: 'error',
|
|
502
|
+
phase: 'initialize',
|
|
503
|
+
message: `MCP server did not respond within ${timeoutMs}ms`,
|
|
504
|
+
suggestion: 'The server may be slow to start. Check that the package is installed and npx cache is warm.',
|
|
505
|
+
details: { command, args, timeoutMs, elapsed: Date.now() - startTime }
|
|
506
|
+
});
|
|
507
|
+
}, timeoutMs);
|
|
508
|
+
let stderrBuffer = '';
|
|
509
|
+
let stdoutBuffer = '';
|
|
510
|
+
let spawnError = null;
|
|
511
|
+
child.stderr?.on('data', (d) => {
|
|
512
|
+
stderrBuffer += d.toString();
|
|
513
|
+
});
|
|
514
|
+
child.on('error', (err) => {
|
|
515
|
+
spawnError = err;
|
|
516
|
+
});
|
|
517
|
+
child.stdout?.on('data', (d) => {
|
|
518
|
+
stdoutBuffer += d.toString();
|
|
519
|
+
});
|
|
520
|
+
child.on('close', (code) => {
|
|
521
|
+
clearTimeout(timer);
|
|
522
|
+
if (spawnError) {
|
|
523
|
+
const msg = spawnError.message || '';
|
|
524
|
+
const isEnoent = msg.includes('ENOENT') || msg.includes('not found');
|
|
525
|
+
resolve({
|
|
526
|
+
status: 'error',
|
|
527
|
+
phase: 'spawn',
|
|
528
|
+
message: `Failed to start MCP server: ${msg}`,
|
|
529
|
+
suggestion: isEnoent
|
|
530
|
+
? 'Verify that npx is installed. Run: npm install -g npm to update npm/npx.'
|
|
531
|
+
: 'Check that the required package can be installed via npx.',
|
|
532
|
+
details: { command, args, error: msg }
|
|
533
|
+
});
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (code !== 0 && stdoutBuffer.trim() === '') {
|
|
537
|
+
resolve({
|
|
538
|
+
status: 'error',
|
|
539
|
+
phase: 'spawn',
|
|
540
|
+
message: `MCP server exited with code ${code} before responding`,
|
|
541
|
+
suggestion: 'Run: npx -y <package> manually to check for installation errors.',
|
|
542
|
+
details: { command, args, exitCode: code, stderr: stderrBuffer.slice(0, 500) }
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Parse all JSON-RPC messages received from stdout
|
|
547
|
+
const lines = stdoutBuffer.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
548
|
+
let initializeResponse = null;
|
|
549
|
+
let toolsListResponse = null;
|
|
550
|
+
for (const line of lines) {
|
|
551
|
+
try {
|
|
552
|
+
const msg = JSON.parse(line);
|
|
553
|
+
if (msg?.id === 1 && msg?.result !== undefined) {
|
|
554
|
+
initializeResponse = msg;
|
|
555
|
+
}
|
|
556
|
+
if (msg?.id === 2 && msg?.result !== undefined) {
|
|
557
|
+
toolsListResponse = msg;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// Ignore non-JSON lines (e.g., startup log output)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (!initializeResponse) {
|
|
565
|
+
resolve({
|
|
566
|
+
status: 'error',
|
|
567
|
+
phase: 'initialize',
|
|
568
|
+
message: 'MCP server did not return a valid initialize response',
|
|
569
|
+
suggestion: 'The server may have crashed during startup. Check stderr for error details.',
|
|
570
|
+
details: { command, args, exitCode: code, stderr: stderrBuffer.slice(0, 500) }
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (!toolsListResponse) {
|
|
575
|
+
resolve({
|
|
576
|
+
status: 'error',
|
|
577
|
+
phase: 'tools-list',
|
|
578
|
+
message: 'MCP server did not respond to tools/list',
|
|
579
|
+
suggestion: 'The server initialized but did not expose any tools. Check the package documentation.',
|
|
580
|
+
details: { command, args, exitCode: code }
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const toolCount = Array.isArray(toolsListResponse.result?.tools)
|
|
585
|
+
? toolsListResponse.result.tools.length
|
|
586
|
+
: 0;
|
|
587
|
+
const elapsed = Date.now() - startTime;
|
|
588
|
+
resolve({
|
|
589
|
+
status: 'passed',
|
|
590
|
+
message: `MCP server responded with ${toolCount} tool(s) in ${elapsed}ms`,
|
|
591
|
+
details: { command, args, toolCount, elapsed }
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
// Send two-phase JSON-RPC handshake
|
|
595
|
+
try {
|
|
596
|
+
const initMsg = JSON.stringify({
|
|
597
|
+
jsonrpc: '2.0',
|
|
598
|
+
id: 1,
|
|
599
|
+
method: 'initialize',
|
|
600
|
+
params: {
|
|
601
|
+
protocolVersion: '2024-11-05',
|
|
602
|
+
capabilities: {},
|
|
603
|
+
clientInfo: { name: 'fraim-doctor', version: '1.0' }
|
|
604
|
+
}
|
|
605
|
+
}) + '\n';
|
|
606
|
+
child.stdin?.write(initMsg);
|
|
607
|
+
const toolsMsg = JSON.stringify({
|
|
608
|
+
jsonrpc: '2.0',
|
|
609
|
+
id: 2,
|
|
610
|
+
method: 'tools/list',
|
|
611
|
+
params: {}
|
|
612
|
+
}) + '\n';
|
|
613
|
+
child.stdin?.write(toolsMsg);
|
|
614
|
+
child.stdin?.end();
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
// stdin may not be writable if the process exited immediately
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Run a connectivity check for a single named stdio MCP server.
|
|
623
|
+
*/
|
|
624
|
+
async function testStdioMCPServer(serverName, command, args) {
|
|
625
|
+
if (process.env.NODE_ENV === 'test') {
|
|
626
|
+
return {
|
|
627
|
+
status: 'warning',
|
|
628
|
+
message: `Stdio MCP runtime check for "${serverName}" skipped (test mode)`,
|
|
629
|
+
details: { testMode: true, skipped: true, serverName }
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
const result = await spawnAndHandshake(command, args, STDIO_HANDSHAKE_TIMEOUT_MS);
|
|
633
|
+
if (result.status === 'passed') {
|
|
634
|
+
return {
|
|
635
|
+
status: 'passed',
|
|
636
|
+
message: `${serverName} stdio MCP: ${result.message}`,
|
|
637
|
+
details: result.details
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
status: 'error',
|
|
642
|
+
message: `${serverName} stdio MCP connectivity failed (phase: ${result.phase}): ${result.message}`,
|
|
643
|
+
suggestion: result.suggestion,
|
|
644
|
+
details: { ...result.details, phase: result.phase }
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Build the 3 stdio MCP runtime checks (git, playwright, fraim).
|
|
649
|
+
* Exported separately so callers can obtain just the runtime checks if needed.
|
|
650
|
+
*/
|
|
651
|
+
function getStdioMCPRuntimeChecks() {
|
|
652
|
+
const npx = (0, command_resolution_1.resolveManagedCommand)('npx');
|
|
653
|
+
// For the fraim MCP server we use the latest launcher so the test
|
|
654
|
+
// exercises the same path that IDE configs use.
|
|
655
|
+
const fraimLauncherPath = (0, fraim_mcp_latest_launcher_1.getFraimMcpLatestLauncherPath)();
|
|
656
|
+
return [
|
|
657
|
+
{
|
|
658
|
+
name: 'git stdio runtime check',
|
|
659
|
+
category: 'mcpConnectivity',
|
|
660
|
+
critical: false,
|
|
661
|
+
run: () => testStdioMCPServer('git', npx, ['-y', '@cyanheads/git-mcp-server'])
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
name: 'playwright stdio runtime check',
|
|
665
|
+
category: 'mcpConnectivity',
|
|
666
|
+
critical: false,
|
|
667
|
+
run: () => testStdioMCPServer('playwright', npx, ['-y', '@playwright/mcp'])
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
name: 'fraim stdio runtime check',
|
|
671
|
+
category: 'mcpConnectivity',
|
|
672
|
+
critical: false,
|
|
673
|
+
run: () => testStdioMCPServer('fraim', process.execPath, [fraimLauncherPath])
|
|
674
|
+
}
|
|
675
|
+
];
|
|
676
|
+
}
|
|
422
677
|
/**
|
|
423
678
|
* Get all MCP connectivity checks
|
|
424
679
|
*/
|
|
@@ -441,5 +696,9 @@ function getMCPConnectivityChecks() {
|
|
|
441
696
|
run: async () => validateIDEMCPConfig(ide)
|
|
442
697
|
});
|
|
443
698
|
}
|
|
699
|
+
// Check 3 (Issue #532): Confirm stdio MCP servers respond at runtime.
|
|
700
|
+
// "Disconnected" in mcp list is expected on-demand-spawn behavior. These checks
|
|
701
|
+
// verify that each server can actually start and complete a JSON-RPC handshake.
|
|
702
|
+
checks.push(...getStdioMCPRuntimeChecks());
|
|
444
703
|
return checks;
|
|
445
704
|
}
|
|
@@ -50,6 +50,14 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
|
|
|
50
50
|
launchCommand: 'gemini',
|
|
51
51
|
installPackage: '@google/gemini-cli',
|
|
52
52
|
},
|
|
53
|
+
{
|
|
54
|
+
id: 'copilot-cli',
|
|
55
|
+
label: 'GitHub Copilot',
|
|
56
|
+
detectAliases: ['copilot'],
|
|
57
|
+
loginCommand: 'copilot login',
|
|
58
|
+
launchCommand: 'copilot',
|
|
59
|
+
installPackage: '@github/copilot',
|
|
60
|
+
},
|
|
53
61
|
];
|
|
54
62
|
/**
|
|
55
63
|
* The canonical row set, in display order. Each row starts in `pending`;
|
|
@@ -109,6 +109,36 @@ exports.AGENT_TOKEN_PRICES = [
|
|
|
109
109
|
source: 'https://ai.google.dev/gemini-api/docs/pricing',
|
|
110
110
|
verifiedOn: '2026-06-02',
|
|
111
111
|
},
|
|
112
|
+
// GitHub Copilot CLI. The CLI's agentic mode uses GPT-4.1 (also branded
|
|
113
|
+
// "gpt-4.1" in the Copilot API stream). GitHub charges for Copilot via
|
|
114
|
+
// premium-request quota rather than per-token billing on most plans; the
|
|
115
|
+
// per-token rates below apply to the Copilot API / enterprise API billing
|
|
116
|
+
// path (pay-per-token, not included-requests) as published at
|
|
117
|
+
// https://docs.github.com/en/copilot/about-github-copilot/plans-for-github-copilot
|
|
118
|
+
// and the underlying OpenAI model pricing for gpt-4.1 at openai.com/api/pricing/.
|
|
119
|
+
// Coverage is 'partial' for Copilot runs where the CLI does not emit a cost
|
|
120
|
+
// field — the table backstops cost computation when a token count is present
|
|
121
|
+
// but no direct costUsd is in the stream.
|
|
122
|
+
{
|
|
123
|
+
agent: 'copilot',
|
|
124
|
+
model: 'gpt-4.1',
|
|
125
|
+
inputPerMTok: 2.00,
|
|
126
|
+
outputPerMTok: 8.00,
|
|
127
|
+
cacheReadPerMTok: 0.50,
|
|
128
|
+
cacheCreationPerMTok: 0,
|
|
129
|
+
source: 'https://openai.com/api/pricing/ (gpt-4.1 row; GitHub Copilot CLI agentic mode)',
|
|
130
|
+
verifiedOn: '2026-06-09',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
agent: 'copilot',
|
|
134
|
+
model: 'gpt-4o',
|
|
135
|
+
inputPerMTok: 2.50,
|
|
136
|
+
outputPerMTok: 10.00,
|
|
137
|
+
cacheReadPerMTok: 1.25,
|
|
138
|
+
cacheCreationPerMTok: 0,
|
|
139
|
+
source: 'https://openai.com/api/pricing/ (gpt-4o row; GitHub Copilot CLI fallback model)',
|
|
140
|
+
verifiedOn: '2026-06-09',
|
|
141
|
+
},
|
|
112
142
|
];
|
|
113
143
|
/**
|
|
114
144
|
* Look up the price entry for an agent + model. Agent is matched
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.163",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"build:fraim-brain": "node scripts/generate-fraim-brain.js",
|
|
16
16
|
"test-all": "npm run test && npm run test:isolated tests/isolated/test-*.ts && npm run test:ui",
|
|
17
17
|
"test": "node scripts/test-with-server.js",
|
|
18
|
-
"test:isolated": "
|
|
18
|
+
"test:isolated": "node scripts/test-isolated.js",
|
|
19
19
|
"test:smoke": "node scripts/test-with-server.js --tags=smoke",
|
|
20
20
|
"test:coverage": "node scripts/test-with-server.js --tags=smoke --coverage",
|
|
21
21
|
"test:stripe": "node scripts/test-with-server.js tests/test-stripe-payment-complete.ts",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -329,7 +329,7 @@
|
|
|
329
329
|
</span>
|
|
330
330
|
<span class="active-employee-row" onclick="event.stopPropagation()">
|
|
331
331
|
<span class="active-employee-label">Maestro</span>
|
|
332
|
-
<select id="active-employee-select" class="employee-select inline" aria-label="
|
|
332
|
+
<select id="active-employee-select" class="employee-select inline" aria-label="Agent Tool"></select>
|
|
333
333
|
</span>
|
|
334
334
|
</summary>
|
|
335
335
|
<div class="panel-body">
|
|
@@ -476,8 +476,8 @@
|
|
|
476
476
|
<div class="word-ctx-card-body" id="word-ctx-card-body" hidden></div>
|
|
477
477
|
</div>
|
|
478
478
|
<div class="employee-line">
|
|
479
|
-
<span class="employee-label">
|
|
480
|
-
<select id="employee-select" class="employee-select"></select>
|
|
479
|
+
<span class="employee-label">Agent Tool:</span>
|
|
480
|
+
<select id="employee-select" class="employee-select" aria-label="Agent Tool"></select>
|
|
481
481
|
</div>
|
|
482
482
|
<div id="agent-install-panel"></div>
|
|
483
483
|
<div id="ab-toggle-wrap" hidden>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -9,8 +9,6 @@
|
|
|
9
9
|
// conversation maps to one job and (when active) one backend run id. The
|
|
10
10
|
// existing single-active-run backend stays untouched per spec R15.
|
|
11
11
|
|
|
12
|
-
const STORAGE_KEY_CONVERSATIONS = 'fraim.aiHub.conversations.v1';
|
|
13
|
-
const STORAGE_KEY_ACTIVE = 'fraim.aiHub.activeConversation.v1';
|
|
14
12
|
const STORAGE_KEY_TREE_WIDTH = 'fraim.aiHub.treeSidebarWidth.v1';
|
|
15
13
|
const STORAGE_KEY_TREE_COLLAPSED = 'fraim.aiHub.treeSidebarCollapsed.v1';
|
|
16
14
|
const TREE_WIDTH_MIN = 176;
|
|
@@ -386,35 +384,6 @@ function normalizeGeminiConversationMessages(conv) {
|
|
|
386
384
|
return changed;
|
|
387
385
|
}
|
|
388
386
|
|
|
389
|
-
function loadConversationsFromStorage() {
|
|
390
|
-
try {
|
|
391
|
-
const raw = window.localStorage.getItem(STORAGE_KEY_CONVERSATIONS);
|
|
392
|
-
state.conversations = raw ? JSON.parse(raw) : {};
|
|
393
|
-
} catch {
|
|
394
|
-
state.conversations = {};
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
state.activeId = window.localStorage.getItem(STORAGE_KEY_ACTIVE) || null;
|
|
398
|
-
} catch {
|
|
399
|
-
state.activeId = null;
|
|
400
|
-
}
|
|
401
|
-
// Retroactively fix titles that were saved as the generic "New job" fallback
|
|
402
|
-
// by re-deriving from the first manager message.
|
|
403
|
-
let normalizedLegacyConversations = false;
|
|
404
|
-
for (const convList of Object.values(state.conversations)) {
|
|
405
|
-
for (const conv of convList) {
|
|
406
|
-
normalizedLegacyConversations = normalizeGeminiConversationMessages(conv) || normalizedLegacyConversations;
|
|
407
|
-
if (conv.title === 'New job') {
|
|
408
|
-
const firstMsg = (conv.messages || []).find((m) => m.role === 'manager');
|
|
409
|
-
if (!firstMsg) continue;
|
|
410
|
-
const rederived = deriveTitle(conv.jobTitle || '', firstMsg.text || '');
|
|
411
|
-
if (rederived !== 'New job') conv.title = rederived;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
if (normalizedLegacyConversations) persistConversations();
|
|
416
|
-
}
|
|
417
|
-
|
|
418
387
|
function projectConversationPayload() {
|
|
419
388
|
return {
|
|
420
389
|
projectPath: state.projectPath || '',
|
|
@@ -423,19 +392,6 @@ function projectConversationPayload() {
|
|
|
423
392
|
};
|
|
424
393
|
}
|
|
425
394
|
|
|
426
|
-
function persistConversationsToCache() {
|
|
427
|
-
try {
|
|
428
|
-
window.localStorage.setItem(STORAGE_KEY_CONVERSATIONS, JSON.stringify(state.conversations));
|
|
429
|
-
if (state.activeId) {
|
|
430
|
-
window.localStorage.setItem(STORAGE_KEY_ACTIVE, state.activeId);
|
|
431
|
-
} else {
|
|
432
|
-
window.localStorage.removeItem(STORAGE_KEY_ACTIVE);
|
|
433
|
-
}
|
|
434
|
-
} catch (error) {
|
|
435
|
-
console.warn('Could not persist conversations:', error);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
395
|
function scheduleConversationDiskPersist() {
|
|
440
396
|
if (!state.projectPath) return;
|
|
441
397
|
if (state.conversationPersistTimer) window.clearTimeout(state.conversationPersistTimer);
|
|
@@ -457,7 +413,6 @@ function scheduleConversationDiskPersist() {
|
|
|
457
413
|
|
|
458
414
|
function persistConversations(options) {
|
|
459
415
|
const opts = options || {};
|
|
460
|
-
persistConversationsToCache();
|
|
461
416
|
if (opts.disk !== false) scheduleConversationDiskPersist();
|
|
462
417
|
}
|
|
463
418
|
|
|
@@ -465,33 +420,17 @@ async function hydrateConversationsFromServer() {
|
|
|
465
420
|
if (!state.projectPath) return;
|
|
466
421
|
try {
|
|
467
422
|
const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(state.projectPath)}`);
|
|
468
|
-
const
|
|
469
|
-
const localConversations = projectConversations();
|
|
470
|
-
const mergedById = new Map();
|
|
471
|
-
for (const conv of serverConversations) mergedById.set(conv.id, conv);
|
|
472
|
-
for (const conv of localConversations) {
|
|
473
|
-
const existing = mergedById.get(conv.id);
|
|
474
|
-
if (!existing || conversationTimestamp(conv) > conversationTimestamp(existing)) {
|
|
475
|
-
mergedById.set(conv.id, conv);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
const conversations = Array.from(mergedById.values())
|
|
479
|
-
.sort((a, b) => conversationTimestamp(b) - conversationTimestamp(a));
|
|
480
|
-
// #534: apply the same Gemini stdout/stderr normalization as the localStorage
|
|
481
|
-
// path so server-hydrated conversations render identically (the localStorage
|
|
482
|
-
// path normalizes in loadConversationsFromStorage; without this, a hydrate-vs-
|
|
483
|
-
// load race can surface uncoalesced/stale rows).
|
|
423
|
+
const conversations = Array.isArray(payload.conversations) ? payload.conversations : [];
|
|
484
424
|
for (const conv of conversations) normalizeGeminiConversationMessages(conv);
|
|
485
425
|
state.conversations[state.projectPath] = conversations;
|
|
486
426
|
const activeCandidates = [state.activeId, payload.activeId].filter(Boolean);
|
|
487
427
|
state.activeId = activeCandidates.find((id) => conversations.some((conv) => conv.id === id)) || (conversations[0] ? conversations[0].id : null);
|
|
488
428
|
state.conversationDiskAvailable = true;
|
|
489
|
-
persistConversations({ disk: false });
|
|
490
429
|
renderRail();
|
|
491
430
|
renderActive();
|
|
492
431
|
} catch (error) {
|
|
493
432
|
state.conversationDiskAvailable = false;
|
|
494
|
-
console.warn('Could not hydrate conversations from
|
|
433
|
+
console.warn('Could not hydrate conversations from server:', error);
|
|
495
434
|
}
|
|
496
435
|
}
|
|
497
436
|
|
|
@@ -767,14 +706,72 @@ function renderRail() {
|
|
|
767
706
|
!(inWorkspace && projectUpdateJobs.has(conv.jobId))
|
|
768
707
|
);
|
|
769
708
|
|
|
709
|
+
// Issue #550: Two-path routing for ad-hoc (freeform) conversations.
|
|
710
|
+
// Path A: jobId === '__freeform__' AND conv.personaKey resolves to a FRAIM
|
|
711
|
+
// employee the user has access to -> groups with that employee's
|
|
712
|
+
// accordion, identical to a structured catalog run.
|
|
713
|
+
// Path B: jobId === '__freeform__' AND no personaKey (no FRAIM employee
|
|
714
|
+
// identity was resolved) -> deferred to a single "Watercooler
|
|
715
|
+
// Conversations" group rendered after all employee groups (R3).
|
|
716
|
+
// The agentName (host CLI) is NOT a FRAIM employee and does not
|
|
717
|
+
// qualify an ad-hoc run for Path A — only personaKey does.
|
|
718
|
+
const watercoolerConvs = [];
|
|
770
719
|
const groups = new Map();
|
|
771
720
|
for (const conv of list) {
|
|
721
|
+
const isFreeform = conv.jobId === '__freeform__';
|
|
722
|
+
if (isFreeform && !conv.personaKey) {
|
|
723
|
+
// Path B: unmatched ad-hoc — collect for the Watercooler group.
|
|
724
|
+
watercoolerConvs.push(conv);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
// Path A (matched ad-hoc with personaKey) or structured run:
|
|
728
|
+
// group by employee key as before.
|
|
772
729
|
const key = conv.personaKey || conversationAgentName(conv) || 'free';
|
|
773
730
|
const label = getConversationEmployeeLabel(conv);
|
|
774
731
|
if (!groups.has(key)) groups.set(key, { key, label, detail: getConversationEmployeeDetail(conv), sample: conv, conversations: [] });
|
|
775
732
|
groups.get(key).conversations.push(conv);
|
|
776
733
|
}
|
|
777
734
|
|
|
735
|
+
// Helper: build the run-item buttons shared by employee groups and Watercooler.
|
|
736
|
+
function buildGroupList(conversations) {
|
|
737
|
+
const groupList = document.createElement('div');
|
|
738
|
+
groupList.className = 'conv-employee-list';
|
|
739
|
+
for (const conv of conversations) {
|
|
740
|
+
const btn = document.createElement('button');
|
|
741
|
+
btn.type = 'button';
|
|
742
|
+
btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
|
|
743
|
+
btn.dataset.conv = conv.id;
|
|
744
|
+
const bodyDiv = document.createElement('span');
|
|
745
|
+
bodyDiv.className = 'conv-body';
|
|
746
|
+
const titleSpan = document.createElement('span');
|
|
747
|
+
titleSpan.className = 'conv-title';
|
|
748
|
+
titleSpan.textContent = conv.title || '';
|
|
749
|
+
bodyDiv.appendChild(titleSpan);
|
|
750
|
+
btn.appendChild(bodyDiv);
|
|
751
|
+
const dotClass = conversationStateDotClass(conv);
|
|
752
|
+
const statusDot = document.createElement('span');
|
|
753
|
+
statusDot.className = 'state-dot conv-state-dot dot-' + dotClass;
|
|
754
|
+
statusDot.title = tfDotTitle(dotClass);
|
|
755
|
+
btn.appendChild(statusDot);
|
|
756
|
+
const statusSpan = document.createElement('span');
|
|
757
|
+
statusSpan.className = 'conv-status';
|
|
758
|
+
statusSpan.textContent = conversationStateLabel(conv);
|
|
759
|
+
statusSpan.classList.add(conversationUiState(conv));
|
|
760
|
+
btn.appendChild(statusSpan);
|
|
761
|
+
// Issue #442: A/B badge on rail entry.
|
|
762
|
+
if (conv.compareMode === 'ab') {
|
|
763
|
+
const badge = document.createElement('span');
|
|
764
|
+
badge.className = 'ab-badge';
|
|
765
|
+
badge.textContent = 'A/B';
|
|
766
|
+
btn.appendChild(badge);
|
|
767
|
+
}
|
|
768
|
+
tfAttachRunDelete(btn, conv, { tree: false }); // #533 R6: delete a run
|
|
769
|
+
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
770
|
+
groupList.appendChild(btn);
|
|
771
|
+
}
|
|
772
|
+
return groupList;
|
|
773
|
+
}
|
|
774
|
+
|
|
778
775
|
for (const group of groups.values()) {
|
|
779
776
|
const details = document.createElement('details');
|
|
780
777
|
details.className = 'conv-employee-group';
|
|
@@ -812,48 +809,46 @@ function renderRail() {
|
|
|
812
809
|
summary.appendChild(addBtn);
|
|
813
810
|
summary.appendChild(count);
|
|
814
811
|
details.appendChild(summary);
|
|
815
|
-
|
|
816
|
-
const groupList = document.createElement('div');
|
|
817
|
-
groupList.className = 'conv-employee-list';
|
|
818
|
-
|
|
819
|
-
for (const conv of group.conversations) {
|
|
820
|
-
const btn = document.createElement('button');
|
|
821
|
-
btn.type = 'button';
|
|
822
|
-
btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
|
|
823
|
-
btn.dataset.conv = conv.id;
|
|
824
|
-
const bodyDiv = document.createElement('span');
|
|
825
|
-
bodyDiv.className = 'conv-body';
|
|
826
|
-
const titleSpan = document.createElement('span');
|
|
827
|
-
titleSpan.className = 'conv-title';
|
|
828
|
-
titleSpan.textContent = conv.title || '';
|
|
829
|
-
bodyDiv.appendChild(titleSpan);
|
|
830
|
-
btn.appendChild(bodyDiv);
|
|
831
|
-
const dotClass = conversationStateDotClass(conv);
|
|
832
|
-
const statusDot = document.createElement('span');
|
|
833
|
-
statusDot.className = 'state-dot conv-state-dot dot-' + dotClass;
|
|
834
|
-
statusDot.title = tfDotTitle(dotClass);
|
|
835
|
-
btn.appendChild(statusDot);
|
|
836
|
-
const statusSpan = document.createElement('span');
|
|
837
|
-
statusSpan.className = 'conv-status';
|
|
838
|
-
statusSpan.textContent = conversationStateLabel(conv);
|
|
839
|
-
statusSpan.classList.add(conversationUiState(conv));
|
|
840
|
-
btn.appendChild(statusSpan);
|
|
841
|
-
// Issue #442: A/B badge on rail entry.
|
|
842
|
-
if (conv.compareMode === 'ab') {
|
|
843
|
-
const badge = document.createElement('span');
|
|
844
|
-
badge.className = 'ab-badge';
|
|
845
|
-
badge.textContent = 'A/B';
|
|
846
|
-
btn.appendChild(badge);
|
|
847
|
-
}
|
|
848
|
-
tfAttachRunDelete(btn, conv, { tree: false }); // #533 R6: delete a run
|
|
849
|
-
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
850
|
-
groupList.appendChild(btn);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
details.appendChild(groupList);
|
|
812
|
+
details.appendChild(buildGroupList(group.conversations));
|
|
854
813
|
els['conv-list'].appendChild(details);
|
|
855
814
|
}
|
|
856
815
|
|
|
816
|
+
// Issue #550 R2/R3: Render the Watercooler Conversations group after all
|
|
817
|
+
// employee groups, only when at least one unmatched ad-hoc conv exists.
|
|
818
|
+
// R9: the persona filter excludes unmatched ad-hoc convs entirely (they carry
|
|
819
|
+
// no personaKey so they never satisfy the filter), meaning watercoolerConvs is
|
|
820
|
+
// already empty when a persona filter is active.
|
|
821
|
+
if (watercoolerConvs.length > 0) {
|
|
822
|
+
const wcDetails = document.createElement('details');
|
|
823
|
+
wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
|
|
824
|
+
wcDetails.open = true;
|
|
825
|
+
const wcSummary = document.createElement('summary');
|
|
826
|
+
wcSummary.className = 'conv-employee-tab';
|
|
827
|
+
// R4: dashed-border avatar placeholder — no image or initials.
|
|
828
|
+
const wcAvatar = document.createElement('span');
|
|
829
|
+
wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--adhoc';
|
|
830
|
+
const wcCopy = document.createElement('span');
|
|
831
|
+
wcCopy.className = 'conv-employee-tab-copy';
|
|
832
|
+
const wcLabel = document.createElement('strong');
|
|
833
|
+
wcLabel.className = 'conv-employee-tab-label';
|
|
834
|
+
wcLabel.textContent = 'Watercooler Conversations'; // R5
|
|
835
|
+
const wcDetail = document.createElement('small');
|
|
836
|
+
wcDetail.className = 'conv-employee-tab-detail';
|
|
837
|
+
wcDetail.textContent = 'Unmatched ad-hoc tasks'; // R5
|
|
838
|
+
wcCopy.appendChild(wcLabel);
|
|
839
|
+
wcCopy.appendChild(wcDetail);
|
|
840
|
+
const wcCount = document.createElement('span');
|
|
841
|
+
wcCount.className = 'conv-employee-tab-count';
|
|
842
|
+
wcCount.textContent = String(watercoolerConvs.length);
|
|
843
|
+
// R6: NO "+ assign" button in Watercooler summary.
|
|
844
|
+
wcSummary.appendChild(wcAvatar);
|
|
845
|
+
wcSummary.appendChild(wcCopy);
|
|
846
|
+
wcSummary.appendChild(wcCount);
|
|
847
|
+
wcDetails.appendChild(wcSummary);
|
|
848
|
+
wcDetails.appendChild(buildGroupList(watercoolerConvs));
|
|
849
|
+
els['conv-list'].appendChild(wcDetails);
|
|
850
|
+
}
|
|
851
|
+
|
|
857
852
|
// #521: hide the "Runs" section header when there's nothing in it — in the
|
|
858
853
|
// workspace, project-update jobs are deduped out, so a project that has only
|
|
859
854
|
// run onboarding would otherwise show a dangling empty "Runs" label.
|
|
@@ -870,6 +865,11 @@ function statusLabel(s) {
|
|
|
870
865
|
function conversationUiState(conv) {
|
|
871
866
|
if (!conv) return 'idle';
|
|
872
867
|
if (conv.status === 'running') return 'working';
|
|
868
|
+
// #549: conv.stopped is set by tfStopRun after a manager-initiated stop.
|
|
869
|
+
// A stopped run is an intentional pause — visually distinct from an error-failed run.
|
|
870
|
+
// Guard on status==='failed' only: a completed run should resolve to complete/waiting,
|
|
871
|
+
// not stay stuck as stopped even if the stopped flag was not cleared.
|
|
872
|
+
if (conv.stopped && conv.status === 'failed') return 'stopped';
|
|
873
873
|
if (conv.status === 'failed') return 'waiting';
|
|
874
874
|
if (conv.status === 'completed' && conv.reviewApproved) return 'complete';
|
|
875
875
|
if (conv.status === 'completed') return 'waiting';
|
|
@@ -881,6 +881,8 @@ function conversationStateDotClass(conv) {
|
|
|
881
881
|
if (uiState === 'working') return 'amber';
|
|
882
882
|
if (uiState === 'waiting') return 'red';
|
|
883
883
|
if (uiState === 'complete') return 'green';
|
|
884
|
+
// #549: stopped runs use a non-pulsing amber dot (intentional pause, not error).
|
|
885
|
+
if (uiState === 'stopped') return 'amber-static';
|
|
884
886
|
return 'grey';
|
|
885
887
|
}
|
|
886
888
|
|
|
@@ -889,6 +891,8 @@ function conversationStateLabel(conv) {
|
|
|
889
891
|
if (uiState === 'working') return 'Working';
|
|
890
892
|
if (uiState === 'waiting') return 'Waiting on you';
|
|
891
893
|
if (uiState === 'complete') return 'Done';
|
|
894
|
+
// #549: manager-stopped run gets a distinct "Stopped" label in the rail.
|
|
895
|
+
if (uiState === 'stopped') return 'Stopped';
|
|
892
896
|
return 'Idle';
|
|
893
897
|
}
|
|
894
898
|
|
|
@@ -1301,6 +1305,7 @@ function renderActive() {
|
|
|
1301
1305
|
}
|
|
1302
1306
|
els['empty'].hidden = true;
|
|
1303
1307
|
els['active-conv'].hidden = false;
|
|
1308
|
+
els['active-conv'].dataset.runId = conv.runId || '';
|
|
1304
1309
|
els['active-title'].textContent = conversationTitle(conv);
|
|
1305
1310
|
renderConversationIdentity(conv);
|
|
1306
1311
|
renderRunStatePill(conv);
|
|
@@ -1516,6 +1521,8 @@ function renderRunStatePill(conv) {
|
|
|
1516
1521
|
pill.textContent = 'DONE';
|
|
1517
1522
|
pill.className = 'run-state-pill complete';
|
|
1518
1523
|
} else {
|
|
1524
|
+
// #549 R3: conversationUiState now returns 'stopped' for manager-stopped runs.
|
|
1525
|
+
// conversationStateLabel returns 'Stopped' for that state; toUpperCase() => 'STOPPED'.
|
|
1519
1526
|
pill.textContent = conversationStateLabel(conv).toUpperCase();
|
|
1520
1527
|
pill.className = `run-state-pill ${conversationUiState(conv)}`;
|
|
1521
1528
|
}
|
|
@@ -1523,16 +1530,33 @@ function renderRunStatePill(conv) {
|
|
|
1523
1530
|
const stopBtn = els['run-stop-btn'];
|
|
1524
1531
|
if (stopBtn) {
|
|
1525
1532
|
const canStop = conv.status === 'running' && !!conv.runId && !conv._stopping;
|
|
1526
|
-
|
|
1533
|
+
// #549 R1/AC1.3: While in-flight (_stopping===true), keep the button VISIBLE but disabled
|
|
1534
|
+
// so the manager retains the affordance showing "Stopping…". Hide only when the run is
|
|
1535
|
+
// not active at all (canStop===false AND not in-flight).
|
|
1536
|
+
stopBtn.hidden = !canStop && !conv._stopping;
|
|
1527
1537
|
stopBtn.disabled = !!conv._stopping;
|
|
1538
|
+
if (conv._stopping) {
|
|
1539
|
+
stopBtn.textContent = '⏹ Stopping…';
|
|
1540
|
+
stopBtn.setAttribute('aria-label', 'Stopping the employee, please wait');
|
|
1541
|
+
} else {
|
|
1542
|
+
stopBtn.textContent = '⏹ Stop';
|
|
1543
|
+
stopBtn.setAttribute('aria-label', 'Ask the employee to stop and wait for you');
|
|
1544
|
+
}
|
|
1528
1545
|
}
|
|
1529
1546
|
}
|
|
1530
1547
|
|
|
1531
1548
|
// #521: ask the agent to stop and park the run in "waiting on you" mode.
|
|
1532
1549
|
async function tfStopRun(conv) {
|
|
1533
|
-
|
|
1550
|
+
// #549 B2: Guard against _stopping so a rapid double-click (or force-click
|
|
1551
|
+
// that bypasses the disabled button) cannot fire a second stop request while
|
|
1552
|
+
// the first is still in-flight. The button is set disabled in renderRunStatePill
|
|
1553
|
+
// but DOM state is not a reliable guard for programmatic re-entry.
|
|
1554
|
+
if (!conv || !conv.runId || conv.status !== 'running' || conv._stopping) return;
|
|
1534
1555
|
conv._stopping = true;
|
|
1535
1556
|
if (typeof renderRunStatePill === 'function') renderRunStatePill(conv);
|
|
1557
|
+
// #549 R1: Update the thread indicator immediately to show the in-flight dash
|
|
1558
|
+
// so the manager sees "stopping in progress" rather than "still working".
|
|
1559
|
+
if (typeof syncWorkingIndicator === 'function') syncWorkingIndicator(conv);
|
|
1536
1560
|
try {
|
|
1537
1561
|
const run = await requestJson('/api/ai-hub/runs/' + encodeURIComponent(conv.runId) + '/stop', {
|
|
1538
1562
|
method: 'POST',
|
|
@@ -1553,6 +1577,11 @@ async function tfStopRun(conv) {
|
|
|
1553
1577
|
} catch (e) {
|
|
1554
1578
|
conv._stopping = false;
|
|
1555
1579
|
if (typeof renderRunStatePill === 'function') renderRunStatePill(conv);
|
|
1580
|
+
// #549 B1: Restore the three typing-dots when the stop POST fails.
|
|
1581
|
+
// Without this call, syncWorkingIndicator was never called after the catch
|
|
1582
|
+
// cleared _stopping, leaving the stopping-dash in #employee-working-indicator
|
|
1583
|
+
// permanently even though the employee is still running.
|
|
1584
|
+
if (typeof syncWorkingIndicator === 'function') syncWorkingIndicator(conv);
|
|
1556
1585
|
if (typeof showStatus === 'function') showStatus((e && e.message) || 'Could not stop the run.', true);
|
|
1557
1586
|
}
|
|
1558
1587
|
}
|
|
@@ -1571,23 +1600,58 @@ function syncThreadUiState(conv) {
|
|
|
1571
1600
|
function syncWorkingIndicator(conv) {
|
|
1572
1601
|
const host = els['messages'];
|
|
1573
1602
|
if (!host) return;
|
|
1574
|
-
const
|
|
1603
|
+
const uiState = conversationUiState(conv);
|
|
1604
|
+
const isStopping = conv && conv._stopping;
|
|
1605
|
+
const shouldShowWorking = uiState === 'working';
|
|
1606
|
+
const shouldShowStopped = uiState === 'stopped';
|
|
1607
|
+
|
|
1608
|
+
// ── #549 R2: Remove stopped indicator when state is no longer stopped. ──
|
|
1609
|
+
let stoppedIndicator = host.querySelector('#employee-stopped-indicator');
|
|
1610
|
+
if (!shouldShowStopped && stoppedIndicator) {
|
|
1611
|
+
stoppedIndicator.remove();
|
|
1612
|
+
stoppedIndicator = null;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// ── Working indicator (three-dot bounce or in-flight dash). ──
|
|
1575
1616
|
let indicator = host.querySelector('#employee-working-indicator');
|
|
1576
|
-
if (!
|
|
1617
|
+
if (!shouldShowWorking && !isStopping) {
|
|
1577
1618
|
if (indicator) indicator.remove();
|
|
1578
|
-
|
|
1619
|
+
} else {
|
|
1620
|
+
// Create the bubble if it doesn't exist yet.
|
|
1621
|
+
if (!indicator) {
|
|
1622
|
+
indicator = document.createElement('article');
|
|
1623
|
+
indicator.id = 'employee-working-indicator';
|
|
1624
|
+
indicator.className = 'message typing-indicator';
|
|
1625
|
+
indicator.setAttribute('aria-label', 'Employee is working');
|
|
1626
|
+
const bubble = document.createElement('div');
|
|
1627
|
+
bubble.className = 'bubble';
|
|
1628
|
+
bubble.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
|
|
1629
|
+
indicator.appendChild(bubble);
|
|
1630
|
+
}
|
|
1631
|
+
// #549 R1: While stop is in-flight, replace the three-dot animation with
|
|
1632
|
+
// a single static dash so the manager sees "stopping" rather than "still working".
|
|
1633
|
+
const bubble = indicator.querySelector('.bubble');
|
|
1634
|
+
if (isStopping) {
|
|
1635
|
+
bubble.innerHTML = '<span class="stopping-dash">–</span>';
|
|
1636
|
+
} else if (!bubble.querySelector('.typing-dot')) {
|
|
1637
|
+
// Restore three dots if _stopping cleared (e.g. stop failed).
|
|
1638
|
+
bubble.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
|
|
1639
|
+
}
|
|
1640
|
+
host.appendChild(indicator);
|
|
1579
1641
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1642
|
+
|
|
1643
|
+
// ── #549 R2: Show stopped-state system message after stop completes. ──
|
|
1644
|
+
if (shouldShowStopped && !stoppedIndicator) {
|
|
1645
|
+
const article = document.createElement('article');
|
|
1646
|
+
article.id = 'employee-stopped-indicator';
|
|
1647
|
+
article.className = 'message system';
|
|
1648
|
+
article.setAttribute('aria-label', 'Employee stopped');
|
|
1585
1649
|
const bubble = document.createElement('div');
|
|
1586
1650
|
bubble.className = 'bubble';
|
|
1587
|
-
bubble.
|
|
1588
|
-
|
|
1651
|
+
bubble.textContent = '⏸ Employee stopped. Send your next instruction to continue.';
|
|
1652
|
+
article.appendChild(bubble);
|
|
1653
|
+
host.appendChild(article);
|
|
1589
1654
|
}
|
|
1590
|
-
host.appendChild(indicator);
|
|
1591
1655
|
}
|
|
1592
1656
|
|
|
1593
1657
|
function buildConversationSummary(conv) {
|
|
@@ -3837,6 +3901,9 @@ async function continueRun(text) {
|
|
|
3837
3901
|
const coachingJobId = state.pendingCoachingJobId || undefined;
|
|
3838
3902
|
clearPendingCoachingJob();
|
|
3839
3903
|
conv.reviewApproved = false;
|
|
3904
|
+
// #549 R6/AC6.1: Clear the stopped flag before starting the new run so
|
|
3905
|
+
// stopped-state indicators are removed on the next render tick.
|
|
3906
|
+
conv.stopped = false;
|
|
3840
3907
|
conv.status = 'running';
|
|
3841
3908
|
upsertConversation(conv);
|
|
3842
3909
|
refreshStatusSurfaces(); // #533 R5: also recolor the tree/area dots back to working
|
|
@@ -4651,7 +4718,8 @@ function wireEvents() {
|
|
|
4651
4718
|
// This is a belt-and-suspenders fallback in case the inline script was skipped.
|
|
4652
4719
|
|
|
4653
4720
|
gatherElements();
|
|
4654
|
-
|
|
4721
|
+
state.conversations = {};
|
|
4722
|
+
state.activeId = null;
|
|
4655
4723
|
wirePopovers();
|
|
4656
4724
|
wireEvents();
|
|
4657
4725
|
|
|
@@ -4707,7 +4775,6 @@ function wireEvents() {
|
|
|
4707
4775
|
if (convNeedsPolling(conv)) startPolling();
|
|
4708
4776
|
} else {
|
|
4709
4777
|
state.activeId = null;
|
|
4710
|
-
persistConversations();
|
|
4711
4778
|
renderActive();
|
|
4712
4779
|
}
|
|
4713
4780
|
})();
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -495,6 +495,19 @@ img.conv-employee-avatar {
|
|
|
495
495
|
background: var(--accent-soft);
|
|
496
496
|
border-color: rgba(0,113,227,.28);
|
|
497
497
|
}
|
|
498
|
+
/* Issue #550: Watercooler Conversations group modifier */
|
|
499
|
+
/* R4: dashed avatar placeholder — no image or persona initials */
|
|
500
|
+
.conv-employee-avatar--adhoc {
|
|
501
|
+
border-style: dashed;
|
|
502
|
+
border-color: var(--muted);
|
|
503
|
+
background: transparent;
|
|
504
|
+
color: transparent;
|
|
505
|
+
}
|
|
506
|
+
/* R5: muted label color for the Watercooler group header */
|
|
507
|
+
.conv-employee-group--adhoc .conv-employee-tab-label {
|
|
508
|
+
color: var(--muted);
|
|
509
|
+
font-weight: 600;
|
|
510
|
+
}
|
|
498
511
|
.conv-employee-list {
|
|
499
512
|
display: grid;
|
|
500
513
|
gap: 8px;
|
|
@@ -588,6 +601,8 @@ img.conv-employee-avatar {
|
|
|
588
601
|
.conv-status.working { color: var(--state-working); }
|
|
589
602
|
.conv-status.waiting { color: var(--state-waiting); }
|
|
590
603
|
.conv-status.complete { color: var(--state-complete); }
|
|
604
|
+
/* #549 R4: Amber label for manager-stopped runs in the conversation rail. */
|
|
605
|
+
.conv-status.stopped { color: var(--warn); }
|
|
591
606
|
.state-dot {
|
|
592
607
|
display: inline-block;
|
|
593
608
|
width: 8px;
|
|
@@ -765,6 +780,11 @@ img.conv-employee-avatar {
|
|
|
765
780
|
background: color-mix(in srgb, var(--done) 14%, transparent);
|
|
766
781
|
color: var(--done);
|
|
767
782
|
}
|
|
783
|
+
/* #549 R3: Amber STOPPED pill — intentional pause, distinct from red WAITING ON YOU. */
|
|
784
|
+
.run-state-pill.stopped {
|
|
785
|
+
background: color-mix(in srgb, var(--warn) 15%, transparent);
|
|
786
|
+
color: var(--warn);
|
|
787
|
+
}
|
|
768
788
|
.run-state-pill.approved {
|
|
769
789
|
background: color-mix(in srgb, #2e7d32 18%, transparent);
|
|
770
790
|
color: #2e7d32;
|
|
@@ -1170,6 +1190,16 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
1170
1190
|
}
|
|
1171
1191
|
.typing-dot:nth-child(2) { animation-delay: 120ms; }
|
|
1172
1192
|
.typing-dot:nth-child(3) { animation-delay: 240ms; }
|
|
1193
|
+
/* #549 R1: Static dash shown in the working-indicator bubble while stop is in-flight.
|
|
1194
|
+
Replaces the animated typing-dots to signal "stopping in progress, please wait". */
|
|
1195
|
+
.stopping-dash {
|
|
1196
|
+
display: inline-flex;
|
|
1197
|
+
align-items: center;
|
|
1198
|
+
justify-content: center;
|
|
1199
|
+
font-size: 14px;
|
|
1200
|
+
color: var(--muted);
|
|
1201
|
+
line-height: 1;
|
|
1202
|
+
}
|
|
1173
1203
|
@keyframes typing-bounce {
|
|
1174
1204
|
0%, 80%, 100% { opacity: 0.35; transform: translateY(0); }
|
|
1175
1205
|
40% { opacity: 0.9; transform: translateY(-3px); }
|
|
@@ -2582,6 +2612,9 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
2582
2612
|
.dot-amber { background: var(--state-working) !important; }
|
|
2583
2613
|
.dot-green { background: var(--state-complete) !important; }
|
|
2584
2614
|
.dot-grey { background: var(--line) !important; }
|
|
2615
|
+
/* #549 R4: amber-static — same amber colour as dot-amber but no pulse animation.
|
|
2616
|
+
Used for manager-stopped runs to distinguish intentional pause from active work. */
|
|
2617
|
+
.dot-amber-static { background: var(--warn) !important; }
|
|
2585
2618
|
|
|
2586
2619
|
/* Workspace conversation pane */
|
|
2587
2620
|
.workspace-conv { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
|