agentgui 1.0.999 → 1.0.1001

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.
@@ -362,6 +362,10 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
362
362
  status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length,
363
363
  activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0,
364
364
  acp: getACPStatus(), db: dbStatus, queueSizes,
365
+ // Surfaces the console-only CORS_ORIGIN=*+no-PASSWORD warning (above)
366
+ // as a fact the GUI settings panel can render a persistent banner
367
+ // from - an operator not tailing logs otherwise never sees the risk.
368
+ corsWildcardOpen: process.env.CORS_ORIGIN === '*' && !process.env.PASSWORD,
365
369
  };
366
370
  // Host-internal facts (memory, filesystem paths) are exposed only to an
367
371
  // authenticated caller, never on the unauthenticated health-probe bypass.
package/lib/ws-setup.js CHANGED
@@ -60,6 +60,10 @@ export function createWsSetup(server, { BASE_URL, watch, staticDir, _assetCache,
60
60
  }
61
61
  debugLog(`[WebSocket] Client ${ws.clientId} disconnected`);
62
62
  });
63
+ } else {
64
+ // No handler owns this route - close it rather than leaving an
65
+ // authenticated socket open and idle (it would never be serviced).
66
+ ws.close(4404, 'unknown-route');
63
67
  }
64
68
  });
65
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.999",
3
+ "version": "1.0.1001",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -345,6 +345,10 @@ function activeSig(list) {
345
345
  return (Array.isArray(list) ? list : []).map(a => a.sessionId + ':' + (a.claudeSessionId || '') + ':' + (a.model || '') + ':' + (a.startedAt || '')).sort().join('|');
346
346
  }
347
347
  async function refreshActive() {
348
+ // A backgrounded tab still holds its interval timer, but polling every ~3.3s
349
+ // while hidden is needless server load across many left-open tabs - skip the
350
+ // network call (and let the next visible tick catch up).
351
+ if (typeof document !== 'undefined' && document.hidden) return;
348
352
  let next;
349
353
  try { next = await B.listActiveChats(state.backend); } catch { return; }
350
354
  const changed = activeSig(next) !== activeSig(state.active);
@@ -366,6 +370,10 @@ function startActivePolling() {
366
370
  refreshActive();
367
371
  // Small jitter so many tabs don't hit the server in lockstep.
368
372
  state.activeTimer = setInterval(refreshActive, 3000 + Math.floor(Math.random() * 600));
373
+ if (!state._visActiveBound && typeof document !== 'undefined') {
374
+ state._visActiveBound = true;
375
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) refreshActive(); });
376
+ }
369
377
  }
370
378
  function stopActivePolling() {
371
379
  if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; }
