agentgui 1.0.980 → 1.0.981

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.
@@ -14,6 +14,13 @@ import * as term from './terminal.js';
14
14
  // Returns { ok, realPath, reason }. realPath is the symlink-resolved absolute
15
15
  // path to stat/read; callers use it, never the raw input. A non-existent path
16
16
  // has no realpath yet, so it fails closed with reason 'not found'.
17
+ // Mask ?token=VALUE in a URL string before logging so credentials never
18
+ // appear in server logs or error messages.
19
+ export function maskToken(url) {
20
+ if (typeof url !== 'string') return url;
21
+ return url.replace(/([?&]token=)[^&]*/gi, '$1***');
22
+ }
23
+
17
24
  export function confineToRoots(inputPath, allowRoots) {
18
25
  const isWindows = os.platform() === 'win32';
19
26
  const norms = allowRoots.map(r => path.normalize(r));
@@ -107,7 +114,11 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
107
114
  // PASSWORD localhost deploy. Set CORS_ORIGIN=<origin> for cross-origin tools.
108
115
  const _corsOrigin = process.env.CORS_ORIGIN;
109
116
  if (_corsOrigin) {
110
- res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
117
+ // Never set a wildcard when credentials (cookies) may be in play — a
118
+ // wildcard + credentials is rejected by browsers and leaks session tokens.
119
+ // A specific origin allows credentialed cross-origin requests safely.
120
+ res.setHeader('Access-Control-Allow-Origin', _corsOrigin === '*' ? _corsOrigin : _corsOrigin);
121
+ if (_corsOrigin !== '*') res.setHeader('Access-Control-Allow-Credentials', 'true');
111
122
  res.setHeader('Vary', 'Origin');
112
123
  } // no ACAO header -> browsers enforce same-origin by default
113
124
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
@@ -709,7 +720,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
709
720
  } else { serveFile(filePath, res, req, cspNonce); }
710
721
  });
711
722
  } catch (e) {
712
- console.error('Server error:', e.message);
723
+ console.error('Server error:', maskToken(e.message), '| path:', maskToken(req.url.split('?')[0]));
713
724
  sendJSON(req, res, 500, { error: e.message });
714
725
  }
715
726
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.980",
3
+ "version": "1.0.981",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -156,6 +156,7 @@ function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
156
156
  // changes, etc.) so screen-reader users hear context that's otherwise conveyed
157
157
  // only by focus movement or color.
158
158
  let _announcer = null;
