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.
@@ -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(executableName(id));
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))
@@ -516,6 +516,7 @@ const HUB_TO_FIRST_RUN_ID = {
516
516
  claude: 'claude-code',
517
517
  codex: 'codex',
518
518
  gemini: 'gemini-cli',
519
+ copilot: 'copilot-cli',
519
520
  };
520
521
  function hubAgentOption(hubId) {
521
522
  const frId = HUB_TO_FIRST_RUN_ID[hubId];
@@ -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
- const MCP_CHECK_TIMEOUT = 10000; // 10 seconds for MCP connectivity checks
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
- return Number.isFinite(major) ? major : null;
76
+ _npmMajorVersionCache = Number.isFinite(major) ? major : null;
65
77
  }
66
78
  catch {
67
- return null;
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.162",
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": "npx tsx --test --test-concurrency=1 --test-reporter=spec ",
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",
@@ -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="Employee"></select>
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">Employee:</span>
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>
@@ -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 serverConversations = Array.isArray(payload.conversations) ? payload.conversations : [];
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 local disk:', error);
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
- stopBtn.hidden = !canStop;
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
- if (!conv || !conv.runId || conv.status !== 'running') return;
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 shouldShow = conversationUiState(conv) === 'working';
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 (!shouldShow) {
1617
+ if (!shouldShowWorking && !isStopping) {
1577
1618
  if (indicator) indicator.remove();
1578
- return;
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
- if (!indicator) {
1581
- indicator = document.createElement('article');
1582
- indicator.id = 'employee-working-indicator';
1583
- indicator.className = 'message typing-indicator';
1584
- indicator.setAttribute('aria-label', 'Employee is working');
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.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
1588
- indicator.appendChild(bubble);
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
- loadConversationsFromStorage();
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
  })();
@@ -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); }