@@ -606,7 +614,7 @@ function view() {
606
614
  // main content, and an optional right context pane. The rail replaces the old
607
615
  // Topbar tabs; it collapses to icon-only and, on mobile, behind its own toggle.
608
616
  const rail = workspaceRail();
609
- const sessions = (state.tab === 'chat' || state.tab === 'history') ? sessionsColumn() : null;
617
+ const sessions = (state.tab === 'chat' || state.tab === 'history' || state.tab === 'live') ? sessionsColumn() : null;
610
618
  // Right context pane. On chat it carries agent/model/cwd + the live running-tool
611
619
  // count + last-turn usage. On files it hosts the inline file preview (split view).
612
620
  // On other tabs it is null but the shell keeps the column TRACK (stableFrame) so
@@ -623,6 +631,13 @@ function view() {
623
631
  turns: state.chat.messages.filter(m => m.role === 'user').length,
624
632
  cost: state.chat.totalCost || null,
625
633
  },
634
+ recentFiles: recentSessionFiles(),
635
+ onOpenFile: (path) => {
636
+ const base = pathBasename(path);
637
+ const dir = base === path ? '' : path.slice(0, path.length - base.length - 1);
638
+ navTo('files');
639
+ loadDir(dir);
640
+ },
626
641
  // One affordance per action: the cwd row opens the SAME inline editor as
627
642
  // the composer context line (it validates via /api/stat) instead of
628
643
  // navigating away to the Files tab.
@@ -638,7 +653,11 @@ function view() {
638
653
 
639
654
  // The left workspace rail: brand, New chat action, and the primary view nav.
640
655
  function workspaceRail() {
641
- const liveCount = (Array.isArray(state.active) ? state.active.length : 0);
656
+ // Same owned+external session set the Live dashboard renders, so the nav
657
+ // badge count and error tone never diverge from what the dashboard shows.
658
+ const liveSessions = computeLiveSessions();
659
+ const liveCount = liveSessions.length;
660
+ const liveHasError = liveSessions.some((s) => s.status === 'error');
642
661
  // Show a live pulse on the chat rail item when a stream is in progress but the
643
662
  // user has navigated to a different tab - so the background stream stays visible.
644
663
  const chatStreaming = state.chat.busy && state.tab !== 'chat';
@@ -646,7 +665,7 @@ function workspaceRail() {
646
665
  { key: 'chat', label: 'Chat', icon: 'forum', active: state.tab === 'chat', count: chatStreaming ? 1 : null, onClick: () => navTo('chat') },
647
666
  { key: 'history', label: 'History', icon: 'thread', active: state.tab === 'history', onClick: () => navTo('history') },
648
667
  { key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
649
- { key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, onClick: () => navTo('live') },
668
+ { key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, rail: liveHasError ? 'flame' : undefined, onClick: () => navTo('live') },
650
669
  { key: 'settings', label: 'Settings', icon: 'settings', active: state.tab === 'settings', onClick: () => navTo('settings') },
651
670
  ];
652
671
  return WorkspaceRail({
@@ -748,6 +767,10 @@ function sessionsColumn() {
748
767
  const sessionsView = visibleSessions();
749
768
  const sliced = sessionsView.slice(0, state.sessionsLimit);
750
769
  const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
770
+ // Same status computation the Live tab uses (running/stale/error), keyed by
771
+ // realSid, so a session pinned to the rail's "Running" group shows whether it
772
+ // is actually stuck vs busy instead of just a plain live dot.
773
+ const liveStatusBySid = new Map(computeLiveSessions().map((s) => [s.realSid, s.status]));
751
774
  const items = sliced.map((s) => {
752
775
  const title = projectLabel(s.title) || projectLabel(s.project) || s.sid;
753
776
  const project = projectLabel(s.project) || '';
@@ -760,6 +783,7 @@ function sessionsColumn() {
760
783
  time: fmtRelTime(s.last),
761
784
  agent: undefined,
762
785
  running: runningSids.has(s.sid),
786
+ status: runningSids.has(s.sid) ? liveStatusBySid.get(s.sid) : undefined,
763
787
  unread: false,
764
788
  // Tool errors are NORMAL in agent sessions (a failed Bash call counts) -
765
789
  // flame-on-any-error painted every real session red. Flame only when
@@ -1507,17 +1531,15 @@ const STALE_AFTER_MS = 45000;
1507
1531
  const STATUS_RANK = { error: 0, stale: 1, running: 2 };
1508
1532
 
1509
1533
  // --- live (multi-session dashboard) ---
1510
- function liveMain() {
1511
- const offline = state.health.status !== 'ok' && state.health.status !== 'unknown';
1534
+ // Shared source of truth for "every live session" (owned chat.active rows plus
1535
+ // external SSE-observed sessions) - used by BOTH the Live tab dashboard and the
1536
+ // WorkspaceRail nav badge/tone, so the count and error signal never diverge
1537
+ // between the two surfaces (a prior cohesion gap: the rail counted owned-only).
1538
+ function computeLiveSessions() {
1512
1539
  const bySid = state.sessionsBySid || new Map();
1513
1540
  const tally = state.live.tally || new Map();
1514
1541
  const now = Date.now();
1515
- // Live-stream health: connected (recent event), connecting (opened, no event
1516
- // yet), or offline (errored - one connection vocabulary across the GUI).
1517
- const streamState = state.live.error ? 'offline' : (state.live.connected ? 'connected' : 'connecting');
1518
1542
  const stoppingSet = state.live.stopping || new Set();
1519
- // Clock-skew-corrected "now" in server-timestamp terms, so pure skew never
1520
- // derives a session stale.
1521
1543
  const nowS = now - (state.live.clockSkew || 0);
1522
1544
  let sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
1523
1545
  // The history index + SSE tally are keyed by claude's REAL session id, not
@@ -1627,6 +1649,16 @@ function liveMain() {
1627
1649
  stopping: false,
1628
1650
  });
1629
1651
  }
1652
+ return sessions;
1653
+ }
1654
+
1655
+ function liveMain() {
1656
+ const offline = state.health.status !== 'ok' && state.health.status !== 'unknown';
1657
+ // Live-stream health: connected (recent event), connecting (opened, no event
1658
+ // yet), or offline (errored - one connection vocabulary across the GUI).
1659
+ const streamState = state.live.error ? 'offline' : (state.live.connected ? 'connected' : 'connecting');
1660
+ const stoppingSet = state.live.stopping || new Set();
1661
+ let sessions = computeLiveSessions();
1630
1662
  // W12: in-dir filter + errors-only toggle.
1631
1663
  const lv = state.live;
1632
1664
  if (lv.errorsOnly) sessions = sessions.filter(s => s.status === 'error');
@@ -1751,6 +1783,29 @@ function runningToolCount() {
1751
1783
  return last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').length;
1752
1784
  }
1753
1785
 
1786
+ // Files touched by Edit/Write/MultiEdit/Read tool calls in the current chat
1787
+ // session, most-recent first, deduped by path - feeds ContextPane's "recent
1788
+ // files" panel (Claude Desktop surfaces recently-touched files; this GUI had
1789
+ // no path to that fact at all before).
1790
+ const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'Read', 'NotebookEdit']);
1791
+ function recentSessionFiles() {
1792
+ const msgs = Array.isArray(state.chat.messages) ? state.chat.messages : [];
1793
+ const seen = new Set();
1794
+ const out = [];
1795
+ for (let i = msgs.length - 1; i >= 0 && out.length < 5; i--) {
1796
+ const parts = Array.isArray(msgs[i].parts) ? msgs[i].parts : [];
1797
+ for (let j = parts.length - 1; j >= 0 && out.length < 5; j--) {
1798
+ const p = parts[j];
1799
+ if (!p || p.kind !== 'tool' || !FILE_TOOLS.has(p.name)) continue;
1800
+ const path = p.args && (p.args.file_path || p.args.path || p.args.notebook_path);
1801
+ if (!path || seen.has(path)) continue;
1802
+ seen.add(path);
1803
+ out.push({ path });
1804
+ }
1805
+ }
1806
+ return out;
1807
+ }
1808
+
1754
1809
  function toolPart(block) {
1755
1810
  const name = block.name || block.kind || 'tool';
1756
1811
  const input = block.input || block.rawInput || {};
@@ -2262,7 +2317,7 @@ function chatFollowups() {
2262
2317
  const fm = text.match(/([A-Za-z]:\\\S+|\/[\w./-]+\.\w+)/);
2263
2318
  if (fm) {
2264
2319
  const base = fm[1].split(/[/\\]/).filter(Boolean).pop();
2265
- if (base) out.push('Open ' + base.replace(/[^\w.\-]/g, '') + ' in files');
2320
+ if (base) out.push('Explain ' + base.replace(/[^\w.\-]/g, ''));
2266
2321
  }
2267
2322
  const result = out.length ? out.slice(0, 3) : FOLLOWUP_SEEDS;
2268
2323
  state._followupsMsgs = msgs;
@@ -2457,7 +2512,7 @@ function reconnectAlert() {
2457
2512
  return Alert({
2458
2513
  key: 'liveerr',
2459
2514
  kind: 'error',
2460
- title: 'Live stream disconnected',
2515
+ title: 'Live stream offline',
2461
2516
  children: [h('span', { key: 'lemsg' }, state.live.error + ' - '), Btn({ key: 'reco', onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
2462
2517
  });
2463
2518
  }
@@ -2946,6 +3001,10 @@ function settingsMain() {
2946
3001
  // backend panel where it applies.
2947
3002
  lede: 'Connection, agents, appearance, keyboard, and local data.',
2948
3003
  }),
3004
+ state.health.corsWildcardOpen
3005
+ ? Alert({ key: 'cors-open-warn', kind: 'error', title: 'Open CORS with no password',
3006
+ children: 'This server allows requests from any origin (CORS_ORIGIN=*) with no PASSWORD set - any page a user visits in their browser can read and modify this filesystem. Set PASSWORD or restrict CORS_ORIGIN to a specific origin.' })
3007
+ : null,
2949
3008
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
2950
3009
  Panel({
2951
3010
  id: 'backend',
@@ -3121,7 +3180,7 @@ function agentsPanel() {
3121
3180
  const usable = avail || a.npxInstallable; // selectable from this row
3122
3181
  const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
3123
3182
  if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
3124
- if (acp) bits.push(acp.healthy ? 'connected' : (acp.running ? 'connecting' : 'disconnected'));
3183
+ if (acp) bits.push(acp.healthy ? 'connected' : (acp.running ? 'connecting' : 'offline'));
3125
3184
  if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
3126
3185
  if (acp && !acp.healthy && acp.providerInfo?.error) bits.push(acp.providerInfo.error);
3127
3186
  if (acp && acp.idleMs > 3_600_000) bits.push(fmtDuration(acp.idleMs) + ' idle');