agentgui 1.0.980 → 1.0.982
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/lib/http-handler.js +13 -2
- package/package.json +1 -1
- package/site/app/index.html +1 -0
- package/site/app/js/app.js +49 -12
package/lib/http-handler.js
CHANGED
|
@@ -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
|
-
|
|
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
package/site/app/index.html
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
247420.css bundle, scoped under .ds-247420. The kit owns all design. -->
|
|
31
31
|
</head>
|
|
32
32
|
<body>
|
|
33
|
+
<a href="#app" class="skip-link">Skip to main content</a>
|
|
33
34
|
<div id="app"><div class="boot-splash" role="status">loading agentgui…</div></div>
|
|
34
35
|
<script type="module" src="./js/app.js"></script>
|
|
35
36
|
</body>
|
package/site/app/js/app.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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(
|
|
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'
|
|
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' ? '
|
|
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
|
-
|
|
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:
|
|
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 ? '
|
|
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');
|
|
@@ -3078,7 +3110,9 @@ function agentsPanel() {
|
|
|
3078
3110
|
// Rail tone keeps its GUI-wide meaning: green=ok/selected,
|
|
3079
3111
|
// flame=error/unavailable. Selection is shown via `active`, not by
|
|
3080
3112
|
// borrowing purple (purple is reserved for subagents).
|
|
3081
|
-
|
|
3113
|
+
// Flame also covers ACP agents that are installed but running-unhealthy
|
|
3114
|
+
// (provider auth error, etc.) so the error state is visually distinct.
|
|
3115
|
+
rail: (!avail || (acp && !acp.healthy)) ? 'flame' : (a.id === state.selectedAgent ? 'green' : undefined),
|
|
3082
3116
|
active: a.id === state.selectedAgent,
|
|
3083
3117
|
// Non-installable agents are genuinely inert: mark them disabled (no
|
|
3084
3118
|
// click, no button role) instead of looking clickable but doing nothing.
|
|
@@ -3184,6 +3218,7 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
|
|
|
3184
3218
|
if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
|
|
3185
3219
|
state.selectedSid = sid;
|
|
3186
3220
|
state.events = [];
|
|
3221
|
+
state.events._seen = new Set(); // O(1) dedupe by event index
|
|
3187
3222
|
state.eventsLoaded = false;
|
|
3188
3223
|
state.eventsSlow = false;
|
|
3189
3224
|
state.eventsLimit = 300; // reset the render window per session
|
|
@@ -3202,7 +3237,9 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
|
|
|
3202
3237
|
// Close the mobile sidebar drawer on selection. The DS only auto-closes when
|
|
3203
3238
|
// the clicked element is an <a>; agentgui's session rows are onClick divs, so
|
|
3204
3239
|
// we close it explicitly here.
|
|
3205
|
-
|
|
3240
|
+
// Close the WorkspaceShell mobile sessions drawer on session selection.
|
|
3241
|
+
if (state.wsSessions) { state.wsSessions = false; }
|
|
3242
|
+
document.querySelector('[data-ws-sessions-open]')?.removeAttribute('data-ws-sessions-open');
|
|
3206
3243
|
render();
|
|
3207
3244
|
// Bring the now-active sidebar row into view (deep-link / back-forward may
|
|
3208
3245
|
// select a row that's scrolled out of the session list).
|