@aiwg/cockpit 2026.6.12 → 2026.6.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.
@@ -371,7 +371,14 @@ async function destroyInstance(upstreamUrl, instanceId) {
371
371
  }
372
372
  } catch (err) {
373
373
  const message = String(err?.message ?? err);
374
- if (inst && / -> 404(?:;|$)/.test(message)) {
374
+ // A docker/container row with a resolvable name is still physically
375
+ // removable even when admin-v2 has no instance record (404): fall through
376
+ // to the `docker rm -f` cleanup below so the stopped container is actually
377
+ // removed. Returning already_gone here would claim success while the
378
+ // container persists and re-appears on the next inventory poll — the
379
+ // "stale stopped Docker row can't be destroyed" failure.
380
+ const dockerCleanable = ['docker', 'container'].includes(runtime) && dockerName;
381
+ if (inst && !dockerCleanable && / -> 404(?:;|$)/.test(message)) {
375
382
  return {
376
383
  target: `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(instanceId)}/destroy`,
377
384
  status: 200,
@@ -397,7 +404,18 @@ async function destroyInstance(upstreamUrl, instanceId) {
397
404
  };
398
405
  }
399
406
 
400
- await spawnCollect('docker', ['rm', '-f', dockerName]);
407
+ let alreadyGone = false;
408
+ try {
409
+ await spawnCollect('docker', ['rm', '-f', dockerName]);
410
+ } catch (err) {
411
+ // `docker rm -f` errors only because the container is already gone (e.g.
412
+ // removed out-of-band). That is success for a destroy request, not a failure.
413
+ if (/No such container|is not running|not found/i.test(String(err?.message ?? err))) {
414
+ alreadyGone = true;
415
+ } else {
416
+ throw err;
417
+ }
418
+ }
401
419
  return {
402
420
  target: `docker rm -f ${dockerName}`,
403
421
  status: 200,
@@ -407,6 +425,8 @@ async function destroyInstance(upstreamUrl, instanceId) {
407
425
  runtime,
408
426
  state: 'destroyed',
409
427
  result: { state: 'destroyed' },
428
+ already_gone: alreadyGone,
429
+ message: alreadyGone ? `Container ${dockerName} was already removed; inventory refreshed.` : undefined,
410
430
  fallback: 'docker-cli-after-admin-v2-instance-not-found',
411
431
  },
412
432
  };
@@ -953,22 +973,34 @@ async function getSessions(executorUrl, instanceId) {
953
973
  return u.toString();
954
974
  } catch { /* fall through to legacy shape */ }
955
975
  }
956
- return `${wsBase}/agents/${encodeURIComponent(sessionAgentId)}/sessions/${encodeURIComponent(sessionId)}/attach`;
957
- };
958
- return {
959
- instance_id: instanceId,
960
- sessions: sessions.map((s) => {
961
- const sessionId = s.id ?? s.session_id ?? s.sessionId;
962
- return {
963
- ...s,
964
- id: sessionId,
965
- instance_id: s.instance_id ?? s.instanceId ?? instanceId,
966
- agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
967
- role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
968
- attach_url: normalizeAttachUrl(s, sessionId),
969
- };
970
- }),
976
+ // Fallback URL construction (session entry carried no attach_url/pty_ws_url).
977
+ // The executor's pty-ws route keys the agent segment by the INSTANCE id, not
978
+ // the registered agent name — resolveSessionAgentId returns the name for some
979
+ // agents (e.g. VMs after a Bridge restart), which the route won't accept and
980
+ // the data-plane socket never connects (#1671). Use the instance id here; the
981
+ // agent name is only needed for the session-list FETCH, not the attach path.
982
+ return `${wsBase}/agents/${encodeURIComponent(instanceId)}/sessions/${encodeURIComponent(sessionId)}/attach`;
971
983
  };
984
+ // Dedup by session id: the executor can register/return the same session more
985
+ // than once (e.g. a session registered twice in its registry), which surfaced
986
+ // as duplicate rows that are impossible to tell apart in the Sessions picker.
987
+ // Keep the first occurrence of each id (and drop id-less entries).
988
+ const seen = new Set();
989
+ const deduped = [];
990
+ for (const s of sessions) {
991
+ const sessionId = s.id ?? s.session_id ?? s.sessionId;
992
+ if (!sessionId || seen.has(sessionId)) continue;
993
+ seen.add(sessionId);
994
+ deduped.push({
995
+ ...s,
996
+ id: sessionId,
997
+ instance_id: s.instance_id ?? s.instanceId ?? instanceId,
998
+ agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
999
+ role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
1000
+ attach_url: normalizeAttachUrl(s, sessionId),
1001
+ });
1002
+ }
1003
+ return { instance_id: instanceId, sessions: deduped };
972
1004
  }
973
1005
 
974
1006
  async function endSession(executorUrl, instanceId, sessionId) {
@@ -1212,7 +1244,9 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1212
1244
  attachUrl = u.toString();
1213
1245
  } catch { /* fall through to legacy shape */ }
1214
1246
  }
