@aiwg/cockpit 2026.7.11 → 2026.7.13

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.
@@ -4,17 +4,34 @@ import { fmtId, capRef } from '../util';
4
4
  import { CapabilitySearch } from './CapabilitySearch';
5
5
  import type { Instance, SessionInfo, CapabilityResult } from '../types';
6
6
  import type { SessionApi } from '../useSession';
7
+ import { markRegistrySessionViewed, sessionRegistryKeyFor, setRegistryActiveSession, upsertRegistrySessions, useSessionRegistry } from '../sessionRegistry';
8
+ import { useSessionSnapshotMonitor } from '../sessionMonitor';
7
9
 
8
10
  export function Sessions({ session, composer, setComposer, onRequestStart, refreshMs = 5_000 }: { session: SessionApi; composer: string; setComposer: (v: string) => void; onRequestStart: (instanceId?: string) => void; refreshMs?: number }) {
9
11
  const [instances, setInstances] = useState<Instance[]>([]);
10
12
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
11
13
  const [instId, setInstId] = useState('');
12
- const [attachUrl, setAttachUrl] = useState('');
14
+ const [selectedSessionKey, setSelectedSessionKey] = useState('');
13
15
  const [backendKey, setBackendKey] = useState('');
14
16
  const [showPicker, setShowPicker] = useState(false);
15
17
  const [endingSession, setEndingSession] = useState('');
18
+ const [reconnectingInstance, setReconnectingInstance] = useState('');
16
19
  const [sessionErr, setSessionErr] = useState('');
20
+ const [attachedInstanceId, setAttachedInstanceId] = useState('');
21
+ const [attachedSessionId, setAttachedSessionId] = useState('');
22
+ const instIdRef = useRef('');
23
+ const attachedRef = useRef(false);
24
+ const attachedOwnerRef = useRef('');
25
+ const inventorySeqRef = useRef(0);
26
+ const sessionsSeqRef = useRef(0);
27
+ const missingAttachedPollsRef = useRef(0);
17
28
  const inputRef = useRef<HTMLInputElement>(null);
29
+ const sessionRegistry = useSessionRegistry();
30
+
31
+ useSessionSnapshotMonitor();
32
+
33
+ useEffect(() => { instIdRef.current = instId; }, [instId]);
34
+ useEffect(() => { attachedRef.current = session.state.attached; }, [session.state.attached]);
18
35
 
19
36
  const insertCap = (r: CapabilityResult) => {
20
37
  const sep = composer && !composer.endsWith(' ') ? ' ' : '';
@@ -23,66 +40,152 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
23
40
  inputRef.current?.focus();
24
41
  };
25
42
 
26
- const refreshInventory = useCallback(() => {
27
- return api<{ instances: Instance[] }>('/api/inventory')
28
- .then((d) => {
29
- const sessionable = dedupeInstances(d.instances).filter((i) => i.state === 'running' && i.session_backends?.some((b) => b.available));
30
- setInstances(sessionable);
31
- setInstId((currentId) => {
32
- if (currentId && sessionable.some((i) => i.id === currentId)) return currentId;
33
- return sessionable[0]?.id ?? '';
34
- });
35
- setBackendKey((currentBackend) => {
36
- if (currentBackend && sessionable.some((i) => i.session_backends.some((b) => `${b.mode}:${b.backend}` === currentBackend && b.available))) return currentBackend;
37
- const firstBackend = sessionable[0]?.session_backends.find((b) => b.available) ?? sessionable[0]?.session_backends[0];
38
- return firstBackend ? `${firstBackend.mode}:${firstBackend.backend}` : '';
39
- });
40
- })
41
- .catch(() => {});
43
+ const refreshInventory = useCallback(async () => {
44
+ const seq = inventorySeqRef.current + 1;
45
+ inventorySeqRef.current = seq;
46
+ const d = await api<{ instances: Instance[] }>('/api/inventory');
47
+ if (seq !== inventorySeqRef.current) return;
48
+ const sessionable = dedupeInstances(d.instances).filter((i) => i.state === 'running' && i.session_backends?.length);
49
+ setInstances(sessionable);
50
+ const currentId = instIdRef.current;
51
+ let nextId = sessionable[0]?.id ?? '';
52
+ if (currentId && sessionable.some((i) => i.id === currentId)) nextId = currentId;
53
+ else if (attachedRef.current && currentId) nextId = currentId;
54
+ instIdRef.current = nextId;
55
+ setInstId(nextId);
56
+ setBackendKey((currentBackend) => {
57
+ const selectedInstance = sessionable.find((i) => i.id === nextId) ?? sessionable[0];
58
+ if (currentBackend && selectedInstance?.session_backends.some((b) => `${b.mode}:${b.backend}` === currentBackend && b.available !== false)) return currentBackend;
59
+ if (attachedRef.current && currentBackend) return currentBackend;
60
+ const firstBackend = selectedInstance?.session_backends.find((b) => b.available !== false) ?? selectedInstance?.session_backends[0];
61
+ return firstBackend ? `${firstBackend.mode}:${firstBackend.backend}` : '';
62
+ });
42
63
  }, []);
43
64
 
44
65
  useEffect(() => {
45
- refreshInventory();
46
- const timer = window.setInterval(refreshInventory, refreshMs);
47
- return () => window.clearInterval(timer);
48
- }, [refreshInventory, refreshMs]);
66
+ attachedOwnerRef.current = attachedInstanceId || instanceIdFromAttachUrl(session.state.url);
67
+ }, [attachedInstanceId, session.state.url]);
49
68
 
50
- const loadSessions = useCallback((id: string) => {
69
+ const loadSessions = useCallback(async (id: string) => {
51
70
  if (!id) return;
52
- api<{ sessions: SessionInfo[] }>(`/api/sessions?instance=${encodeURIComponent(id)}`)
53
- .then((d) => {
54
- const nextSessions = d.sessions ?? [];
55
- setSessions(nextSessions);
56
- setSessionErr('');
57
- setAttachUrl((currentUrl) => {
58
- if (currentUrl && nextSessions.some((s) => s.attach_url === currentUrl)) return currentUrl;
59
- return nextSessions[0]?.attach_url ?? '';
60
- });
61
- })
62
- .catch((e) => { setSessions([]); setAttachUrl(''); setSessionErr((e as Error).message); });
71
+ const seq = sessionsSeqRef.current + 1;
72
+ sessionsSeqRef.current = seq;
73
+ const d = await api<{ sessions: SessionInfo[] }>(`/api/sessions?instance=${encodeURIComponent(id)}`);
74
+ if (seq !== sessionsSeqRef.current || id !== instIdRef.current) return;
75
+ const nextSessions = d.sessions ?? [];
76
+ upsertRegistrySessions(nextSessions);
77
+ setSessions(nextSessions);
78
+ setSessionErr('');
79
+ setSelectedSessionKey((currentKey) => {
80
+ if (currentKey && nextSessions.some((s) => sessionKey(s) === currentKey)) return currentKey;
81
+ if (attachedRef.current && attachedOwnerRef.current === id && currentKey) return currentKey;
82
+ return nextSessions[0] ? sessionKey(nextSessions[0]) : '';
83
+ });
63
84
  }, []);
64
- // Reload the selected instance's sessions on selection change AND on an interval,
65
- // so a session created elsewhere (the Start modal, the Running board, another
66
- // operator) shows up in the nav without re-selecting the instance.
85
+
86
+ useEffect(() => {
87
+ let cancelled = false;
88
+ let timer: number | undefined;
89
+ let delay = refreshMs;
90
+ const schedule = (ms: number) => {
91
+ timer = window.setTimeout(tick, ms);
92
+ };
93
+ const tick = async () => {
94
+ if (cancelled) return;
95
+ if (endingSession) {
96
+ schedule(refreshMs);
97
+ return;
98
+ }
99
+ try {
100
+ const idBeforeInventory = instIdRef.current;
101
+ await refreshInventory();
102
+ if (idBeforeInventory && idBeforeInventory === instIdRef.current) await loadSessions(instIdRef.current);
103
+ delay = refreshMs;
104
+ } catch (e) {
105
+ if (!cancelled) setSessionErr((e as Error).message);
106
+ delay = Math.min(Math.max(refreshMs, delay * 2), 30_000);
107
+ }
108
+ if (!cancelled) schedule(delay);
109
+ };
110
+ tick();
111
+ return () => {
112
+ cancelled = true;
113
+ if (timer !== undefined) window.clearTimeout(timer);
114
+ };
115
+ }, [endingSession, loadSessions, refreshInventory, refreshMs]);
116
+
67
117
  useEffect(() => {
68
118
  if (!instId) return;
69
- loadSessions(instId);
70
- const timer = window.setInterval(() => loadSessions(instId), refreshMs);
71
- return () => window.clearInterval(timer);
72
- }, [instId, loadSessions, refreshMs]);
119
+ // Clear the previous instance's rows immediately so a slow load never shows
120
+ // the wrong instance's sessions in the nav during the switch.
121
+ setSessions([]);
122
+ setSessionErr('');
123
+ loadSessions(instId).catch((e) => setSessionErr((e as Error).message));
124
+ }, [instId, loadSessions]);
73
125
 
74
- const send = () => { if (session.sendInput(composer)) setComposer(''); };
75
126
  const attached = session.state.attached;
76
127
  const requestedReplayRole = session.state.role === 'controller' ? 'controller' : 'observer';
77
128
  const current = instances.find((i) => i.id === instId);
78
129
  const backends = current?.session_backends ?? [];
79
- const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey) ?? backends.find((b) => b.available) ?? backends[0];
80
- const selectedSession = sessions.find((s) => s.attach_url === attachUrl);
130
+ const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey) ?? backends.find((b) => b.available !== false) ?? backends[0];
131
+ const currentUnavailableReason = backends.find((b) => b.available === false)?.reason;
132
+ const currentReconnectable = current ? isReconnectable(current) : false;
133
+ const selectedSession = sessions.find((s) => sessionKey(s) === selectedSessionKey);
134
+ const attachedOwner = attachedInstanceId || instanceIdFromAttachUrl(session.state.url);
135
+ const attachedKey = attachedOwner && attachedSessionId ? `${attachedOwner}:${attachedSessionId}` : sessionKeyFromAttachUrl(session.state.url);
136
+ const activeTarget = session.state.target ?? (attachedOwner && attachedSessionId ? { instanceId: attachedOwner, sessionId: attachedSessionId } : null);
137
+ // Merge the currently-attached session into the nav even when the executor's
138
+ // session-list API omits it. Host-runtime PTY sessions are not returned by
139
+ // list_sessions (agentic-sandbox #500 follow-up), so a live, attached session
140
+ // would otherwise render as "No sessions yet". The synthetic row reuses the
141
+ // attach URL Cockpit already holds so selecting it re-attaches/replays.
142
+ const displaySessions: SessionInfo[] = (attached && attachedOwner === instId && attachedSessionId
143
+ && !sessions.some((s) => sessionKey(s) === attachedKey))
144
+ ? [...sessions, {
145
+ id: attachedSessionId,
146
+ instance_id: instId,
147
+ attach_url: session.state.url ?? '',
148
+ session_name: 'attached session',
149
+ session_backend: selectedBackend?.backend,
150
+ session_class: selectedBackend?.mode,
151
+ }]
152
+ : sessions;
153
+ const send = () => { if (session.sendInput(composer, activeTarget)) setComposer(''); };
154
+ const attachToSession = (s: SessionInfo, role: 'controller' | 'observer') => {
155
+ setAttachedInstanceId(s.instance_id || instId);
156
+ setAttachedSessionId(String(s.id));
157
+ setRegistryActiveSession(s.instance_id || instId, String(s.id));
158
+ session.attach(s.attach_url, false, role, { instanceId: s.instance_id || instId, sessionId: String(s.id) });
159
+ };
160
+ const replaySession = (s: SessionInfo, role: 'controller' | 'observer') => {
161
+ setAttachedInstanceId(s.instance_id || instId);
162
+ setAttachedSessionId(String(s.id));
163
+ setRegistryActiveSession(s.instance_id || instId, String(s.id));
164
+ session.replay(s.attach_url, role, { instanceId: s.instance_id || instId, sessionId: String(s.id) });
165
+ };
166
+ const detachSession = () => {
167
+ setAttachedInstanceId('');
168
+ setAttachedSessionId('');
169
+ setRegistryActiveSession(null, null);
170
+ session.detach();
171
+ };
172
+ useEffect(() => {
173
+ const selected = sessions.find((s) => sessionKey(s) === selectedSessionKey);
174
+ if (selected) markRegistrySessionViewed(selected.instance_id, String(selected.id));
175
+ }, [selectedSessionKey, sessions]);
81
176
  useEffect(() => {
82
177
  if (!session.state.url) return;
83
- const sessionStillListed = sessions.some((s) => s.attach_url === session.state.url);
84
- if (sessions.length && !sessionStillListed) session.detach();
85
- }, [session.state.url, session.detach, sessions]);
178
+ if (!attachedOwner || attachedOwner !== instId) return;
179
+ const sessionStillListed = sessions.some((s) => sessionKey(s) === attachedKey);
180
+ if (sessionStillListed) {
181
+ missingAttachedPollsRef.current = 0;
182
+ return;
183
+ }
184
+ if (sessions.length) {
185
+ missingAttachedPollsRef.current += 1;
186
+ if (missingAttachedPollsRef.current >= 2) detachSession();
187
+ }
188
+ }, [attachedKey, attachedOwner, instId, session.state.url, sessions]);
86
189
  useEffect(() => {
87
190
  if (!current) return;
88
191
  const valid = current.session_backends.some((b) => `${b.mode}:${b.backend}` === backendKey);
@@ -103,7 +206,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
103
206
  setSessionErr('');
104
207
  try {
105
208
  await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
106
- if (session.state.url === s.attach_url) session.detach();
209
+ if (sessionKey(s) === attachedKey) detachSession();
107
210
  await loadSessions(current.id);
108
211
  } catch (e) {
109
212
  setSessionErr((e as Error).message);
@@ -111,6 +214,20 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
111
214
  setEndingSession('');
112
215
  }
113
216
  };
217
+ const reconnectCurrent = async () => {
218
+ if (!current) return;
219
+ setReconnectingInstance(current.id);
220
+ setSessionErr('');
221
+ try {
222
+ await api(`/api/instances/${encodeURIComponent(current.id)}/reconnect`, { method: 'POST' });
223
+ await refreshInventory();
224
+ await loadSessions(current.id);
225
+ } catch (e) {
226
+ setSessionErr((e as Error).message);
227
+ } finally {
228
+ setReconnectingInstance('');
229
+ }
230
+ };
114
231
 
115
232
  return (
116
233
  <>
@@ -125,7 +242,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
125
242
  <h2>Instances</h2>
126
243
  <span className="hint">{instances.length}</span>
127
244
  </div>
128
- {!instances.length && <p className="empty">No session-capable running instances.</p>}
245
+ {!instances.length && <p className="empty">No running instances with session metadata.</p>}
129
246
  <ul className="nav-list">
130
247
  {instances.map((i) => {
131
248
  const isSel = i.id === instId;
@@ -139,11 +256,17 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
139
256
  </button>
140
257
  {isSel && (
141
258
  <div className="nav-sessions">
142
- {sessions.length === 0 && <p className="empty nav-empty">No sessions yet.</p>}
259
+ {displaySessions.length === 0 && (
260
+ <p className="empty nav-empty">
261
+ {isReconnectable(i) ? 'Agent unreachable; reconnect to recover existing sessions.' : 'No sessions yet.'}
262
+ </p>
263
+ )}
143
264
  <ul>
144
- {sessions.map((s) => {
145
- const selS = s.attach_url === attachUrl;
146
- const live = session.state.url === s.attach_url && attached;
265
+ {displaySessions.map((s) => {
266
+ const key = sessionKey(s);
267
+ const selS = key === selectedSessionKey;
268
+ const live = key === attachedKey && attached;
269
+ const registryEntry = sessionRegistry.entries[sessionRegistryKeyFor(s)];
147
270
  return (
148
271
  <li key={s.id}>
149
272
  <button
@@ -154,11 +277,11 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
154
277
  // Docker/tmux streams repaint and control is reasserted.
155
278
  onClick={() => {
156
279
  const role = session.state.role === 'controller' && selectedBackend?.drive !== false ? 'controller' : 'observer';
157
- setAttachUrl(s.attach_url);
158
- if (s.attach_url === session.state.url && attached) {
159
- session.replay(s.attach_url, role);
280
+ setSelectedSessionKey(key);
281
+ if (key === attachedKey && attached) {
282
+ replaySession(s, role);
160
283
  } else {
161
- session.attach(s.attach_url, false, role);
284
+ attachToSession(s, role);
162
285
  }
163
286
  }}
164
287
  title={s.id}
@@ -166,6 +289,8 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
166
289
  <span className="nav-session-name">{sessionLabel(s)}</span>
167
290
  <span className="nav-session-meta">{sessionMeta(s)}</span>
168
291
  {sessionHoldsController(s) && <span className="badge controller" title="A controller is connected">ctrl</span>}
292
+ {registryEntry?.unread && <span className="badge unread" title="Unread output">unread</span>}
293
+ {registryEntry?.responseNeeded.needed && <span className="badge response" title="Response needed">response</span>}
169
294
  {live && <span className="badge live-dot" title="Attached here">●</span>}
170
295
  </button>
171
296
  <button
@@ -181,7 +306,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
181
306
  );
182
307
  })}
183
308
  </ul>
184
- <button className="cta-sm nav-new-session" disabled={backends.length > 0 && !selectedBackend?.available} onClick={() => onRequestStart(i.id)}>+ New session</button>
309
+ <button className="cta-sm nav-new-session" disabled={backends.length > 0 && selectedBackend?.available === false} onClick={() => onRequestStart(i.id)}>+ New session</button>
185
310
  </div>
186
311
  )}
187
312
  </li>
@@ -201,13 +326,18 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
201
326
  </>
202
327
  )}
203
328
  <span className="controls-active" title={selectedSession?.id}>{selectedSession ? sessionLabel(selectedSession) : '— no session selected —'}</span>
204
- <button disabled={!attachUrl || (attached && session.state.role === 'observer')} onClick={() => session.attach(attachUrl, false, 'observer')}>Observe</button>
205
- <button disabled={!attachUrl || selectedBackend?.drive === false || (attached && session.state.role === 'controller')} onClick={() => session.attach(attachUrl, false, 'controller')}>
329
+ <button disabled={!selectedSession || (attached && session.state.role === 'observer')} onClick={() => selectedSession && attachToSession(selectedSession, 'observer')}>Observe</button>
330
+ <button disabled={!selectedSession || selectedBackend?.drive === false || (attached && session.state.role === 'controller')} onClick={() => selectedSession && attachToSession(selectedSession, 'controller')}>
206
331
  {attached && session.state.role === 'observer' ? 'Take Control' : 'Drive'}
207
332
  </button>
208
333
  <button disabled={!attached || selectedBackend?.keyframe === false} onClick={session.requestKeyframe}>Keyframe</button>
209
- <button disabled={!attached} onClick={() => session.replay(attachUrl, requestedReplayRole)}>Reattach + replay</button>
210
- <button disabled={!attached} onClick={session.detach}>Detach</button>
334
+ <button disabled={!attached} onClick={() => selectedSession && replaySession(selectedSession, requestedReplayRole)}>Reattach + replay</button>
335
+ <button disabled={!attached} onClick={detachSession}>Detach</button>
336
+ {currentReconnectable && (
337
+ <button disabled={reconnectingInstance === current?.id} onClick={reconnectCurrent}>
338
+ {reconnectingInstance === current?.id ? 'Reconnecting…' : 'Reconnect'}
339
+ </button>
340
+ )}
211
341
  {session.state.role && <span className={`badge ${session.state.role}`}>{session.state.role}</span>}
212
342
  </div>
213
343
  {sessionErr && <p className="err">Session action failed: {sessionErr}</p>}
@@ -216,6 +346,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
216
346
  {current.runtime_posture.label} · {current.transport.label} ({current.transport.mode}) · attach starts as observe unless control is explicitly granted.
217
347
  {attached && session.state.role === 'observer' ? ' Click Take Control to re-attach with write access.' : ''}
218
348
  {selectedBackend && !selectedBackend.available ? ` ${selectedBackend.reason ?? 'Selected backend is unavailable.'}` : ''}
349
+ {currentReconnectable ? ` Agent is unreachable while the runtime is still running. ${currentUnavailableReason ?? 'Reconnect can re-register the agent without restarting the instance.'}` : ''}
219
350
  </p>
220
351
  )}
221
352
  <div className="terminal" ref={session.openTerminal} role="log" aria-label="Session output" />
@@ -245,15 +376,44 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
245
376
  }
246
377
 
247
378
  function sessionLabel(s: SessionInfo): string {
248
- return s.session_name ?? s.sessionName ?? fmtId(s.id);
379
+ return s.session_name ?? fmtId(s.id);
249
380
  }
250
381
  function sessionMeta(s: SessionInfo): string {
251
- const backend = `${s.mode ?? s.session_class ?? 'managed'}/${s.backend ?? s.session_backend ?? 'tmux'}`;
252
- const viewers = s.members ?? ((s.controllers ?? 0) + (s.observers ?? 0));
382
+ const backend = `${s.session_class ?? 'managed'}/${s.session_backend ?? 'tmux'}`;
383
+ // v2 membership is authoritative; omit the viewer fragment when the executor
384
+ // doesn't advertise membership rather than implying "0 viewers".
385
+ if (!s.membership) return backend;
386
+ const viewers = s.membership.attachment_count;
253
387
  return `${backend} · ${viewers} viewer${viewers === 1 ? '' : 's'}`;
254
388
  }
255
389
  function sessionHoldsController(s: SessionInfo): boolean {
256
- return s.has_controller === true || (s.controllers ?? 0) > 0;
390
+ return (s.membership?.controllers.length ?? 0) > 0;
391
+ }
392
+
393
+ function sessionKey(s: SessionInfo): string {
394
+ return `${s.instance_id}:${s.id}`;
395
+ }
396
+
397
+ function sessionKeyFromAttachUrl(url: string | null): string {
398
+ const parts = sessionPartsFromAttachUrl(url);
399
+ return parts ? `${parts.instanceId}:${parts.sessionId}` : '';
400
+ }
401
+
402
+ function sessionPartsFromAttachUrl(url: string | null): { instanceId: string; sessionId: string } | null {
403
+ if (!url) return null;
404
+ const pattern = /\/agents\/([^/]+)\/sessions\/([^/]+)\/attach/;
405
+ try {
406
+ const parsed = new URL(url);
407
+ const match = parsed.pathname.match(pattern);
408
+ return match ? { instanceId: decodeURIComponent(match[1]), sessionId: decodeURIComponent(match[2]) } : null;
409
+ } catch {
410
+ const match = url.match(pattern);
411
+ return match ? { instanceId: decodeURIComponent(match[1]), sessionId: decodeURIComponent(match[2]) } : null;
412
+ }
413
+ }
414
+
415
+ function instanceIdFromAttachUrl(url: string | null): string {
416
+ return sessionPartsFromAttachUrl(url)?.instanceId ?? '';
257
417
  }
258
418
 
259
419
  function dedupeInstances(instances: Instance[]) {
@@ -264,3 +424,14 @@ function dedupeInstances(instances: Instance[]) {
264
424
  return true;
265
425
  });
266
426
  }
427
+
428
+ // VM runtimes included per #1778 — the bridge signals the in-guest agent via
429
+ // qemu-guest-agent, the container/docker path via docker exec.
430
+ const RECONNECTABLE_RUNTIMES = ['docker', 'container', 'vm', 'qemu', 'kvm'];
431
+
432
+ function isReconnectable(i: Instance): boolean {
433
+ const runtime = String(i.runtime_posture?.kind ?? i.runtime).toLowerCase();
434
+ const running = String(i.state).toLowerCase() === 'running';
435
+ const agentMissing = i.agent_ready === false || i.session_backends?.some((b) => b.available === false);
436
+ return running && RECONNECTABLE_RUNTIMES.includes(runtime) && Boolean(agentMissing);
437
+ }
@@ -65,7 +65,7 @@ describe('StartSessionModal (#1640/#1641)', () => {
65
65
  const startBtn = await screen.findByRole('button', { name: /start session/i });
66
66
  await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
67
67
  fireEvent.click(startBtn);
68
- await waitFor(() => expect(session.attach).toHaveBeenCalledWith(expect.stringContaining('/attach'), false, 'observer'));
68
+ await waitFor(() => expect(session.attach).toHaveBeenCalledWith(expect.stringContaining('/attach'), false, 'observer', { instanceId: 'inst-aaaaaaaa-1111', sessionId: 'sess-x' }));
69
69
  const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.find((c) => String(c[0]).includes('/sessions') && c[1]?.method === 'POST');
70
70
  expect(String(postCall?.[0])).toMatch(/mode=managed&backend=tmux/);
71
71
  expect(String(postCall?.[0])).not.toContain('loadout=');
@@ -34,6 +34,11 @@ export function StartSessionModal({ open, onClose, session, onStarted, initialIn
34
34
  useEffect(() => {
35
35
  if (!open) return;
36
36
  setErr('');
37
+ // The modal stays mounted across open/close, so stale state persists. A
38
+ // successful start left busy=true (it was only cleared on failure), which
39
+ // re-rendered the next open with a permanently greyed-out "Starting…"
40
+ // button until a full page refresh. Reset on every open.
41
+ setBusy(false);
37
42
  let cancelled = false;
38
43
  (async () => {
39
44
  try {
@@ -89,9 +94,10 @@ export function StartSessionModal({ open, onClose, session, onStarted, initialIn
89
94
  { method: 'POST', signal: controller.signal },
90
95
  );
91
96
  if (!s.attach_url) throw new Error('Session started but no attach URL was returned.');
92
- session.attach(s.attach_url, false, posture);
97
+ session.attach(s.attach_url, false, posture, { instanceId: current.id, sessionId: s.id });
93
98
  onStarted();
94
99
  onClose();
100
+ setBusy(false);
95
101
  } catch (e) {
96
102
  const message = e instanceof DOMException && e.name === 'AbortError'
97
103
  ? 'Starting the session timed out. The instance may still be preparing PTY support; refresh sessions and try again.'
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState, type CSSProperties } from 'react';
2
2
  import { api } from '../api';
3
- import { fmtId } from '../util';
3
+ import { fmtId, runtimeFamily as runtimeTargetFamily } from '../util';
4
4
  import type { Instance, Approval, Cost, RunningTask } from '../types';
5
5
 
6
6
  interface Status {
@@ -82,9 +82,9 @@ export function Welcome({ onStartSession, onLaunchInstance, goTo }: { onStartSes
82
82
 
83
83
  const runningInstances = st?.instances.filter((i) => i.state === 'running') ?? [];
84
84
  const runtimeCoverage = {
85
- host: st?.instances.some((i) => i.runtime_posture.kind === 'host') ?? false,
86
- container: st?.instances.some((i) => i.runtime_posture.kind === 'container' || i.runtime_posture.kind === 'docker') ?? false,
87
- vm: st?.instances.some((i) => i.runtime_posture.kind === 'vm') ?? false,
85
+ host: st?.instances.some((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'host') ?? false,
86
+ container: st?.instances.some((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'container') ?? false,
87
+ vm: st?.instances.some((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'vm') ?? false,
88
88
  };
89
89
  const copyStartCommand = async () => {
90
90
  await navigator.clipboard?.writeText('aiwg cockpit');
@@ -352,8 +352,9 @@ function inventoryWarning(inv: InventoryEnvelope) {
352
352
  }
353
353
 
354
354
  function runtimeFamily(instance: Instance): string {
355
- if (instance.runtime_posture.kind === 'host') return 'terminal';
356
- if (instance.runtime_posture.kind === 'vm' || instance.runtime_posture.kind === 'container' || instance.runtime_posture.kind === 'docker') return 'cube';
355
+ const family = runtimeTargetFamily(instance.runtime_posture?.kind ?? instance.runtime);
356
+ if (family === 'host') return 'terminal';
357
+ if (family === 'vm' || family === 'container') return 'cube';
357
358
  return 'window';
358
359
  }
359
360
 
@@ -374,12 +375,12 @@ function initialWallMode(): WallReviewMode {
374
375
 
375
376
  function buildOrbitNodes(st: Status | null): OrbitNode[] {
376
377
  const running = st?.instances.filter((i) => i.state === 'running') ?? [];
377
- const host = st?.instances.find((i) => i.runtime_posture.kind === 'host');
378
- const container = st?.instances.find((i) => i.runtime_posture.kind === 'container' || i.runtime_posture.kind === 'docker');
379
- const vm = st?.instances.find((i) => i.runtime_posture.kind === 'vm');
378
+ const host = st?.instances.find((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'host');
379
+ const container = st?.instances.find((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'container');
380
+ const vm = st?.instances.find((i) => runtimeTargetFamily(i.runtime_posture?.kind ?? i.runtime) === 'vm');
380
381
  const drive = st?.instances.some((i) => i.session_backends.some((b) => b.available && b.drive)) ?? false;
381
382
  const approvals = st?.approvals.length ?? 0;
382
- const cost = st?.cost?.total.usd;
383
+ const cost = st?.cost?.total?.usd;
383
384
  const base: Omit<OrbitNode, 'x' | 'y'>[] = [
384
385
  {
385
386
  key: 'host',
@@ -0,0 +1,85 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { useSessionSnapshotMonitor } from './sessionMonitor';
4
+ import {
5
+ getSessionRegistrySnapshot,
6
+ resetSessionRegistryForTest,
7
+ setRegistryActiveSession,
8
+ upsertRegistrySessions,
9
+ } from './sessionRegistry';
10
+ import type { SessionInfo } from './types';
11
+
12
+ const BACKGROUND_SESSION: SessionInfo = {
13
+ id: 'sess-bg',
14
+ instance_id: 'inst-bg',
15
+ attach_url: 'ws://x/agents/inst-bg/sessions/sess-bg/attach',
16
+ session_name: 'background',
17
+ };
18
+
19
+ beforeEach(() => {
20
+ resetSessionRegistryForTest();
21
+ Object.defineProperty(document, 'hidden', { configurable: true, value: false });
22
+ vi.useFakeTimers();
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.useRealTimers();
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ describe('useSessionSnapshotMonitor', () => {
31
+ it('polls non-attached session snapshots into the registry', async () => {
32
+ upsertRegistrySessions([BACKGROUND_SESSION]);
33
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
34
+ if (String(input).includes('/api/instances/inst-bg/sessions/sess-bg/screen')) {
35
+ return jsonResponse({ text: 'Background prompt? [y/N]\n', seq: 4 });
36
+ }
37
+ return new Response('{}', { status: 404 });
38
+ });
39
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
40
+
41
+ renderHook(() => useSessionSnapshotMonitor(10_000));
42
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
43
+
44
+ const entry = getSessionRegistrySnapshot().entries['inst-bg:sess-bg'];
45
+ expect(entry.snapshot?.text).toContain('Background prompt?');
46
+ expect(entry.responseNeeded.needed).toBe(true);
47
+ expect(entry.unread).toBe(true);
48
+ expect(MockFetchUrls(fetchMock)).toContain('/api/instances/inst-bg/sessions/sess-bg/screen');
49
+ });
50
+
51
+ it('does not poll the actively attached session', async () => {
52
+ upsertRegistrySessions([BACKGROUND_SESSION]);
53
+ setRegistryActiveSession('inst-bg', 'sess-bg');
54
+ const fetchMock = vi.fn(async () => jsonResponse({ text: 'should not fetch' }));
55
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
56
+
57
+ renderHook(() => useSessionSnapshotMonitor(10_000));
58
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
59
+
60
+ expect(fetchMock).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('pauses snapshot polling while the document is hidden', async () => {
64
+ Object.defineProperty(document, 'hidden', { configurable: true, value: true });
65
+ upsertRegistrySessions([BACKGROUND_SESSION]);
66
+ const fetchMock = vi.fn(async () => jsonResponse({ text: 'hidden' }));
67
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
68
+
69
+ renderHook(() => useSessionSnapshotMonitor(10_000));
70
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
71
+
72
+ expect(fetchMock).not.toHaveBeenCalled();
73
+ });
74
+ });
75
+
76
+ function jsonResponse(body: unknown): Response {
77
+ return new Response(JSON.stringify(body), {
78
+ status: 200,
79
+ headers: { 'Content-Type': 'application/json' },
80
+ });
81
+ }
82
+
83
+ function MockFetchUrls(fetchMock: ReturnType<typeof vi.fn>): string {
84
+ return fetchMock.mock.calls.map(([input]) => String(input)).join('\n');
85
+ }