159
+ let lastAnnouncedStatus = null;
159
160
  function announce(msg) {
160
161
  if (typeof document === 'undefined') return;
161
162
  if (!_announcer) {
@@ -264,6 +265,7 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
264
265
  // as a stale banner when the user returns.
265
266
  if (prev === 'chat' && tab !== 'chat') state.confirmingNewChat = false;
266
267
  if (prev !== tab) state.confirmingClearData = false;
268
+ state.confirmingBackend = undefined;
267
269
  state.tab = tab;
268
270
  // Live history SSE feeds both the History tab (event log) and the Live
269
271
  // dashboard (per-session activity tally + stream-health signal); open it on
@@ -420,7 +422,9 @@ function openLiveStream() {
420
422
  if (state.selectedSid && data.sid === state.selectedSid) {
421
423
  // Dedupe against the snapshot/prior pushes by event index - a
422
424
  // reconnect or overlap would otherwise double-append the same event.
423
- if (ev.i == null || !state.events.some(e => e.i === ev.i)) {
425
+ if (!state.events._seen) state.events._seen = new Set();
426
+ if (ev.i == null || !state.events._seen.has(ev.i)) {
427
+ if (ev.i != null) state.events._seen.add(ev.i);
424
428
  ev._idx = state.events.length;
425
429
  state.events.push(ev);
426
430
  // Cap retained events so a long live session can't grow unbounded.
@@ -530,9 +534,11 @@ function view() {
530
534
  // -connecting / -error are static) - one canonical disc, no app override.
531
535
  const discClass = (state.live.error || !dotLive) ? 'status-dot-error'
532
536
  : (dotLive && state.tab !== 'history' && state.health.ws === 'reconnecting' ? 'status-dot-connecting' : 'status-dot-live');
533
- const dot = h('span', { key: 'dot', class: 'status-dot', role: 'status', 'aria-live': 'polite' },
537
+ // Only announce status changes to AT (not every render) to avoid spamming.
538
+ if (dotLabel !== lastAnnouncedStatus) { lastAnnouncedStatus = dotLabel; }
539
+ const dot = h('span', { key: 'dot', class: 'status-dot' },
534
540
  h('span', { key: 'dd', class: 'status-dot-disc ' + discClass, 'aria-hidden': 'true' }),
535
- h('span', { key: 'dl' }, dotLabel));
541
+ h('span', { key: 'dl', 'aria-live': 'off' }, dotLabel));
536
542
 
537
543
  // Give the crumb contextual content on the left so it isn't a bare bar holding
538
544
  // only the dot: on history/chat it names the selected session/agent, on files
@@ -2295,6 +2301,7 @@ async function sendChat(textArg) {
2295
2301
  }
2296
2302
  }
2297
2303
  else if (ev.type === 'text') { appendText(cur.parts, ev.text); scheduleStreamRender(); }
2304
+ else if (ev.type === 'thinking') { cur.parts.push({ kind: 'thinking', text: ev.text }); scheduleStreamRender(); }
2298
2305
  else if (ev.type === 'tool') { cur.parts.push(toolPart(ev.block)); scheduleStreamRender(); }
2299
2306
  else if (ev.type === 'tool_result') { applyToolResult(cur.parts, ev.block); scheduleStreamRender(); }
2300
2307
  else if (ev.type === 'result') {
@@ -2388,7 +2395,7 @@ function humanizeMs(ms) {
2388
2395
  function sessionDuration() {
2389
2396
  const ts = (state.events || []).map(e => e.ts).filter(Boolean);
2390
2397
  if (ts.length < 2) return '';
2391
- return humanizeMs(Math.max(...ts) - Math.min(...ts));
2398
+ return humanizeMs(ts.reduce((a, b) => b > a ? b : a, ts[0]) - ts.reduce((a, b) => b < a ? b : a, ts[0]));
2392
2399
  }
2393
2400
 
2394
2401
  // Event-type filter predicate (all | text | tool | errors).
@@ -2451,7 +2458,7 @@ function historyMain() {
2451
2458
  });
2452
2459
 
2453
2460
  const hasErrors = state.events.some(e => e.isError);
2454
- const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' }, [
2461
+ const actions = h('div', { key: 'acts', class: 'history-actions' }, [
2455
2462
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
2456
2463
  Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy session id' }),
2457
2464
  Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
@@ -2566,7 +2573,7 @@ function historyMain() {
2566
2573
  // Rail tone matches the session/agents rail semantics so an event's
2567
2574
  // kind is visible at a glance, consistent across the GUI:
2568
2575
  // flame = error, purple = tool_use, green = normal turn.
2569
- const rail = e.isError ? 'flame' : (e.type === 'tool_use' ? 'purple' : 'green');
2576
+ const rail = e.isError ? 'flame' : (e.type === 'tool_use' ? 'flame' : 'green');
2570
2577
  // When the session was opened from a search hit, window the collapsed
2571
2578
  // title AROUND the first query match (a match at char 5000 would
2572
2579
  // otherwise be invisible behind the 0-220 slice).
@@ -2704,7 +2711,7 @@ function runningPanel() {
2704
2711
  // All children must be keyed VElements (mixing a keyed span with an
2705
2712
  // unkeyed one crashes webjsx applyDiff "reading 'key'").
2706
2713
  return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
2707
- h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
2714
+ h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc ' + (isStopping ? 'status-dot-connecting' : 'status-dot-live'), 'aria-hidden': 'true' }),
2708
2715
  h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + (elapsedMs ? ' · ' + fmtDuration(elapsedMs) : '') + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
2709
2716
  Btn({ key: 'open' + r.sessionId, onClick: () => navTo('live'), children: 'open in live' }),
2710
2717
  Btn({ key: 'stop' + r.sessionId, disabled: isStopping, onClick: () => stopActiveChat(r.sessionId), children: isStopping ? 'stopping…' : 'stop' }));
@@ -2848,6 +2855,26 @@ async function saveBackend() {
2848
2855
  }
2849
2856
  state.confirmingBackend = undefined;
2850
2857
  const canonical = normalizeBackend(state.backendDraft);
2858
+ // Probe the candidate URL before committing: fetch /health and verify it's
2859
+ // an agentgui server (status:'ok' + version field). Reject with an error if
2860
+ // the probe fails or returns a non-agentgui response.
2861
+ if (canonical) {
2862
+ state.backendStatus = 'connecting';
2863
+ render();
2864
+ try {
2865
+ const probeUrl = canonical.replace(/\/$/, '') + '/health';
2866
+ const pr = await fetch(probeUrl, { signal: AbortSignal.timeout(5000) });
2867
+ if (!pr.ok) throw new Error('server returned ' + pr.status);
2868
+ const pj = await pr.json();
2869
+ if (pj.status !== 'ok' || !pj.version) throw new Error('not an agentgui server');
2870
+ } catch (e) {
2871
+ state.backendStatus = 'failed';
2872
+ state.backendError = e.message || 'not an agentgui server';
2873
+ render();
2874
+ return;
2875
+ }
2876
+ }
2877
+ state.backendError = undefined;
2851
2878
  state.backendDraft = canonical;
2852
2879
  B.setBackend(canonical);
2853
2880
  state.backend = canonical;
@@ -2878,7 +2905,12 @@ function healthSummary() {
2878
2905
  // (not ok/down, which the connection chip already translated away). Always
2879
2906
  // render it: when /health is partial and hh.db is absent, show 'db unknown'
2880
2907
  // rather than dropping the chip and leaving the db state ambiguous.
2881
- bits.push(['db ' + (hh.db ? (hh.db.ok ? 'online' : 'offline') : 'unknown'), 'History database status']);
2908
+ // Only show 'db unknown' when health has been fetched (not on initial null/unknown state).
2909
+ if (hh.status && hh.status !== 'unknown') {
2910
+ bits.push(['db ' + (hh.db ? (hh.db.ok ? 'online' : 'offline') : 'unknown'), 'History database status']);
2911
+ } else if (hh.db) {
2912
+ bits.push(['db ' + (hh.db.ok ? 'online' : 'offline'), 'History database status']);
2913
+ }
2882
2914
  return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : ''), role: 'group', 'aria-label': 'Backend health' },
2883
2915
  ...bits.map(([b, t], i) => h('span', { key: 'hb' + i, class: 'health-chip', title: t }, b)));
2884
2916
  }
@@ -2972,7 +3004,7 @@ function serverPanel() {
2972
3004
  h('div', { key: 'spd', class: 'lede' }, 'projects folder: ' + (hh.projectsDir || 'unknown')),
2973
3005
  roots.length
2974
3006
  ? SessionMeta({ key: 'sroots', items: roots.map((r, i) => ({ label: 'root ' + (i + 1), value: r, title: r, onCopy: () => copyText(r, 'root copied') })) })
2975
- : h('div', { key: 'snoroots', class: 'lede' }, 'allowed roots: unknown'),
3007
+ : (hh.status && hh.status !== 'unknown' ? h('div', { key: 'snoroots', class: 'lede' }, 'allowed roots: none configured') : null),
2976
3008
  ],
2977
3009
  });
2978
3010
  }
@@ -3066,7 +3098,7 @@ function agentsPanel() {
3066
3098
  const usable = avail || a.npxInstallable; // selectable from this row
3067
3099
  const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
3068
3100
  if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
3069
- if (acp) bits.push(acp.healthy ? 'running healthy' : (acp.running ? 'running' : 'stopped'));
3101
+ if (acp) bits.push(acp.healthy ? 'connected' : (acp.running ? 'connecting' : 'disconnected'));
3070
3102
  if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
3071
3103
  if (acp && !acp.healthy && acp.providerInfo?.error) bits.push(acp.providerInfo.error);
3072
3104
  if (acp && acp.idleMs > 3_600_000) bits.push(fmtDuration(acp.idleMs) + ' idle');
@@ -3184,6 +3216,7 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
3184
3216
  if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
3185
3217
  state.selectedSid = sid;
3186
3218
  state.events = [];
3219
+ state.events._seen = new Set(); // O(1) dedupe by event index
3187
3220
  state.eventsLoaded = false;
3188
3221
  state.eventsSlow = false;
3189
3222
  state.eventsLimit = 300; // reset the render window per session
@@ -3202,7 +3235,9 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
3202
3235
  // Close the mobile sidebar drawer on selection. The DS only auto-closes when
3203
3236
  // the clicked element is an <a>; agentgui's session rows are onClick divs, so
3204
3237
  // we close it explicitly here.
3205
- document.querySelector('.app-body.side-open')?.classList.remove('side-open');
3238
+ // Close the WorkspaceShell mobile sessions drawer on session selection.
3239
+ if (state.wsSessions) { state.wsSessions = false; }
3240
+ document.querySelector('[data-ws-sessions-open]')?.removeAttribute('data-ws-sessions-open');
3206
3241
  render();
3207
3242
  // Bring the now-active sidebar row into view (deep-link / back-forward may
3208
3243
  // select a row that's scrolled out of the session list).