agentgui 1.0.936 → 1.0.937

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.
@@ -20,7 +20,11 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
20
20
  if (hits > RATE_LIMIT_MAX) { res.writeHead(429, { 'Retry-After': '60' }); res.end('Too Many Requests'); return; }
21
21
 
22
22
  const _pwd = process.env.PASSWORD;
23
- if (_pwd) {
23
+ // Optional: exempt /health from auth so container/k8s probes work
24
+ // without distributing the password to monitoring infra.
25
+ const _bareEarly = req.url.split('?')[0];
26
+ const _healthExempt = process.env.HEALTH_NO_AUTH === '1' && (_bareEarly === '/health' || _bareEarly === '/api/health' || _bareEarly === (BASE_URL + '/health') || _bareEarly === (BASE_URL + '/api/health'));
27
+ if (_pwd && !_healthExempt) {
24
28
  const _auth = req.headers['authorization'] || '';
25
29
  let _ok = false;
26
30
  const _checkToken = (tok) => {
@@ -51,6 +51,7 @@ export function register(router, deps) {
51
51
  const model = p?.model || undefined;
52
52
  const subAgent = p?.subAgent || undefined;
53
53
  const cwd = p?.cwd || STARTUP_CWD;
54
+ const resumeSessionId = p?.resumeSid || p?.resumeSessionId || undefined;
54
55
  if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
55
56
 
56
57
  const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
@@ -88,7 +89,7 @@ export function register(router, deps) {
88
89
  try {
89
90
  const config = {
90
91
  verbose: true, outputFormat: 'stream-json', timeout: 1800000, print: true,
91
- model, subAgent, onEvent,
92
+ model, subAgent, onEvent, resumeSessionId,
92
93
  onPid: () => {}, onProcess: (proc) => { ctrl.proc = proc; },
93
94
  };
94
95
  await runClaudeWithStreaming(content, cwd, agentId, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.936",
3
+ "version": "1.0.937",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -12,7 +12,7 @@ const state = {
12
12
  tab: 'chat',
13
13
  models: [],
14
14
  selectedModel: '',
15
- chat: { messages: [], busy: false, abort: null, draft: '' },
15
+ chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
16
16
  sessions: [],
17
17
  selectedSid: null,
18
18
  events: [],
@@ -20,7 +20,9 @@ const state = {
20
20
  searchHits: null,
21
21
  historyError: null,
22
22
  showSubagents: false,
23
- live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0 },
23
+ sessionsLimit: 60,
24
+ projectFilter: '',
25
+ live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0 },
24
26
  };
25
27
 
26
28
  function readHash() {
@@ -74,12 +76,14 @@ function openLiveStream() {
74
76
  state.live.lastEventTs = Date.now();
75
77
  state.live.eventCount++;
76
78
  if (kind === 'hello') {
77
- state.live.connected = true;
79
+ if (!state.live.connected) state.live.connected = true;
80
+ if (state.live.error) { state.live.error = null; state.live.reconnects++; }
78
81
  } else if (kind === 'event' && data) {
79
82
  if (state.selectedSid && data.sid === state.selectedSid) {
80
83
  state.events.push(data);
81
84
  }
82
- const sess = state.sessions.find(s => s.sid === data.sid);
85
+ const arr = Array.isArray(state.sessions) ? state.sessions : [];
86
+ const sess = arr.find(s => s.sid === data.sid);
83
87
  if (sess) {
84
88
  sess.events = (sess.events || 0) + 1;
85
89
  sess.last = data.ts || Date.now();
@@ -98,9 +102,12 @@ function openLiveStream() {
98
102
  scheduleRender();
99
103
  });
100
104
  state.live.es.addEventListener('error', () => {
101
- state.live.connected = false;
102
- state.live.error = 'connection lost';
103
- scheduleRender();
105
+ // EventSource auto-reconnects; only flap state once per disconnect.
106
+ if (!state.live.error) {
107
+ state.live.connected = false;
108
+ state.live.error = 'connection lost (auto-retry)';
109
+ scheduleRender();
110
+ }
104
111
  });
105
112
  } catch (e) {
106
113
  state.live.error = e.message;
@@ -119,8 +126,10 @@ function view() {
119
126
  const ok = state.health.status === 'ok';
120
127
  const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
121
128
  const dotText = state.tab === 'history'
122
- ? (state.live.error ? '○ stream offline' : (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
123
- : (ok ? ' connected' : ' offline');
129
+ ? (state.live.error
130
+ ? '◌ ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
131
+ : (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
132
+ : (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
124
133
  const dot = h('span', { key: 'dot' }, dotText);
125
134
 
126
135
  const topbar = Topbar({
@@ -211,19 +220,25 @@ function chatMain() {
211
220
  onSend: (v) => { state.chat.draft = v; sendChat(); },
212
221
  });
213
222
 
223
+ const resumeBanner = state.chat.resumeSid
224
+ ? h('div', { key: 'rb', style: 'padding:.5em .75em;background:rgba(80,200,120,.1);border-radius:4px;display:flex;justify-content:space-between;align-items:center;margin-bottom:.5em' },
225
+ h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via claude --resume'),
226
+ Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' }))
227
+ : null;
214
228
  return [
229
+ resumeBanner,
215
230
  Chat({
216
- title: state.selectedModel || 'agent',
231
+ title: (state.selectedModel || 'agent') + (state.chat.resumeSid ? ' · resume' : ''),
217
232
  sub: state.chat.busy ? 'streaming…' : (state.chat.messages.length + ' messages'),
218
233
  messages: msgs,
219
234
  composer,
220
235
  }),
221
- ];
236
+ ].filter(Boolean);
222
237
  }
223
238
 
224
239
  function newChat() {
225
240
  state.chat.abort?.abort();
226
- state.chat = { messages: [], busy: false, abort: null, draft: '' };
241
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
227
242
  render();
228
243
  }
229
244
 
@@ -246,6 +261,7 @@ async function sendChat() {
246
261
  model: state.selectedModel,
247
262
  messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
248
263
  signal: ctrl.signal,
264
+ resumeSid: state.chat.resumeSid || undefined,
249
265
  })) {
250
266
  if (ev.type === 'text') { cur.content += ev.text; render(); }
251
267
  if (ev.type === 'error') { cur.content += '\n[error] ' + JSON.stringify(ev.error); render(); }
@@ -314,21 +330,42 @@ function historyMain() {
314
330
  function resumeInChat(sess) {
315
331
  state.tab = 'chat';
316
332
  closeLiveStream();
317
- state.chat.draft = '/resume ' + (sess?.sid || state.selectedSid);
333
+ state.chat.resumeSid = sess?.sid || state.selectedSid;
334
+ state.chat.messages = [];
335
+ state.chat.draft = '';
336
+ // Default to claude-code if no model yet (only claude supports --resume by sid here).
337
+ if (!state.selectedModel || state.selectedModel !== 'claude-code') state.selectedModel = 'claude-code';
318
338
  render();
319
339
  }
320
340
 
321
341
  function visibleSessions() {
322
342
  const arr = Array.isArray(state.sessions) ? state.sessions : [];
323
- const filtered = state.showSubagents ? arr : arr.filter(s => !s.isSubagent);
343
+ let filtered = state.showSubagents ? arr : arr.filter(s => !s.isSubagent);
344
+ if (state.projectFilter) {
345
+ const pf = state.projectFilter.toLowerCase();
346
+ filtered = filtered.filter(s => (s.project || '').toLowerCase().includes(pf));
347
+ }
324
348
  return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
325
349
  }
326
350
 
351
+ function uniqueProjects() {
352
+ const arr = Array.isArray(state.sessions) ? state.sessions : [];
353
+ const seen = new Map();
354
+ for (const s of arr) {
355
+ if (!s.project) continue;
356
+ seen.set(s.project, (seen.get(s.project) || 0) + 1);
357
+ }
358
+ return Array.from(seen.entries()).sort((a, b) => b[1] - a[1]);
359
+ }
360
+
327
361
  function historySide() {
328
362
  const searching = !!state.searchHits;
329
363
  const sessionsView = visibleSessions();
364
+ const limit = state.sessionsLimit;
365
+ const visible = searching ? state.searchHits.results.slice(0, 60) : sessionsView.slice(0, limit);
366
+ const truncatedBy = searching ? Math.max(0, state.searchHits.results.length - 60) : Math.max(0, sessionsView.length - limit);
330
367
  const rows = searching
331
- ? state.searchHits.results.slice(0, 60).map((r, i) =>
368
+ ? visible.map((r, i) =>
332
369
  Row({
333
370
  key: 'sr' + i,
334
371
  rank: String(i + 1).padStart(3, '0'),
@@ -338,7 +375,7 @@ function historySide() {
338
375
  onClick: () => loadSession(r.sid),
339
376
  })
340
377
  )
341
- : sessionsView.slice(0, 120).map((s, i) =>
378
+ : visible.map((s, i) =>
342
379
  Row({
343
380
  key: 'sess' + s.sid,
344
381
  rank: String(i + 1).padStart(3, '0'),
@@ -350,6 +387,7 @@ function historySide() {
350
387
  })
351
388
  );
352
389
  const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
390
+ const projects = uniqueProjects();
353
391
 
354
392
  return [
355
393
  Side({
@@ -373,6 +411,15 @@ function historySide() {
373
411
  value: state.searchQ,
374
412
  onInput: (v) => { state.searchQ = v; runSearch(); },
375
413
  }),
414
+ state.searchQ && searching
415
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; render(); }, children: '× clear search' })
416
+ : null,
417
+ !searching && projects.length > 1
418
+ ? h('div', { key: 'projfilter', style: 'display:flex;flex-wrap:wrap;gap:.25em;padding:.25em 0' },
419
+ h('span', { key: 'allp', class: 'lede', style: 'cursor:pointer;padding:.15em .5em;border-radius:3px;' + (!state.projectFilter ? 'background:rgba(80,200,120,.15)' : ''), onClick: () => { state.projectFilter = ''; render(); } }, 'all'),
420
+ ...projects.slice(0, 8).map(([name, count]) =>
421
+ h('span', { key: 'p'+name, class: 'lede', style: 'cursor:pointer;padding:.15em .5em;border-radius:3px;' + (state.projectFilter === name ? 'background:rgba(80,200,120,.15)' : ''), title: name, onClick: () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); } }, (name.length > 20 ? name.slice(0, 20) + '…' : name) + ' (' + count + ')')))
422
+ : null,
376
423
  !searching && subagentCount
377
424
  ? h('label', { key: 'subtog', class: 'lede', style: 'display:flex;gap:.5em;align-items:center;padding:.25em 0' },
378
425
  h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
@@ -381,6 +428,9 @@ function historySide() {
381
428
  state.historyError
382
429
  ? h('p', { key: 'err', class: 'lede' }, '⚠ ' + state.historyError)
383
430
  : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede' }, 'no sessions yet')),
431
+ !searching && truncatedBy > 0
432
+ ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: '↓ show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
433
+ : null,
384
434
  ],
385
435
  }),
386
436
  ];
@@ -87,11 +87,31 @@ let _wsReady = null; // Promise that resolves when ws is OPEN
87
87
  let _nextReqId = 1;
88
88
  const _pending = new Map(); // requestId → { resolve, reject }
89
89
  const _sessionListeners = new Map(); // sessionId → Set<(event)=>void>
90
- const _statusListeners = new Set(); // fn(state) where state in 'open'|'closed'|'error'
90
+ const _statusListeners = new Set(); // fn(state) where state in 'open'|'closed'|'error'|'reconnecting'
91
+ let _reconnectAttempts = 0;
92
+ let _reconnectTimer = null;
93
+ let _wsBaseHint = ''; // base remembered for reconnect
91
94
 
92
95
  export function onWsStatus(fn) { _statusListeners.add(fn); return () => _statusListeners.delete(fn); }
93
96
  function emitStatus(s) { for (const fn of _statusListeners) { try { fn(s); } catch {} } }
94
97
 
98
+ function scheduleReconnect() {
99
+ if (_reconnectTimer) return;
100
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
101
+ // Wait for online before retrying.
102
+ const onOnline = () => { window.removeEventListener('online', onOnline); _reconnectAttempts = 0; ensureWs(_wsBaseHint).catch(() => {}); };
103
+ window.addEventListener('online', onOnline);
104
+ return;
105
+ }
106
+ const delay = Math.min(30000, 500 * Math.pow(2, _reconnectAttempts));
107
+ _reconnectAttempts++;
108
+ emitStatus('reconnecting');
109
+ _reconnectTimer = setTimeout(() => {
110
+ _reconnectTimer = null;
111
+ ensureWs(_wsBaseHint).catch(() => {});
112
+ }, delay);
113
+ }
114
+
95
115
  function wsUrl(base) {
96
116
  let proto, host;
97
117
  if (base) {
@@ -110,11 +130,20 @@ function wsUrl(base) {
110
130
  }
111
131
 
112
132
  function ensureWs(base) {
133
+ _wsBaseHint = base || _wsBaseHint;
113
134
  if (_ws && _ws.readyState === 1) return _wsReady;
114
135
  if (_ws && _ws.readyState === 0) return _wsReady;
115
- _ws = new WebSocket(wsUrl(base));
136
+ _ws = new WebSocket(wsUrl(_wsBaseHint));
116
137
  _wsReady = new Promise((resolve, reject) => {
117
- _ws.addEventListener('open', () => { emitStatus('open'); resolve(_ws); });
138
+ _ws.addEventListener('open', () => {
139
+ _reconnectAttempts = 0;
140
+ emitStatus('open');
141
+ // Re-subscribe any session listeners that survived the disconnect.
142
+ for (const sid of _sessionListeners.keys()) {
143
+ try { _ws.send(encode({ m: 'conversation.subscribe', r: _nextReqId++, p: { sessionId: sid } })); } catch {}
144
+ }
145
+ resolve(_ws);
146
+ });
118
147
  _ws.addEventListener('error', (e) => { emitStatus('error'); reject(e); });
119
148
  _ws.addEventListener('close', () => {
120
149
  emitStatus('closed');
@@ -122,6 +151,8 @@ function ensureWs(base) {
122
151
  _pending.clear();
123
152
  _ws = null;
124
153
  _wsReady = null;
154
+ // Auto-reconnect if there are listeners or callers will retry.
155
+ if (_sessionListeners.size > 0 || _statusListeners.size > 0) scheduleReconnect();
125
156
  });
126
157
  _ws.addEventListener('message', (ev) => {
127
158
  let msg;
@@ -187,7 +218,7 @@ export async function listModels(base) {
187
218
  // { type: 'error', error: '...' }
188
219
  //
189
220
  // Caller signature kept compatible with the previous HTTP/SSE impl.
190
- export async function* streamChat(base, { model, messages, signal, agentId }) {
221
+ export async function* streamChat(base, { model, messages, signal, agentId, resumeSid }) {
191
222
  // The last user message is the prompt; agentgui's claude-runner doesn't
192
223
  // accept a full message list — it spawns the agent for a single prompt.
193
224
  // For multi-turn, the agent's own session/resume handles continuity.
@@ -221,7 +252,7 @@ export async function* streamChat(base, { model, messages, signal, agentId }) {
221
252
  // Kick off the chat on the server.
222
253
  let started;
223
254
  try {
224
- started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel });
255
+ started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel, resumeSid });
225
256
  } catch (e) {
226
257
  yield { type: 'error', error: e.message };
227
258
  return;