1215
- return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(sessionAgentId)}/sessions/${encodeURIComponent(sessionId)}/attach` });
1247
+ // Same as the list path (#1671): the attach segment must be the instance
1248
+ // id the executor's pty-ws route accepts, not the resolved agent name.
1249
+ return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
1216
1250
  }
1217
1251
 
1218
1252
  // --- management surface (UC-012): lifecycle + task cancel ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.6.12",
3
+ "version": "2026.6.13",
4
4
  "description": "AIWG Cockpit — UX-first control plane over AIWG + multi-stack agentic sessions. Opt-in, separately published; NOT shipped in the base aiwg npm package (guarded by test/smoke/cockpit-base-footprint.test.js).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -219,6 +219,27 @@ describe('App shell (rendered DOM)', () => {
219
219
  expect(screen.queryByText(/action failed/i)).toBeNull();
220
220
  });
221
221
 
222
+ it('keeps Destroy enabled for a stopped Docker row so it can be cleaned up', async () => {
223
+ const stale = { ...instance('stale-dkr-1', 'docker', 'full-suite'), state: 'stopped' };
224
+ const inventory = { instances: [stale], count: 1, fetched_at: new Date().toISOString() };
225
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
226
+ const url = String(input);
227
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
228
+ if (url.includes('/api/inventory')) return jsonResponse(inventory);
229
+ if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
230
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
231
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
232
+ return jsonResponse({});
233
+ }) as typeof fetch;
234
+
235
+ render(<App />);
236
+ fireEvent.click(screen.getByRole('tab', { name: 'Inventory' }));
237
+ const destroy = await screen.findByRole('button', { name: /destroy instance stale-dkr-1/i });
238
+ // Previously hard-disabled for stopped Docker rows, which trapped stale
239
+ // containers in inventory with no in-UI way to remove them.
240
+ expect((destroy as HTMLButtonElement).disabled).toBe(false);
241
+ });
242
+
222
243
  it('each tab has a matching labelled tabpanel (controls/labelledby pairing)', () => {
223
244
  render(<App />);
224
245
  for (const tab of screen.getAllByRole('tab')) {
package/web/src/App.tsx CHANGED
@@ -202,10 +202,21 @@ interface OperationStatus {
202
202
  error?: { message?: string; detail?: string; code?: string } | string;
203
203
  }
204
204
 
205
+ // How long the picker waits for a newly-launched instance's agent to register
206
+ // before handing the user back to Inventory. Heavy loadouts (e.g. full-suite —
207
+ // all 9 providers × 6 frameworks) legitimately take well over a minute to
208
+ // install and enroll their agent inside a fresh container/VM, so a short wait
209
+ // produced false "no session-ready agent" failures for instances that were in
210
+ // fact up and still installing. Overridable via window.AIWG_COCKPIT_SESSION_WAIT_S.
211
+ const SESSION_READY_TIMEOUT_S = (() => {
212
+ const raw = Number((window as unknown as { AIWG_COCKPIT_SESSION_WAIT_S?: unknown }).AIWG_COCKPIT_SESSION_WAIT_S);
213
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 150;
214
+ })();
215
+
205
216
  async function waitForSessionReady(instanceId?: string, operationId?: string) {
206
217
  let last = '';
207
218
  let operationDetail = '';
208
- for (let i = 0; i < 45; i += 1) {
219
+ for (let i = 0; i < SESSION_READY_TIMEOUT_S; i += 1) {
209
220
  if (operationId) {
210
221
  const op = await api<OperationStatus>(`/api/operations/${encodeURIComponent(operationId)}`);
211
222
  const state = String(op.state ?? '').toLowerCase();
@@ -234,7 +245,12 @@ async function waitForSessionReady(instanceId?: string, operationId?: string) {
234
245
  }
235
246
  await sleep(1_000);
236
247
  }
237
- throw new Error(`Instance launched, but no session-ready agent appeared within 45s (${last}).`);
248
+ const where = instanceId ? `Instance ${instanceId}` : 'The instance';
249
+ throw new Error(
250
+ `${where} launched and is still installing its loadout — its agent had not registered after ${SESSION_READY_TIMEOUT_S}s `
251
+ + `(${last}). This is not a failure: heavy loadouts (e.g. full-suite) can take longer. `
252
+ + `It will appear under Inventory once its agent enrolls; open a session from there when it shows as running.`,
253
+ );
238
254
  }
239
255
 
240
256
  function operationFailure(op: OperationStatus) {
@@ -116,8 +116,7 @@ export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession
116
116
  : <button aria-label={`Start instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/start`, 'POST')}>Start Instance</button>}{' '}
117
117
  <button
118
118
  aria-label={`Destroy instance ${fmtId(i.id)}`}
119
- disabled={i.state !== 'running' && i.runtime === 'docker'}
120
- title={i.state !== 'running' && i.runtime === 'docker' ? 'Sandbox reports this stopped Docker row but admin-v2 no longer has a destroyable instance record.' : undefined}
119
+ title={i.state !== 'running' && i.runtime === 'docker' ? 'Stopped Docker row — Destroy removes the container directly (admin-v2 has no instance record).' : undefined}
121
120
  onClick={() => { if (confirm(`Destroy ${fmtId(i.id)}? This cannot be undone.`)) control(`/api/instances/${encodeURIComponent(i.id)}`, 'DELETE'); }}
122
121
  >
123
122
  Destroy
@@ -77,6 +77,21 @@ describe('LaunchInstanceModal', () => {
77
77
  });
78
78
  });
79
79
 
80
+ it('regenerates a fresh instance name on each open so back-to-back launches do not collide', async () => {
81
+ globalThis.fetch = mockFetch();
82
+ const { rerender } = render(<LaunchInstanceModal open onClose={() => {}} onLaunched={() => {}} />);
83
+ fireEvent.change(await screen.findByLabelText('Runtime'), { target: { value: 'docker' } });
84
+ const name1 = (await screen.findByLabelText('Name') as HTMLInputElement).value;
85
+ expect(name1).toMatch(/^cockpit-/);
86
+ // Close then reopen — the second launch (e.g. a VM after a Docker) must get a
87
+ // different name, or both register the same name-keyed agent and shadow each other.
88
+ rerender(<LaunchInstanceModal open={false} onClose={() => {}} onLaunched={() => {}} />);
89
+ rerender(<LaunchInstanceModal open onClose={() => {}} onLaunched={() => {}} />);
90
+ const name2 = (await screen.findByLabelText('Name') as HTMLInputElement).value;
91
+ expect(name2).toMatch(/^cockpit-/);
92
+ expect(name2).not.toBe(name1);
93
+ });
94
+
80
95
  it('passes a VM SSH public key path when launching QEMU', async () => {
81
96
  globalThis.fetch = mockFetch();
82
97
  const onLaunched = vi.fn();
@@ -22,6 +22,15 @@ const FALLBACK_LOADOUTS: Loadout[] = [
22
22
  { id: 'full-suite', label: 'full-suite', description: 'Multi-provider tool suite', runtimes: ['qemu', 'vm'] },
23
23
  ];
24
24
 
25
+ // A fresh, collision-resistant instance name per launch. The executor keys
26
+ // agents by instance name, so two instances sharing a name (e.g. a Docker
27
+ // container and a VM both named `cockpit-<ts>`) register the same agent id and
28
+ // shadow each other — the second launch silently knocks the first offline.
29
+ // Date.now() alone collides within a page session (the name was generated once
30
+ // at mount and reused); add random entropy and regenerate on every open/launch.
31
+ const genInstanceName = () =>
32
+ `cockpit-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.slice(0, 32);
33
+
25
34
  export function LaunchInstanceModal({
26
35
  open,
27
36
  onClose,
@@ -32,7 +41,7 @@ export function LaunchInstanceModal({
32
41
  onLaunched: (instanceId?: string, openSession?: boolean, operationId?: string) => Promise<void> | void;
33
42
  }) {
34
43
  const [runtime, setRuntime] = useState<Runtime>('host');
35
- const [name, setName] = useState(() => `cockpit-${Date.now().toString(36)}`.slice(0, 32));
44
+ const [name, setName] = useState(genInstanceName);
36
45
  const [loadout, setLoadout] = useState('host-tools');
37
46
  const [loadouts, setLoadouts] = useState<Loadout[]>([]);
38
47
  const [instances, setInstances] = useState<Instance[]>([]);
@@ -49,6 +58,9 @@ export function LaunchInstanceModal({
49
58
 
50
59
  useEffect(() => {
51
60
  if (!open) return;
61
+ // Fresh name every time the picker opens, so back-to-back launches (e.g.
62
+ // Docker then VM) never collide on the executor's name-keyed agent registry.
63
+ setName(genInstanceName());
52
64
  let cancelled = false;
53
65
  Promise.all([
54
66
  api<{ loadouts: Loadout[] }>('/api/loadouts').catch(() => ({ loadouts: [] as Loadout[] })),
@@ -116,6 +128,7 @@ export function LaunchInstanceModal({
116
128
  : `Launch accepted: ${instanceId ?? operationId ?? 'operation pending'}`);
117
129
  await onLaunched(instanceId, openSession, operationId);
118
130
  if (openSession) onClose();
131
+ else setName(genInstanceName()); // modal stays open — next launch gets a fresh, non-colliding name
119
132
  } catch (e) {
120
133
  setErr((e as Error).message);
121
134
  } finally {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
2
+ import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react';
3
3
  import { Sessions } from './Sessions';
4
4
  import type { SessionApi } from '../useSession';
5
5
 
@@ -110,10 +110,83 @@ describe('Sessions', () => {
110
110
 
111
111
  render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={10} />);
112
112
 
113
- expect(await screen.findByRole('option', { name: /docker-one/i })).toBeTruthy();
114
- expect(await screen.findByRole('option', { name: /docker-two/i })).toBeTruthy();
115
- expect(screen.queryByRole('option', { name: /docker-one/i })).toBeNull();
116
- expect(await screen.findByRole('option', { name: /sess-new/i })).toBeTruthy();
113
+ // The nav lists instances as buttons (not <select> options) now (#1670).
114
+ expect(await screen.findByText('docker-one')).toBeTruthy();
115
+ // Inventory refresh swaps inst-1 → inst-2; the dead instance drops out and
116
+ // selection follows to the live one, whose session is listed underneath.
117
+ expect(await screen.findByText('docker-two')).toBeTruthy();
118
+ await waitFor(() => expect(screen.queryByText('docker-one')).toBeNull());
119
+ // Scope to the nav (the active-session indicator also carries the id by title).
120
+ const nav = screen.getByLabelText('Instances and sessions');
121
+ expect(await within(nav).findByTitle('sess-new')).toBeTruthy();
122
+ });
123
+
124
+ it('auto-attaches in observe when a different session is selected (#1670)', async () => {
125
+ const session = stubSession(); // currently attached to .../sessions/sess-1/attach as controller
126
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
127
+ const url = String(input);
128
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
129
+ if (url.includes('/api/sessions?instance=')) return jsonResponse({
130
+ sessions: [{ id: 'sess-2', session_name: 'terminal-other', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-2/attach', mode: 'managed', backend: 'tmux' }],
131
+ });
132
+ return new Response('{}', { status: 404 });
133
+ });
134
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
135
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
136
+
137
+ const nav = screen.getByLabelText('Instances and sessions');
138
+ const sessBtn = await within(nav).findByTitle('sess-2');
139
+ fireEvent.click(sessBtn);
140
+ // Selecting a not-yet-attached session attaches it read-only; the operator clicks Drive to take over.
141
+ expect(session.attach).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-2/attach', false, 'observer');
142
+ });
143
+
144
+ it('does not re-attach (downgrade) when re-selecting the session already attached', async () => {
145
+ const session = stubSession(); // state.url === .../sessions/sess-1/attach, role controller
146
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
147
+ const url = String(input);
148
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
149
+ if (url.includes('/api/sessions?instance=')) return jsonResponse({
150
+ sessions: [{ id: 'sess-1', session_name: 'terminal-main', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
151
+ });
152
+ return new Response('{}', { status: 404 });
153
+ });
154
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
155
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
156
+
157
+ const nav = screen.getByLabelText('Instances and sessions');
158
+ fireEvent.click(await within(nav).findByTitle('sess-1'));
159
+ // Clicking the session we already drive must not downgrade us back to observer.
160
+ expect(session.attach).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('distinguishes sessions by name + backend + viewer count in the nav (#1670)', async () => {
164
+ const session = stubSession();
165
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
166
+ const url = String(input);
167
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
168
+ if (url.includes('/api/sessions?instance=')) return jsonResponse({
169
+ sessions: [
170
+ { id: 'sess-a', session_name: 'terminal-alpha', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-a/attach', mode: 'managed', backend: 'tmux', controllers: 1, observers: 1 },
171
+ { id: 'sess-b', session_name: 'terminal-beta', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-b/attach', mode: 'direct', backend: 'native', members: 0 },
172
+ ],
173
+ });
174
+ return new Response('{}', { status: 404 });
175
+ });
176
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
177
+
178
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
179
+
180
+ // Two distinct sessions, each shown by name with its own backend/viewer meta.
181
+ // Scope to the nav — the controls bar's active-session indicator echoes the label.
182
+ await screen.findByText('terminal-beta');
183
+ const nav = screen.getByLabelText('Instances and sessions');
184
+ expect(within(nav).getByText('terminal-alpha')).toBeTruthy();
185
+ expect(within(nav).getByText('terminal-beta')).toBeTruthy();
186
+ expect(within(nav).getByText('managed/tmux · 2 viewers')).toBeTruthy();
187
+ expect(within(nav).getByText('direct/native · 0 viewers')).toBeTruthy();
188
+ // sess-a has a controller connected → it carries the ctrl badge; sess-b does not.
189
+ expect(within(nav).getByTitle('A controller is connected')).toBeTruthy();
117
190
  });
118
191
  });
119
192
 
@@ -60,7 +60,15 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
60
60
  })
61
61
  .catch((e) => { setSessions([]); setAttachUrl(''); setSessionErr((e as Error).message); });
62
62
  }, []);
63
- useEffect(() => { loadSessions(instId); }, [instId, loadSessions]);
63
+ // Reload the selected instance's sessions on selection change AND on an interval,
64
+ // so a session created elsewhere (the Start modal, the Running board, another
65
+ // operator) shows up in the nav without re-selecting the instance.
66
+ useEffect(() => {
67
+ if (!instId) return;
68
+ loadSessions(instId);
69
+ const timer = window.setInterval(() => loadSessions(instId), refreshMs);
70
+ return () => window.clearInterval(timer);
71
+ }, [instId, loadSessions, refreshMs]);
64
72
 
65
73
  const send = () => { if (session.sendInput(composer)) setComposer(''); };
66
74
  const attached = session.state.attached;
@@ -85,15 +93,16 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
85
93
  // Starting now routes through the shared picker (#1640/#1641) so this tab and the
86
94
  // dashboard verb share one params/clobber/error path. The selects below remain for
87
95
  // attaching to / observing / driving sessions that already exist.
88
- const endSelectedSession = async () => {
89
- if (!current || !selectedSession) return;
90
- const label = selectedSession.session_name ?? selectedSession.id;
96
+ const endSelectedSession = async (target?: SessionInfo) => {
97
+ const s = target ?? selectedSession;
98
+ if (!current || !s) return;
99
+ const label = s.session_name ?? s.id;
91
100
  if (!confirm(`End session ${label}? This closes the PTY and detaches connected views.`)) return;
92
- setEndingSession(selectedSession.id);
101
+ setEndingSession(s.id);
93
102
  setSessionErr('');
94
103
  try {
95
- await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(selectedSession.id)}`, { method: 'DELETE' });
96
- if (session.state.url === selectedSession.attach_url) session.detach();
104
+ await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
105
+ if (session.state.url === s.attach_url) session.detach();
97
106
  await loadSessions(current.id);
98
107
  } catch (e) {
99
108
  setSessionErr((e as Error).message);
@@ -105,80 +114,142 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
105
114
  return (
106
115
  <>
107
116
  <p className="hint">
108
- <strong>Workspace.</strong> Attach to a single session on one instance to observe or drive it. Start a
109
- new session above, or pick a live task from the <strong>Running</strong> fleet board and open it here.
117
+ <strong>Workspace.</strong> Pick an instance on the left, then a session under it to observe or drive.
118
+ Start a new session per instance, or open a live task from the <strong>Running</strong> fleet board.
110
119
  </p>
111
- <div className="controls">
112
- <label htmlFor="sel-instance">Instance</label>
113
- <select id="sel-instance" value={instId} onChange={(e) => setInstId(e.target.value)}>
114
- {instances.map((i) => <option key={i.id} value={i.id}>{i.launch_context?.name ?? fmtId(i.id)} · {i.loadout}</option>)}
115
- {!instances.length && <option value="">— no session-capable running instances —</option>}
116
- </select>
117
- <label htmlFor="sel-backend">Mode</label>
118
- <select id="sel-backend" value={backendKey} onChange={(e) => setBackendKey(e.target.value)}>
119
- {backends.length
120
- ? backends.map((b) => <option key={`${b.mode}:${b.backend}`} value={`${b.mode}:${b.backend}`} disabled={!b.available}>{b.mode} · {b.backend}{b.available ? '' : ` — ${b.reason ?? 'unsupported'}`}</option>)
121
- : <option value="">— not advertised —</option>}
122
- </select>
123
- <button onClick={() => onRequestStart(instId)}>Start…</button>
124
- <label htmlFor="sel-session">Session</label>
125
- <select id="sel-session" value={attachUrl} onChange={(e) => setAttachUrl(e.target.value)}>
126
- {sessions.length
127
- ? sessions.map((s) => <option key={s.id} value={s.attach_url}>{s.id} · {s.mode ?? s.session_class ?? 'managed'}/{s.backend ?? s.session_backend ?? 'tmux'} · {current?.launch_context?.name ?? fmtId(instId)}</option>)
128
- : <option value="">— no sessions —</option>}
129
- </select>
130
- <button disabled={!attachUrl || (attached && session.state.role === 'observer')} onClick={() => session.attach(attachUrl, false, 'observer')}>Observe</button>
131
- <button disabled={!attachUrl || selectedBackend?.drive === false || (attached && session.state.role === 'controller')} onClick={() => session.attach(attachUrl, false, 'controller')}>
132
- {attached && session.state.role === 'observer' ? 'Take Control' : 'Drive'}
133
- </button>
134
- <button disabled={!attached || selectedBackend?.keyframe === false} onClick={session.requestKeyframe}>Keyframe</button>
135
- <button disabled={!attached} onClick={() => session.replay(attachUrl, requestedReplayRole)}>Reattach + replay</button>
136
- <button
137
- disabled={!selectedSession || endingSession === selectedSession?.id}
138
- onClick={endSelectedSession}
139
- title="Terminate the selected PTY session on the agent"
140
- >
141
- {endingSession === selectedSession?.id ? 'Ending…' : 'End Session'}
142
- </button>
143
- <button disabled={!attached} onClick={session.detach}>Detach</button>
144
- {session.state.role && <span className={`badge ${session.state.role}`}>{session.state.role}</span>}
145
- </div>
146
- {sessionErr && <p className="err">Session action failed: {sessionErr}</p>}
147
- {current && (
148
- <p className="hint">
149
- {current.runtime_posture.label} · {current.transport.label} ({current.transport.mode}) · attach starts as observe unless control is explicitly granted.
150
- {attached && session.state.role === 'observer' ? ' Click Take Control to re-attach with write access.' : ''}
151
- {selectedBackend && !selectedBackend.available ? ` ${selectedBackend.reason ?? 'Selected backend is unavailable.'}` : ''}
152
- </p>
153
- )}
154
- <div className="terminal" ref={session.openTerminal} role="log" aria-label="Session output" />
155
- {showPicker && (
156
- <div className="picker">
157
- <p className="hint" style={{ marginTop: 0 }}>Pick a capability to insert into the command — then Send to inject it. (Lookup is UI; the agent runs it.)</p>
158
- <CapabilitySearch compact autoFocus onPick={insertCap} />
159
- </div>
160
- )}
161
- <div className="inputrow">
162
- <button aria-label="Insert a capability" title="Insert a capability (search)" onClick={() => setShowPicker((v) => !v)}>+</button>
163
- <input
164
- ref={inputRef}
165
- value={composer}
166
- onChange={(e) => setComposer(e.target.value)}
167
- onKeyDown={(e) => { if (e.key === 'Enter') send(); }}
168
- placeholder={session.isController ? 'Type to drive the session…' : 'Observing — input is read-only'}
169
- disabled={!session.isController}
170
- aria-label="Session input"
171
- />
172
- <button disabled={!session.isController} onClick={send}>Send</button>
120
+ <div className="session-workspace">
121
+ {/* Persistent instances → sessions navigation (agentic-sandbox-style control screen, #1670). */}
122
+ <aside className="session-nav" aria-label="Instances and sessions">
123
+ <div className="session-nav-head">
124
+ <h2>Instances</h2>
125
+ <span className="hint">{instances.length}</span>
126
+ </div>
127
+ {!instances.length && <p className="empty">No session-capable running instances.</p>}
128
+ <ul className="nav-list">
129
+ {instances.map((i) => {
130
+ const isSel = i.id === instId;
131
+ const name = i.launch_context?.name ?? fmtId(i.id);
132
+ return (
133
+ <li key={i.id} className={`nav-instance${isSel ? ' selected' : ''}`}>
134
+ <button className="nav-instance-row" aria-expanded={isSel} onClick={() => setInstId(i.id)} title={i.id}>
135
+ <span className={`badge isolation-${i.runtime_posture.isolation}`}>{i.runtime}</span>
136
+ <span className="nav-instance-name">{name}</span>
137
+ <span className={`state ${i.state}`}><span className="dot" aria-hidden="true" />{i.state}</span>
138
+ </button>
139
+ {isSel && (
140
+ <div className="nav-sessions">
141
+ {sessions.length === 0 && <p className="empty nav-empty">No sessions yet.</p>}
142
+ <ul>
143
+ {sessions.map((s) => {
144
+ const selS = s.attach_url === attachUrl;
145
+ const live = session.state.url === s.attach_url && attached;
146
+ return (
147
+ <li key={s.id}>
148
+ <button
149
+ className={`nav-session${selS ? ' selected' : ''}${live ? ' live' : ''}`}
150
+ // Selecting a session auto-attaches in observe (read-only) so the
151
+ // operator immediately sees it; they click Drive to take control.
152
+ // Re-selecting the session already attached here is a no-op (don't
153
+ // downgrade an active controller back to observer).
154
+ onClick={() => {
155
+ setAttachUrl(s.attach_url);
156
+ if (s.attach_url !== session.state.url) session.attach(s.attach_url, false, 'observer');
157
+ }}
158
+ title={s.id}
159
+ >
160
+ <span className="nav-session-name">{sessionLabel(s)}</span>
161
+ <span className="nav-session-meta">{sessionMeta(s)}</span>
162
+ {sessionHoldsController(s) && <span className="badge controller" title="A controller is connected">ctrl</span>}
163
+ {live && <span className="badge live-dot" title="Attached here">●</span>}
164
+ </button>
165
+ <button
166
+ className="nav-session-end"
167
+ aria-label={`End session ${sessionLabel(s)}`}
168
+ title="End this PTY session"
169
+ disabled={endingSession === s.id}
170
+ onClick={() => endSelectedSession(s)}
171
+ >
172
+ {endingSession === s.id ? '…' : '×'}
173
+ </button>
174
+ </li>
175
+ );
176
+ })}
177
+ </ul>
178
+ <button className="cta-sm nav-new-session" disabled={backends.length > 0 && !selectedBackend?.available} onClick={() => onRequestStart(i.id)}>+ New session</button>
179
+ </div>
180
+ )}
181
+ </li>
182
+ );
183
+ })}
184
+ </ul>
185
+ </aside>
186
+
187
+ <section className="session-main">
188
+ <div className="controls">
189
+ {backends.length > 1 && (
190
+ <>
191
+ <label htmlFor="sel-backend">Mode</label>
192
+ <select id="sel-backend" value={backendKey} onChange={(e) => setBackendKey(e.target.value)}>
193
+ {backends.map((b) => <option key={`${b.mode}:${b.backend}`} value={`${b.mode}:${b.backend}`} disabled={!b.available}>{b.mode} · {b.backend}{b.available ? '' : ` — ${b.reason ?? 'unsupported'}`}</option>)}
194
+ </select>
195
+ </>
196
+ )}
197
+ <span className="controls-active" title={selectedSession?.id}>{selectedSession ? sessionLabel(selectedSession) : '— no session selected —'}</span>
198
+ <button disabled={!attachUrl || (attached && session.state.role === 'observer')} onClick={() => session.attach(attachUrl, false, 'observer')}>Observe</button>
199
+ <button disabled={!attachUrl || selectedBackend?.drive === false || (attached && session.state.role === 'controller')} onClick={() => session.attach(attachUrl, false, 'controller')}>
200
+ {attached && session.state.role === 'observer' ? 'Take Control' : 'Drive'}
201
+ </button>
202
+ <button disabled={!attached || selectedBackend?.keyframe === false} onClick={session.requestKeyframe}>Keyframe</button>
203
+ <button disabled={!attached} onClick={() => session.replay(attachUrl, requestedReplayRole)}>Reattach + replay</button>
204
+ <button disabled={!attached} onClick={session.detach}>Detach</button>
205
+ {session.state.role && <span className={`badge ${session.state.role}`}>{session.state.role}</span>}
206
+ </div>
207
+ {sessionErr && <p className="err">Session action failed: {sessionErr}</p>}
208
+ {current && (
209
+ <p className="hint">
210
+ {current.runtime_posture.label} · {current.transport.label} ({current.transport.mode}) · attach starts as observe unless control is explicitly granted.
211
+ {attached && session.state.role === 'observer' ? ' Click Take Control to re-attach with write access.' : ''}
212
+ {selectedBackend && !selectedBackend.available ? ` ${selectedBackend.reason ?? 'Selected backend is unavailable.'}` : ''}
213
+ </p>
214
+ )}
215
+ <div className="terminal" ref={session.openTerminal} role="log" aria-label="Session output" />
216
+ {showPicker && (
217
+ <div className="picker">
218
+ <p className="hint" style={{ marginTop: 0 }}>Pick a capability to insert into the command — then Send to inject it. (Lookup is UI; the agent runs it.)</p>
219
+ <CapabilitySearch compact autoFocus onPick={insertCap} />
220
+ </div>
221
+ )}
222
+ <div className="inputrow">
223
+ <button aria-label="Insert a capability" title="Insert a capability (search)" onClick={() => setShowPicker((v) => !v)}>+</button>
224
+ <input
225
+ ref={inputRef}
226
+ value={composer}
227
+ onChange={(e) => setComposer(e.target.value)}
228
+ onKeyDown={(e) => { if (e.key === 'Enter') send(); }}
229
+ placeholder={session.isController ? 'Type to drive the session…' : 'Observing — input is read-only'}
230
+ disabled={!session.isController}
231
+ aria-label="Session input"
232
+ />
233
+ <button disabled={!session.isController} onClick={send}>Send</button>
234
+ </div>
235
+ </section>
173
236
  </div>
174
- <p className="hint">
175
- Pick or start a session, then Attach. Cockpit requests observe-first access; drive/control is explicit and denial reasons stay visible through the session stream.
176
- Actions inject their command right here.
177
- </p>
178
237
  </>
179
238
  );
180
239
  }
181
240
 
241
+ function sessionLabel(s: SessionInfo): string {
242
+ return s.session_name ?? s.sessionName ?? fmtId(s.id);
243
+ }
244
+ function sessionMeta(s: SessionInfo): string {
245
+ const backend = `${s.mode ?? s.session_class ?? 'managed'}/${s.backend ?? s.session_backend ?? 'tmux'}`;
246
+ const viewers = s.members ?? ((s.controllers ?? 0) + (s.observers ?? 0));
247
+ return `${backend} · ${viewers} viewer${viewers === 1 ? '' : 's'}`;
248
+ }
249
+ function sessionHoldsController(s: SessionInfo): boolean {
250
+ return s.has_controller === true || (s.controllers ?? 0) > 0;
251
+ }
252
+
182
253
  function dedupeInstances(instances: Instance[]) {
183
254
  const seen = new Set<string>();
184
255
  return instances.filter((instance) => {
@@ -252,3 +252,38 @@ code { font:13px ui-monospace, monospace; color:var(--muted); }
252
252
  @media (prefers-reduced-motion: reduce) {
253
253
  *, *::before, *::after { scroll-behavior:auto !important; transition:none !important; animation:none !important; }
254
254
  }
255
+
256
+ /* Session workspace — persistent instances→sessions nav (agentic-sandbox-style control screen, #1670) */
257
+ .session-workspace { display:grid; grid-template-columns:minmax(240px, 300px) minmax(0, 1fr); gap:16px; align-items:start; }
258
+ @media (max-width: 820px) { .session-workspace { grid-template-columns:1fr; } }
259
+ .session-nav { border:1px solid var(--line); border-radius:10px; background:var(--panel); padding:10px; max-height:calc(100vh - 250px); overflow-y:auto; }
260
+ .session-nav-head { display:flex; align-items:center; justify-content:space-between; margin:2px 4px 8px; }
261
+ .session-nav-head h2 { margin:0; font-size:15px; }
262
+ .session-nav-head .hint { margin:0; }
263
+ .nav-list { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:4px; }
264
+ .nav-instance { border-radius:8px; border:1px solid transparent; }
265
+ .nav-instance.selected { background:rgba(94,234,212,.06); border-color:var(--line); }
266
+ .nav-instance-row { width:100%; display:flex; align-items:center; gap:8px; background:none; border:0; padding:8px; cursor:pointer; text-align:left; border-radius:8px; }
267
+ .nav-instance-row:hover { border-color:transparent; background:rgba(148,163,184,.08); }
268
+ .nav-instance-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:600; font-size:13px; }
269
+ .nav-instance .state { font-size:11px; color:var(--muted); }
270
+ .nav-instance .badge { font-size:10px; padding:1px 7px; text-transform:lowercase; }
271
+ .nav-sessions { padding:0 6px 8px 10px; display:flex; flex-direction:column; gap:4px; }
272
+ .nav-sessions ul { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:3px; }
273
+ .nav-sessions li { display:flex; align-items:stretch; gap:4px; }
274
+ .nav-session { flex:1; min-width:0; display:flex; flex-wrap:wrap; align-items:center; gap:4px 6px; background:none; border:1px solid transparent; border-left:2px solid var(--line); border-radius:6px; padding:6px 8px; cursor:pointer; text-align:left; }
275
+ .nav-session:hover { background:rgba(148,163,184,.08); }
276
+ .nav-session.selected { border-color:var(--accent); border-left-color:var(--accent); background:rgba(94,234,212,.08); }
277
+ .nav-session.live { border-left-color:var(--ok); }
278
+ .nav-session-name { flex-basis:100%; font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
279
+ .nav-session-meta { font-size:11px; color:var(--muted); }
280
+ .nav-session .badge { padding:0 6px; font-size:10px; }
281
+ .nav-session .live-dot { border-color:var(--ok); color:var(--ok); }
282
+ .nav-session-end { align-self:center; padding:4px 9px; color:var(--muted); border-color:transparent; background:none; line-height:1; }
283
+ .nav-session-end:hover:not(:disabled) { border-color:var(--err); color:var(--err); }
284
+ .nav-new-session { margin-top:6px; align-self:flex-start; }
285
+ .cta-sm { font-size:12px; padding:5px 10px; border-color:var(--accent); color:var(--accent); }
286
+ .nav-empty { margin:4px 0; font-size:12px; }
287
+ .session-main { min-width:0; }
288
+ .controls-active { color:var(--muted); font-size:13px; padding:5px 10px; border:1px dashed var(--line); border-radius:7px; max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
289
+ .badge.live-dot { color:var(--ok); border-color:var(--ok); }
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { useSession } from './useSession';
4
+
5
+ // Minimal WebSocket double: records every constructed socket and lets the test
6
+ // drive open/close/message. Mirrors the readiness-race timing (#1669).
7
+ class MockWS {
8
+ static instances: MockWS[] = [];
9
+ url: string;
10
+ sent: string[] = [];
11
+ private listeners: Record<string, ((e: unknown) => void)[]> = {};
12
+ constructor(url: string) { this.url = url; MockWS.instances.push(this); }
13
+ addEventListener(type: string, fn: (e: unknown) => void) { (this.listeners[type] ||= []).push(fn); }
14
+ send(data: string) { this.sent.push(data); }
15
+ close() { /* no-op; the test drives 'close' explicitly */ }
16
+ emit(type: string, e: unknown = {}) { (this.listeners[type] || []).forEach((fn) => fn(e)); }
17
+ }
18
+
19
+ beforeEach(() => {
20
+ MockWS.instances = [];
21
+ (globalThis as unknown as { WebSocket: unknown }).WebSocket = MockWS as unknown;
22
+ vi.useFakeTimers();
23
+ });
24
+ afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
25
+
26
+ describe('useSession — retry through the PTY-readiness window (#1669)', () => {
27
+ it('reconnects on an early empty close instead of giving up', () => {
28
+ const { result } = renderHook(() => useSession());
29
+ act(() => { result.current.attach('ws://x/attach', false, 'controller'); });
30
+ expect(MockWS.instances).toHaveLength(1);
31
+
32
+ // First socket opens, then closes with zero frames (agent PTY not ready yet).
33
+ act(() => { MockWS.instances[0].emit('open'); MockWS.instances[0].emit('close'); });
34
+ // No reconnect yet (waits the backoff)...
35
+ expect(MockWS.instances).toHaveLength(1);
36
+ // ...then a fresh socket is opened to retry.
37
+ act(() => { vi.advanceTimersByTime(1300); });
38
+ expect(MockWS.instances).toHaveLength(2);
39
+ });
40
+
41
+ it('counts a failing socket once even though it fires both error and close', () => {
42
+ const { result } = renderHook(() => useSession());
43
+ act(() => { result.current.attach('ws://x/attach', false, 'controller'); });
44
+ // A real failing WebSocket dispatches BOTH 'error' and 'close'; that must
45
+ // burn only one retry slot, not two (otherwise the budget halves silently).
46
+ act(() => { MockWS.instances[0].emit('open'); MockWS.instances[0].emit('error'); MockWS.instances[0].emit('close'); });
47
+ act(() => { vi.advanceTimersByTime(1300); });
48
+ expect(MockWS.instances).toHaveLength(2);
49
+ });
50
+
51
+ it('stops retrying once the first frame arrives (real stream established)', () => {
52
+ const { result } = renderHook(() => useSession());
53
+ act(() => { result.current.attach('ws://x/attach', false, 'controller'); });
54
+
55
+ // Reach the second attempt, then this socket actually streams a frame.
56
+ act(() => { MockWS.instances[0].emit('open'); MockWS.instances[0].emit('close'); });
57
+ act(() => { vi.advanceTimersByTime(1300); });
58
+ const live = MockWS.instances[1];
59
+ act(() => {
60
+ live.emit('open');
61
+ live.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
62
+ live.emit('message', { data: JSON.stringify({ op: 'output', seq: 1, payload: { data: btoa('hi') } }) });
63
+ });
64
+ // A later close after streaming must NOT spawn another socket.
65
+ act(() => { live.emit('close'); vi.advanceTimersByTime(5000); });
66
+ expect(MockWS.instances).toHaveLength(2);
67
+ });
68
+
69
+ it('gives up after the retry budget and does not reconnect forever', () => {
70
+ const { result } = renderHook(() => useSession());
71
+ act(() => { result.current.attach('ws://x/attach', false, 'controller'); });
72
+ // Every attempt closes empty; after the budget it stops creating sockets.
73
+ for (let i = 0; i < 10; i += 1) {
74
+ act(() => { MockWS.instances[MockWS.instances.length - 1].emit('open'); MockWS.instances[MockWS.instances.length - 1].emit('close'); });
75
+ act(() => { vi.advanceTimersByTime(1300); });
76
+ }
77
+ // 1 initial + 6 retries = 7 sockets, then it stops.
78
+ expect(MockWS.instances.length).toBeLessThanOrEqual(7);
79
+ });
80
+ });
@@ -22,6 +22,12 @@ export interface ResponseNeededState { needed: boolean; prompt: string; since: s
22
22
  const RESIZE_FLOOR_COLS = 20;
23
23
  const RESIZE_FLOOR_ROWS = 5;
24
24
 
25
+ // Retry-through-readiness window (#1669). A freshly-launched instance can accept
26
+ // the attach but stream 0 frames and close within ~2s while its PTY/tmux comes
27
+ // up; ~7s of reconnects rides past that without a hard error.
28
+ const MAX_READY_RETRIES = 6;
29
+ const READY_RETRY_MS = 1200;
30
+
25
31
  const textEnc = new TextEncoder();
26
32
  const textDec = new TextDecoder();
27
33
 
@@ -76,6 +82,14 @@ export function useSession() {
76
82
  const roRef = useRef<ResizeObserver | null>(null);
77
83
  const roleRef = useRef<Role>(null); // current role, read by term.onData without re-subscribing
78
84
  const outputTailRef = useRef('');
85
+ // Retry-through-readiness state (#1669): a freshly-launched VM/container can
86
+ // accept the pty-ws attach, send 0 frames, and close within ~2s because the
87
+ // agent's PTY/tmux isn't streamable yet. Rather than show a hard
88
+ // [connection error], reconnect a few times until the first frame arrives.
89
+ const gotFrameRef = useRef(false); // any output/keyframe seen on the current attach
90
+ const retryRef = useRef(0); // reconnect attempts since the last user-initiated attach
91
+ const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
92
+ const closedByUserRef = useRef(false); // detach()/new attach — suppress reconnect
79
93
  const [state, setState] = useState<SessionState>({ attached: false, role: null, url: null });
80
94
  const [responseNeeded, setResponseNeeded] = useState<ResponseNeededState>({ needed: false, prompt: '', since: null, source: 'pty' });
81
95
 
@@ -111,6 +125,9 @@ export function useSession() {
111
125
  convertEol: false, // the PTY/tmux emits its own CR/LF
112
126
  scrollback: 2000,
113
127
  cursorBlink: false,
128
+ // Read-only until control is granted: observe must not capture keystrokes
129
+ // at all (not just drop them on send). Flipped to false on controller.
130
+ disableStdin: true,
114
131
  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
115
132
  fontSize: 13,
116
133
  theme: { background: '#0a0c10', foreground: '#cdd3de' },
@@ -153,53 +170,101 @@ export function useSession() {
153
170
  useEffect(() => () => {
154
171
  try { roRef.current?.disconnect(); } catch { /* */ }
155
172
  try { termRef.current?.dispose(); } catch { /* */ }
173
+ closedByUserRef.current = true;
174
+ if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
156
175
  wsRef.current?.close();
157
176
  }, []);
158
177
 
159
178
  const attach = useCallback((url: string, replay = false, requestedRole: Exclude<Role, null> = 'observer') => {
179
+ if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
180
+ closedByUserRef.current = false;
181
+ retryRef.current = 0;
182
+ gotFrameRef.current = false;
160
183
  wsRef.current?.close();
161
184
  if (!replay) lastSeq.current = 0;
162
185
  roleRef.current = null;
186
+ if (termRef.current) termRef.current.options.disableStdin = true; // read-only until role_assigned grants control
163
187
  clearResponseNeeded();
164
188
  outputTailRef.current = '';
165
189
  setState({ attached: false, role: null, url });
166
190
  if (!replay) { try { termRef.current?.reset(); } catch { /* */ } }
167
- const ws = new WebSocket(replay ? `${url}?replay_from=${lastSeq.current}` : url);
168
- wsRef.current = ws;
169
- ws.addEventListener('open', () => setState((s) => ({ ...s, attached: true, url })));
170
- ws.addEventListener('close', () => { roleRef.current = null; setState((s) => ({ ...s, attached: false, role: null })); });
171
- ws.addEventListener('error', () => write(textEnc.encode('\r\n[connection error]\r\n')));
172
- ws.addEventListener('message', (ev) => {
173
- let m: WsMsg;
174
- try { m = JSON.parse(ev.data as string); } catch { return; }
175
- switch (m.op) {
176
- case 'binding_hello': {
177
- sendOp('pty.join_session', { role: requestedRole });
178
- // Tell the PTY our current dimensions up front so the first tmux redraw fits.
179
- const t = termRef.current;
180
- if (t && t.cols >= RESIZE_FLOOR_COLS && t.rows >= RESIZE_FLOOR_ROWS) sendOp('pty.session_resize', { cols: t.cols, rows: t.rows });
181
- break;
191
+
192
+ // Open (or re-open, on a readiness retry) the data-plane socket.
193
+ const connect = () => {
194
+ const ws = new WebSocket(replay ? `${url}?replay_from=${lastSeq.current}` : url);
195
+ wsRef.current = ws;
196
+ let gone = false; // a failing socket fires BOTH 'error' and 'close' — handle once
197
+ ws.addEventListener('open', () => setState((s) => ({ ...s, attached: true, url })));
198
+ const onGone = (kind: 'close' | 'error') => {
199
+ if (gone) return;
200
+ gone = true;
201
+ roleRef.current = null;
202
+ setState((s) => ({ ...s, attached: false, role: null }));
203
+ // Clean detach, or we were already streaming → leave it (a real end/drop).
204
+ if (closedByUserRef.current || gotFrameRef.current) return;
205
+ // Early empty close/error before the first frame → the agent's PTY isn't
206
+ // streamable yet. Reconnect through the readiness window rather than error.
207
+ if (retryRef.current < MAX_READY_RETRIES) {
208
+ retryRef.current += 1;
209
+ if (retryRef.current === 1) write(textEnc.encode('\r\n[waiting for session…]\r\n'));
210
+ retryTimerRef.current = setTimeout(connect, READY_RETRY_MS);
211
+ return;
182
212
  }
183
- case 'role_assigned':
184
- roleRef.current = m.payload?.role ?? null;
185
- setState((s) => ({ ...s, role: m.payload?.role ?? null }));
186
- requestAnimationFrame(() => fit());
187
- break;
188
- case 'output':
189
- if (m.seq) lastSeq.current = Math.max(lastSeq.current, m.seq);
190
- write(b64ToBytes(m.payload?.data ?? ''));
191
- break;
192
- case 'keyframe':
193
- for (const f of m.payload?.frames ?? []) { if (f.seq) lastSeq.current = Math.max(lastSeq.current, f.seq); write(b64ToBytes(f.payload.data)); }
194
- break;
195
- case 'error':
196
- write(textEnc.encode(`\r\n[${m.payload?.code ?? 'error'}]\r\n`));
197
- break;
198
- }
199
- });
213
+ void kind;
214
+ write(textEnc.encode('\r\n[connection error — session did not become ready]\r\n'));
215
+ };
216
+ ws.addEventListener('close', () => onGone('close'));
217
+ ws.addEventListener('error', () => onGone('error'));
218
+ ws.addEventListener('message', (ev) => {
219
+ let m: WsMsg;
220
+ try { m = JSON.parse(ev.data as string); } catch { return; }
221
+ switch (m.op) {
222
+ case 'binding_hello': {
223
+ sendOp('pty.join_session', { role: requestedRole });
224
+ // Tell the PTY our current dimensions up front so the first tmux redraw fits.
225
+ const t = termRef.current;
226
+ if (t && t.cols >= RESIZE_FLOOR_COLS && t.rows >= RESIZE_FLOOR_ROWS) sendOp('pty.session_resize', { cols: t.cols, rows: t.rows });
227
+ break;
228
+ }
229
+ case 'role_assigned': {
230
+ const role = m.payload?.role ?? null;
231
+ roleRef.current = role;
232
+ setState((s) => ({ ...s, role }));
233
+ // Only a controller may type into the terminal; observers are read-only.
234
+ if (termRef.current) termRef.current.options.disableStdin = role !== 'controller';
235
+ // Re-attaching to an established session whose shell is idle gets no
236
+ // live output and the join replay carries no keyframe — request one so
237
+ // the current screen paints immediately instead of staying blank.
238
+ if (!gotFrameRef.current) sendOp('pty.request_keyframe');
239
+ requestAnimationFrame(() => fit());
240
+ break;
241
+ }
242
+ case 'output':
243
+ gotFrameRef.current = true; retryRef.current = 0; // first frame → readiness reached
244
+ if (m.seq) lastSeq.current = Math.max(lastSeq.current, m.seq);
245
+ write(b64ToBytes(m.payload?.data ?? ''));
246
+ break;
247
+ case 'keyframe':
248
+ gotFrameRef.current = true; retryRef.current = 0;
249
+ for (const f of m.payload?.frames ?? []) { if (f.seq) lastSeq.current = Math.max(lastSeq.current, f.seq); write(b64ToBytes(f.payload.data)); }
250
+ break;
251
+ case 'error':
252
+ write(textEnc.encode(`\r\n[${m.payload?.code ?? 'error'}]\r\n`));
253
+ break;
254
+ }
255
+ });
256
+ };
257
+ connect();
200
258
  }, []);
201
259
 
202
- const detach = useCallback(() => { wsRef.current?.close(); wsRef.current = null; }, []);
260
+ const detach = useCallback(() => {
261
+ closedByUserRef.current = true;
262
+ roleRef.current = null;
263
+ if (termRef.current) termRef.current.options.disableStdin = true; // detached → read-only
264
+ if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
265
+ wsRef.current?.close();
266
+ wsRef.current = null;
267
+ }, []);
203
268
  const replay = useCallback((url: string, requestedRole?: Exclude<Role, null>) => {
204
269
  const role = requestedRole ?? roleRef.current ?? 'observer';
205
270
  detach();