@aiwg/cockpit 2026.7.9 → 2026.7.11

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.
@@ -357,19 +357,23 @@ async function proxy(res, method, target) {
357
357
  return json(res, r.status, body);
358
358
  }
359
359
 
360
- async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption } = {}) {
360
+ async function fetchJsonFirst(candidates, { method = 'GET', headers, body: requestBodyOption, timeoutMs = 0 } = {}) {
361
361
  const failures = [];
362
362
  for (const candidate of candidates) {
363
363
  const target = typeof candidate === 'string' ? candidate : candidate.target;
364
364
  const requestMethod = typeof candidate === 'string' ? method : candidate.method ?? method;
365
365
  const requestHeaders = typeof candidate === 'string' ? headers : candidate.headers ?? headers;
366
366
  const requestBody = typeof candidate === 'string' ? requestBodyOption : candidate.body ?? requestBodyOption;
367
+ const controller = timeoutMs > 0 ? new AbortController() : null;
368
+ const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
367
369
  let r;
368
370
  try {
369
- r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody });
371
+ r = await fetch(target, { method: requestMethod, headers: requestHeaders, body: requestBody, ...(controller ? { signal: controller.signal } : {}) });
370
372
  } catch (err) {
371
373
  failures.push(`${target} -> ${String(err?.message ?? err)}`);
372
374
  continue;
375
+ } finally {
376
+ if (timeout) clearTimeout(timeout);
373
377
  }
374
378
  const responseBody = await r.json().catch(() => ({}));
375
379
  if (r.ok) return { target, status: r.status, body: responseBody };
@@ -687,7 +691,18 @@ function normalizeTransport(posture) {
687
691
  function normalizeSessionBackends(backends, runtimeKind, state = 'unknown', agentReady = false) {
688
692
  const list = Array.isArray(backends) ? backends : [];
689
693
  if (!list.length && runtimeKind === 'host') {
690
- return [{ mode: 'managed', backend: 'tmux', observe: true, drive: true, replay: false, keyframe: false, available: true, reason: 'agentic-sandbox v1 host session API default' }];
694
+ return [{
695
+ mode: 'managed',
696
+ backend: 'tmux',
697
+ observe: true,
698
+ drive: true,
699
+ replay: false,
700
+ keyframe: false,
701
+ available: agentReady,
702
+ reason: agentReady
703
+ ? 'agentic-sandbox v1 host session API default'
704
+ : 'host runtime is provisioned but the host agent has not registered; PTY sessions are not ready',
705
+ }];
691
706
  }
692
707
  if (!list.length && ['docker', 'container', 'vm', 'qemu', 'kvm'].includes(runtimeKind) && String(state).toLowerCase() === 'running') {
693
708
  return [{
@@ -751,6 +766,28 @@ function normalizeInstance(executorUrl, i) {
751
766
  };
752
767
  }
753
768
 
769
+ function defaultSessionLaunch(instance) {
770
+ const runtime = String(instance?.runtime_posture?.kind ?? instance?.runtime ?? '').toLowerCase();
771
+ if (runtime === 'host') {
772
+ return {
773
+ command: 'bash',
774
+ args: ['-l'],
775
+ working_dir: instance?.launch_context?.cwd,
776
+ };
777
+ }
778
+ if (runtime === 'container' || runtime === 'docker' || runtime === 'vm' || runtime === 'qemu' || runtime === 'kvm') {
779
+ return {
780
+ command: '/bin/bash',
781
+ args: ['-lc', 'cd "${HOME:-/root}" && exec /bin/bash -l'],
782
+ working_dir: '/root',
783
+ };
784
+ }
785
+ return {
786
+ command: 'bash',
787
+ args: ['-l'],
788
+ };
789
+ }
790
+
754
791
  function runtimeExtensionFromCard(card) {
755
792
  const extensions = card?.capabilities?.extensions;
756
793
  if (!Array.isArray(extensions)) return null;
@@ -1284,6 +1321,10 @@ async function getSessions(executorUrl, instanceId) {
1284
1321
  `${executorUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
1285
1322
  ]));
1286
1323
  const sessions = asArrayFromEnvelope(body, ['sessions', 'items', 'data']);
1324
+ return normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId });
1325
+ }
1326
+
1327
+ export function normalizeSessionRows({ sessions, executorUrl, instanceId, sessionAgentId = instanceId }) {
1287
1328
  const wsBase = executorUrl.replace(/^http/i, 'ws');
1288
1329
  const normalizeAttachUrl = (s, sessionId) => {
1289
1330
  const explicit = s.attach_url ?? s.attachUrl;
@@ -1304,26 +1345,83 @@ async function getSessions(executorUrl, instanceId) {
1304
1345
  // agent name is only needed for the session-list FETCH, not the attach path.
1305
1346
  return `${wsBase}/agents/${encodeURIComponent(instanceId)}/sessions/${encodeURIComponent(sessionId)}/attach`;
1306
1347
  };
1307
- // Dedup by session id: the executor can register/return the same session more
1308
- // than once (e.g. a session registered twice in its registry), which surfaced
1309
- // as duplicate rows that are impossible to tell apart in the Sessions picker.
1310
- // Keep the first occurrence of each id (and drop id-less entries).
1311
- const seen = new Set();
1312
- const deduped = [];
1348
+ const sessionAliases = (entry, sessionId) => {
1349
+ const aliases = [`session:${sessionId}`];
1350
+ const commandId = entry.command_id ?? entry.commandId;
1351
+ if (commandId) aliases.push(`command:${commandId}`);
1352
+ return aliases;
1353
+ };
1354
+ const fallbackScore = (entry) => {
1355
+ const sessionId = String(entry.id ?? entry.session_id ?? entry.sessionId ?? '');
1356
+ const commandId = String(entry.command_id ?? entry.commandId ?? '');
1357
+ const id = String(entry.id ?? '');
1358
+ const name = String(entry.session_name ?? entry.sessionName ?? '');
1359
+ const command = String(entry.command ?? '').trim();
1360
+ let score = 0;
1361
+ if (id && commandId && id === commandId) score += 2;
1362
+ if (name && (name === sessionId || name === commandId)) score += 2;
1363
+ if (/^\/?bin\/bash\s+-l$/.test(command)) score += 1;
1364
+ if (entry.has_screen === false || entry.hasScreen === false) score += 1;
1365
+ return score;
1366
+ };
1367
+ const namedScore = (entry) => {
1368
+ const name = String(entry.session_name ?? entry.sessionName ?? '');
1369
+ let score = name ? 1 : 0;
1370
+ if (/^terminal-[a-z0-9-]+$/i.test(name)) score += 2;
1371
+ if (entry.has_screen === true || entry.hasScreen === true) score += 1;
1372
+ return score;
1373
+ };
1374
+ const shouldReplace = (existing, candidate) => {
1375
+ const existingFallback = fallbackScore(existing);
1376
+ const candidateFallback = fallbackScore(candidate);
1377
+ if (existingFallback !== candidateFallback) return candidateFallback < existingFallback;
1378
+ const existingNamed = namedScore(existing);
1379
+ const candidateNamed = namedScore(candidate);
1380
+ if (existingNamed !== candidateNamed) return candidateNamed > existingNamed;
1381
+ return false;
1382
+ };
1383
+ const mergeGroups = (target, source) => {
1384
+ if (target === source) return target;
1385
+ for (const alias of source.aliases) {
1386
+ target.aliases.add(alias);
1387
+ groupsByAlias.set(alias, target);
1388
+ }
1389
+ source.merged = true;
1390
+ if (!target.value || (source.value && shouldReplace(target.value, source.value))) target.value = source.value;
1391
+ return target;
1392
+ };
1393
+ // Dedup by every stable alias we see. Docker/host fallback rows can share a
1394
+ // command id with the Cockpit-created session; QEMU fallback rows can instead
1395
+ // share only the formal session id while carrying a different command id. Keep
1396
+ // the named/screen-backed session row so the UI exposes the working attach URL.
1397
+ const groups = [];
1398
+ const groupsByAlias = new Map();
1313
1399
  for (const s of sessions) {
1314
1400
  const sessionId = s.id ?? s.session_id ?? s.sessionId;
1315
- if (!sessionId || seen.has(sessionId)) continue;
1316
- seen.add(sessionId);
1317
- deduped.push({
1401
+ if (!sessionId) continue;
1402
+ const normalized = {
1318
1403
  ...s,
1319
1404
  id: sessionId,
1320
1405
  instance_id: s.instance_id ?? s.instanceId ?? instanceId,
1321
1406
  agent_id: s.agent_id ?? s.agentId ?? sessionAgentId,
1322
1407
  role_policy: s.role_policy ?? s.rolePolicy ?? (s.default_role === 'observer' ? 'observe-default' : s.default_role) ?? 'observe-default',
1323
1408
  attach_url: normalizeAttachUrl(s, sessionId),
1324
- });
1409
+ };
1410
+ const aliases = sessionAliases(s, sessionId);
1411
+ let group = aliases.map((alias) => groupsByAlias.get(alias)).find(Boolean);
1412
+ if (!group) {
1413
+ group = { aliases: new Set(), value: null, merged: false };
1414
+ groups.push(group);
1415
+ }
1416
+ for (const alias of aliases) {
1417
+ const other = groupsByAlias.get(alias);
1418
+ if (other && other !== group) group = mergeGroups(group, other);
1419
+ group.aliases.add(alias);
1420
+ groupsByAlias.set(alias, group);
1421
+ }
1422
+ if (!group.value || shouldReplace(group.value, normalized)) group.value = normalized;
1325
1423
  }
1326
- return { instance_id: instanceId, sessions: deduped };
1424
+ return { instance_id: instanceId, sessions: groups.filter((group) => !group.merged && group.value).map((group) => group.value) };
1327
1425
  }
1328
1426
 
1329
1427
  async function endSession(executorUrl, instanceId, sessionId) {
@@ -1566,6 +1664,14 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1566
1664
  if (backend) qs.set('backend', backend);
1567
1665
  if (loadout) qs.set('loadout', loadout);
1568
1666
  const sessionAgentId = await resolveSessionAgentId(upstreamUrl, id);
1667
+ let sessionLaunch = defaultSessionLaunch();
1668
+ try {
1669
+ const inventory = await getInventory(upstreamUrl);
1670
+ sessionLaunch = defaultSessionLaunch(inventory.instances.find((inst) => inst.id === id));
1671
+ } catch {
1672
+ // Session creation can still proceed without an explicit cwd; the
1673
+ // executor/agent will fall back to its own process cwd.
1674
+ }
1569
1675
  const candidates = unique([sessionAgentId, id]).flatMap((agentId) => [
1570
1676
  {
1571
1677
  target: `${upstreamUrl}/api/v1/agents/${encodeURIComponent(agentId)}/sessions`,
@@ -1574,8 +1680,9 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1574
1680
  body: JSON.stringify({
1575
1681
  session_backend: backend || 'tmux',
1576
1682
  session_class: mode || 'managed',
1577
- command: 'bash',
1578
- args: ['-l'],
1683
+ command: sessionLaunch.command,
1684
+ args: sessionLaunch.args,
1685
+ ...(sessionLaunch.working_dir ? { working_dir: sessionLaunch.working_dir } : {}),
1579
1686
  }),
1580
1687
  },
1581
1688
  {
@@ -1585,7 +1692,7 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1585
1692
  ]);
1586
1693
  let sessionCreate;
1587
1694
  try {
1588
- sessionCreate = await fetchJsonFirst(candidates);
1695
+ sessionCreate = await fetchJsonFirst(candidates, { timeoutMs: 8000 });
1589
1696
  } catch (err) {
1590
1697
  return json(res, 409, {
1591
1698
  error: 'agent_not_registered',
@@ -3,7 +3,7 @@
3
3
  import assert from 'node:assert/strict';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { createExecutor } from '../../mock-executor/src/server.mjs';
6
- import { createBridge } from './server.mjs';
6
+ import { createBridge, normalizeSessionRows } from './server.mjs';
7
7
 
8
8
  const mock = createExecutor();
9
9
  await new Promise((r) => mock.listen(0, '127.0.0.1', r));
@@ -56,6 +56,32 @@ try {
56
56
  assert.equal(demo.backend, 'native', 'demo session backend');
57
57
  assert.equal(demo.role_policy, 'observe-default', 'session role policy');
58
58
 
59
+ const qemuDedup = normalizeSessionRows({
60
+ executorUrl,
61
+ instanceId: 'vm-1',
62
+ sessionAgentId: 'vm-agent-name',
63
+ sessions: [
64
+ {
65
+ id: 'sess-formal',
66
+ session_id: 'sess-formal',
67
+ command_id: 'cmd-real',
68
+ session_name: 'terminal-qemu',
69
+ command: '/bin/bash',
70
+ has_screen: true,
71
+ },
72
+ {
73
+ id: 'sess-formal',
74
+ session_id: 'sess-formal',
75
+ command_id: 'sess-formal',
76
+ session_name: 'sess-formal',
77
+ command: '/bin/bash -l',
78
+ has_screen: false,
79
+ },
80
+ ],
81
+ });
82
+ assert.equal(qemuDedup.sessions.length, 1, 'QEMU formal session + fallback row dedupe to one session');
83
+ assert.equal(qemuDedup.sessions[0].session_name, 'terminal-qemu', 'dedupe keeps the named screen-backed session');
84
+
59
85
  // missing instance param is a 400
60
86
  assert.equal((await f("/api/sessions")).status, 400, 'sessions requires instance');
61
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.7.9",
3
+ "version": "2026.7.11",
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",
@@ -139,6 +139,7 @@ describe('LaunchInstanceModal', () => {
139
139
  .find((call) => String(call[0]).includes('/api/instances') && call[1]?.method === 'POST');
140
140
  expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
141
141
  runtime: 'qemu',
142
+ agentshare: true,
142
143
  ssh_key: '~/.ssh/agentic_ed25519.pub',
143
144
  });
144
145
  });
@@ -141,7 +141,10 @@ export function LaunchInstanceModal({
141
141
  body.image = image === '__custom__' ? customImage.trim() : image;
142
142
  body.agentshare = true;
143
143
  }
144
- if (runtime === 'qemu' && sshKey.trim()) body.ssh_key = sshKey.trim();
144
+ if (runtime === 'qemu') {
145
+ body.agentshare = true;
146
+ if (sshKey.trim()) body.ssh_key = sshKey.trim();
147
+ }
145
148
  if (runtime === 'docker' && mounts.trim()) body.mounts = mounts.split('\n').map((m) => m.trim()).filter(Boolean);
146
149
  const op = await api<{ id?: string; instance_id?: string; instanceId?: string; operation?: { id?: string }; result?: { instance_id?: string; instanceId?: string } }>('/api/instances', {
147
150
  method: 'POST',
@@ -121,7 +121,7 @@ describe('Sessions', () => {
121
121
  expect(await within(nav).findByTitle('sess-new')).toBeTruthy();
122
122
  });
123
123
 
124
- it('auto-attaches in observe when a different session is selected (#1670)', async () => {
124
+ it('keeps controller posture when a different session is selected while driving (#1670)', async () => {
125
125
  const session = stubSession(); // currently attached to .../sessions/sess-1/attach as controller
126
126
  const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
127
127
  const url = String(input);
@@ -137,11 +137,12 @@ describe('Sessions', () => {
137
137
  const nav = screen.getByLabelText('Instances and sessions');
138
138
  const sessBtn = await within(nav).findByTitle('sess-2');
139
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');
140
+ // Selecting a not-yet-attached session should not silently downgrade an
141
+ // operator who is already driving another session.
142
+ expect(session.attach).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-2/attach', false, 'controller');
142
143
  });
143
144
 
144
- it('does not re-attach (downgrade) when re-selecting the session already attached', async () => {
145
+ it('reattaches with replay instead of downgrading when re-selecting the session already attached', async () => {
145
146
  const session = stubSession(); // state.url === .../sessions/sess-1/attach, role controller
146
147
  const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
147
148
  const url = String(input);
@@ -156,8 +157,10 @@ describe('Sessions', () => {
156
157
 
157
158
  const nav = screen.getByLabelText('Instances and sessions');
158
159
  fireEvent.click(await within(nav).findByTitle('sess-1'));
159
- // Clicking the session we already drive must not downgrade us back to observer.
160
+ // Clicking the session we already drive replays/reasserts controller; it
161
+ // must not downgrade us back to observer.
160
162
  expect(session.attach).not.toHaveBeenCalled();
163
+ expect(session.replay).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-1/attach', 'controller');
161
164
  });
162
165
 
163
166
  it('distinguishes sessions by name + backend + viewer count in the nav (#1670)', async () => {
@@ -53,6 +53,7 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
53
53
  .then((d) => {
54
54
  const nextSessions = d.sessions ?? [];
55
55
  setSessions(nextSessions);
56
+ setSessionErr('');
56
57
  setAttachUrl((currentUrl) => {
57
58
  if (currentUrl && nextSessions.some((s) => s.attach_url === currentUrl)) return currentUrl;
58
59
  return nextSessions[0]?.attach_url ?? '';
@@ -147,13 +148,18 @@ export function Sessions({ session, composer, setComposer, onRequestStart, refre
147
148
  <li key={s.id}>
148
149
  <button
149
150
  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).
151
+ // Selecting a session keeps the operator's current posture when
152
+ // possible. If they are already driving, do not silently downgrade
153
+ // to observe; if they click the attached row, reattach+replay so
154
+ // Docker/tmux streams repaint and control is reasserted.
154
155
  onClick={() => {
156
+ const role = session.state.role === 'controller' && selectedBackend?.drive !== false ? 'controller' : 'observer';
155
157
  setAttachUrl(s.attach_url);
156
- if (s.attach_url !== session.state.url) session.attach(s.attach_url, false, 'observer');
158
+ if (s.attach_url === session.state.url && attached) {
159
+ session.replay(s.attach_url, role);
160
+ } else {
161
+ session.attach(s.attach_url, false, role);
162
+ }
157
163
  }}
158
164
  title={s.id}
159
165
  >
@@ -11,13 +11,13 @@ const INSTANCE = {
11
11
  launch_context: { loadout: 'agentic-dev' },
12
12
  session_backends: [{ mode: 'managed', backend: 'tmux', available: true, drive: true }],
13
13
  };
14
- function mockFetch(postImpl?: () => Response | Promise<Response>) {
14
+ function mockFetch(postImpl?: (init?: RequestInit) => Response | Promise<Response>) {
15
15
  return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
16
16
  const url = String(input);
17
17
  const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
18
18
  if (url.includes('/api/inventory')) return ok({ instances: [INSTANCE] });
19
19
  if (url.includes('/sessions') && init?.method === 'POST') {
20
- return postImpl ? postImpl() : ok({ id: 'sess-x', attach_url: 'ws://x/agents/i/sessions/sess-x/attach' });
20
+ return postImpl ? postImpl(init) : ok({ id: 'sess-x', attach_url: 'ws://x/agents/i/sessions/sess-x/attach' });
21
21
  }
22
22
  return new Response('{}', { status: 404 });
23
23
  }) as unknown as typeof fetch;
@@ -32,7 +32,7 @@ function stubSession(attached = false): SessionApi {
32
32
  }
33
33
 
34
34
  beforeEach(() => { (window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPIT_TOKEN__ = 't'; });
35
- afterEach(() => { cleanup(); vi.restoreAllMocks(); });
35
+ afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); });
36
36
 
37
37
  describe('StartSessionModal (#1640/#1641)', () => {
38
38
  it('renders nothing when closed', () => {
@@ -83,4 +83,27 @@ describe('StartSessionModal (#1640/#1641)', () => {
83
83
  await waitFor(() => expect(screen.getByText(/→ 500/)).toBeTruthy());
84
84
  expect(alertSpy).not.toHaveBeenCalled();
85
85
  });
86
+
87
+ it('unlocks when session start times out', async () => {
88
+ globalThis.fetch = mockFetch((init) => new Promise<Response>((_, reject) => {
89
+ init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
90
+ }));
91
+ render(<StartSessionModal open onClose={() => {}} session={stubSession()} onStarted={() => {}} startTimeoutMs={5} />);
92
+ const startBtn = await screen.findByRole('button', { name: /start session/i });
93
+ await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
94
+ fireEvent.click(startBtn);
95
+ expect(await screen.findByRole('button', { name: /starting/i })).toBeTruthy();
96
+ await waitFor(() => expect(screen.getByText(/timed out/i)).toBeTruthy());
97
+ expect(screen.getByRole('button', { name: /start session/i })).toBeTruthy();
98
+ });
99
+
100
+ it('unlocks when the bridge returns no attach URL', async () => {
101
+ globalThis.fetch = mockFetch(() => new Response('{"id":"sess-x"}', { status: 200, headers: { 'content-type': 'application/json' } }));
102
+ render(<StartSessionModal open onClose={() => {}} session={stubSession()} onStarted={() => {}} />);
103
+ const startBtn = await screen.findByRole('button', { name: /start session/i });
104
+ await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
105
+ fireEvent.click(startBtn);
106
+ await waitFor(() => expect(screen.getByText(/no attach URL/i)).toBeTruthy());
107
+ expect(screen.getByRole('button', { name: /start session/i })).toBeTruthy();
108
+ });
86
109
  });
@@ -15,9 +15,12 @@ interface Props {
15
15
  session: SessionApi;
16
16
  onStarted: () => void; // switch to the Sessions workspace after attach
17
17
  initialInstanceId?: string; // pre-select when opened from a specific instance/board
18
+ startTimeoutMs?: number;
18
19
  }
19
20
 
20
- export function StartSessionModal({ open, onClose, session, onStarted, initialInstanceId }: Props) {
21
+ const START_SESSION_TIMEOUT_MS = 15000;
22
+
23
+ export function StartSessionModal({ open, onClose, session, onStarted, initialInstanceId, startTimeoutMs = START_SESSION_TIMEOUT_MS }: Props) {
21
24
  const [instances, setInstances] = useState<Instance[]>([]);
22
25
  const [instId, setInstId] = useState('');
23
26
  const [loadoutId, setLoadoutId] = useState('');
@@ -77,17 +80,26 @@ export function StartSessionModal({ open, onClose, session, onStarted, initialIn
77
80
  const start = async () => {
78
81
  if (!current || !selectedBackend) return;
79
82
  setBusy(true); setErr('');
83
+ const controller = new AbortController();
84
+ const timeout = window.setTimeout(() => controller.abort(), startTimeoutMs);
80
85
  try {
81
86
  const qs = new URLSearchParams({ mode: selectedBackend.mode, backend: selectedBackend.backend });
82
87
  const s = await api<{ id: string; attach_url: string }>(
83
- `/api/instances/${encodeURIComponent(current.id)}/sessions?${qs}`, { method: 'POST' },
88
+ `/api/instances/${encodeURIComponent(current.id)}/sessions?${qs}`,
89
+ { method: 'POST', signal: controller.signal },
84
90
  );
91
+ if (!s.attach_url) throw new Error('Session started but no attach URL was returned.');
85
92
  session.attach(s.attach_url, false, posture);
86
93
  onStarted();
87
94
  onClose();
88
95
  } catch (e) {
89
- setErr((e as Error).message); // inline — the operator sees exactly why
96
+ const message = e instanceof DOMException && e.name === 'AbortError'
97
+ ? 'Starting the session timed out. The instance may still be preparing PTY support; refresh sessions and try again.'
98
+ : (e as Error).message;
99
+ setErr(message); // inline — the operator sees exactly why
90
100
  setBusy(false);
101
+ } finally {
102
+ window.clearTimeout(timeout);
91
103
  }
92
104
  };
93
105
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { renderHook, act } from '@testing-library/react';
3
- import { useSession } from './useSession';
3
+ import { stripTerminalAutoResponses, useSession } from './useSession';
4
4
 
5
5
  // Minimal WebSocket double: records every constructed socket and lets the test
6
6
  // drive open/close/message. Mirrors the readiness-race timing (#1669).
@@ -23,6 +23,17 @@ beforeEach(() => {
23
23
  });
24
24
  afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
25
25
 
26
+ describe('stripTerminalAutoResponses', () => {
27
+ it('drops OSC color query replies without dropping real input', () => {
28
+ const data = '\x1b]10;rgb:cdcd/d3d3/dede\x07ls\x1b]11;rgb:0a0a/0c0c/1010\x1b\\\r';
29
+ expect(stripTerminalAutoResponses(data)).toBe('ls\r');
30
+ });
31
+
32
+ it('drops terminal identity/status replies', () => {
33
+ expect(stripTerminalAutoResponses('\x1b[?1;2chello\x1b[0n\x1b[12;40R')).toBe('hello');
34
+ });
35
+ });
36
+
26
37
  describe('useSession — retry through the PTY-readiness window (#1669)', () => {
27
38
  it('reconnects on an early empty close instead of giving up', () => {
28
39
  const { result } = renderHook(() => useSession());
@@ -77,4 +88,67 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
77
88
  // 1 initial + 6 retries = 7 sockets, then it stops.
78
89
  expect(MockWS.instances.length).toBeLessThanOrEqual(7);
79
90
  });
91
+
92
+ it('ignores stale socket messages after switching sessions', () => {
93
+ const { result } = renderHook(() => useSession());
94
+ act(() => { result.current.attach('ws://x/agents/i/sessions/old/attach', false, 'controller'); });
95
+ const old = MockWS.instances[0];
96
+ act(() => { result.current.attach('ws://x/agents/i/sessions/new/attach', false, 'observer'); });
97
+ const current = MockWS.instances[1];
98
+
99
+ act(() => {
100
+ old.emit('open');
101
+ old.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
102
+ old.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
103
+ old.emit('close');
104
+ });
105
+ expect(old.sent).toEqual([]);
106
+ expect(result.current.state.url).toBe('ws://x/agents/i/sessions/new/attach');
107
+ expect(result.current.state.role).toBeNull();
108
+
109
+ act(() => {
110
+ current.emit('open');
111
+ current.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
112
+ current.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'observer' } }) });
113
+ });
114
+ expect(JSON.parse(current.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer' } });
115
+ expect(result.current.state.role).toBe('observer');
116
+ });
117
+
118
+ it('does not let a stale close clear the active controller role', () => {
119
+ const { result } = renderHook(() => useSession());
120
+ act(() => { result.current.attach('ws://x/old', false, 'observer'); });
121
+ const old = MockWS.instances[0];
122
+ act(() => { result.current.attach('ws://x/new', false, 'controller'); });
123
+ const current = MockWS.instances[1];
124
+
125
+ act(() => {
126
+ current.emit('open');
127
+ current.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
128
+ });
129
+ expect(result.current.state.role).toBe('controller');
130
+
131
+ act(() => { old.emit('close'); });
132
+ expect(result.current.state.url).toBe('ws://x/new');
133
+ expect(result.current.state.attached).toBe(true);
134
+ expect(result.current.state.role).toBe('controller');
135
+ });
136
+
137
+ it('reattaches for replay immediately and asks the active socket for replay_from', () => {
138
+ const { result } = renderHook(() => useSession());
139
+ act(() => { result.current.attach('ws://x/session', false, 'controller'); });
140
+ const first = MockWS.instances[0];
141
+ act(() => {
142
+ first.emit('open');
143
+ first.emit('message', { data: JSON.stringify({ op: 'output', seq: 12, payload: { data: btoa('ready') } }) });
144
+ });
145
+
146
+ act(() => { result.current.replay('ws://x/session', 'controller'); });
147
+ expect(MockWS.instances).toHaveLength(2);
148
+ const replay = MockWS.instances[1];
149
+ expect(replay.url).toBe('ws://x/session?replay_from=12');
150
+
151
+ act(() => { replay.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) }); });
152
+ expect(JSON.parse(replay.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'controller', replay_from: 12 } });
153
+ });
80
154
  });
@@ -51,6 +51,13 @@ const toB64 = (s: string): string => {
51
51
  return btoa(bin);
52
52
  };
53
53
 
54
+ export function stripTerminalAutoResponses(data: string): string {
55
+ return data
56
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
57
+ .replace(/\x1b\[\?[0-9;]*[cnhl]/g, '')
58
+ .replace(/\x1b\[[0-9;]*[Rn]/g, '');
59
+ }
60
+
54
61
  function stripAnsi(text: string): string {
55
62
  return text
56
63
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
@@ -82,6 +89,7 @@ export function useSession() {
82
89
  const roRef = useRef<ResizeObserver | null>(null);
83
90
  const roleRef = useRef<Role>(null); // current role, read by term.onData without re-subscribing
84
91
  const outputTailRef = useRef('');
92
+ const connectionIdRef = useRef(0);
85
93
  // Retry-through-readiness state (#1669): a freshly-launched VM/container can
86
94
  // accept the pty-ws attach, send 0 frames, and close within ~2s because the
87
95
  // agent's PTY/tmux isn't streamable yet. Rather than show a hard
@@ -111,7 +119,9 @@ export function useSession() {
111
119
  noteOutput(bytes);
112
120
  try { termRef.current?.write(bytes); } catch { /* term not open */ }
113
121
  };
114
- const sendOp = (op: string, payload?: unknown) => { try { wsRef.current?.send(JSON.stringify(payload === undefined ? { op } : { op, payload })); } catch { /* socket closed */ } };
122
+ const encodeOp = (op: string, payload?: unknown) => JSON.stringify(payload === undefined ? { op } : { op, payload });
123
+ const sendOp = (op: string, payload?: unknown) => { try { wsRef.current?.send(encodeOp(op, payload)); } catch { /* socket closed */ } };
124
+ const sendOn = (ws: WebSocket, op: string, payload?: unknown) => { try { ws.send(encodeOp(op, payload)); } catch { /* socket closed */ } };
115
125
  const clearResponseNeeded = () => setResponseNeeded({ needed: false, prompt: '', since: null, source: 'pty' });
116
126
 
117
127
  // Mount the terminal into the host element (ref callback from the Sessions tab).
@@ -139,11 +149,14 @@ export function useSession() {
139
149
  // Forward keystrokes to the PTY only while driving.
140
150
  term.onData((data) => {
141
151
  if (roleRef.current !== 'controller') return;
152
+ const userData = stripTerminalAutoResponses(data);
153
+ if (!userData) return;
142
154
  clearResponseNeeded();
143
- sendOp('pty.session_input', { data: toB64(data) });
155
+ sendOp('pty.session_input', { data: toB64(userData) });
144
156
  });
145
157
  // Keep tmux sized to the terminal so redraws don't wrap/overflow.
146
158
  term.onResize(({ cols, rows }) => {
159
+ if (roleRef.current !== 'controller') return;
147
160
  if (cols < RESIZE_FLOOR_COLS || rows < RESIZE_FLOOR_ROWS) return;
148
161
  sendOp('pty.session_resize', { cols, rows });
149
162
  });
@@ -172,11 +185,14 @@ export function useSession() {
172
185
  try { termRef.current?.dispose(); } catch { /* */ }
173
186
  closedByUserRef.current = true;
174
187
  if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
188
+ connectionIdRef.current += 1;
175
189
  wsRef.current?.close();
176
190
  }, []);
177
191
 
178
192
  const attach = useCallback((url: string, replay = false, requestedRole: Exclude<Role, null> = 'observer') => {
179
193
  if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
194
+ const connectionId = connectionIdRef.current + 1;
195
+ connectionIdRef.current = connectionId;
180
196
  closedByUserRef.current = false;
181
197
  retryRef.current = 0;
182
198
  gotFrameRef.current = false;
@@ -191,11 +207,16 @@ export function useSession() {
191
207
 
192
208
  // Open (or re-open, on a readiness retry) the data-plane socket.
193
209
  const connect = () => {
210
+ if (connectionIdRef.current !== connectionId || closedByUserRef.current) return;
194
211
  const ws = new WebSocket(replay ? `${url}?replay_from=${lastSeq.current}` : url);
195
212
  wsRef.current = ws;
196
213
  let gone = false; // a failing socket fires BOTH 'error' and 'close' — handle once
197
- ws.addEventListener('open', () => setState((s) => ({ ...s, attached: true, url })));
214
+ ws.addEventListener('open', () => {
215
+ if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
216
+ setState((s) => ({ ...s, attached: true, url }));
217
+ });
198
218
  const onGone = (kind: 'close' | 'error') => {
219
+ if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
199
220
  if (gone) return;
200
221
  gone = true;
201
222
  roleRef.current = null;
@@ -216,14 +237,15 @@ export function useSession() {
216
237
  ws.addEventListener('close', () => onGone('close'));
217
238
  ws.addEventListener('error', () => onGone('error'));
218
239
  ws.addEventListener('message', (ev) => {
240
+ if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
219
241
  let m: WsMsg;
220
242
  try { m = JSON.parse(ev.data as string); } catch { return; }
221
243
  switch (m.op) {
222
244
  case 'binding_hello': {
223
- sendOp('pty.join_session', { role: requestedRole });
245
+ sendOn(ws, 'pty.join_session', { role: requestedRole, ...(replay ? { replay_from: lastSeq.current } : {}) });
224
246
  // Tell the PTY our current dimensions up front so the first tmux redraw fits.
225
247
  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 });
248
+ if (requestedRole === 'controller' && t && t.cols >= RESIZE_FLOOR_COLS && t.rows >= RESIZE_FLOOR_ROWS) sendOn(ws, 'pty.session_resize', { cols: t.cols, rows: t.rows });
227
249
  break;
228
250
  }
229
251
  case 'role_assigned': {
@@ -232,10 +254,9 @@ export function useSession() {
232
254
  setState((s) => ({ ...s, role }));
233
255
  // Only a controller may type into the terminal; observers are read-only.
234
256
  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');
257
+ // The gateway owns replay/keyframe delivery for joined sessions.
258
+ // Avoid probing here: on some backends keyframe requests are
259
+ // controller-gated and create noisy permission errors for observers.
239
260
  requestAnimationFrame(() => fit());
240
261
  break;
241
262
  }
@@ -259,6 +280,7 @@ export function useSession() {
259
280
 
260
281
  const detach = useCallback(() => {
261
282
  closedByUserRef.current = true;
283
+ connectionIdRef.current += 1;
262
284
  roleRef.current = null;
263
285
  if (termRef.current) termRef.current.options.disableStdin = true; // detached → read-only
264
286
  if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
@@ -267,9 +289,8 @@ export function useSession() {
267
289
  }, []);
268
290
  const replay = useCallback((url: string, requestedRole?: Exclude<Role, null>) => {
269
291
  const role = requestedRole ?? roleRef.current ?? 'observer';
270
- detach();
271
- setTimeout(() => attach(url, true, role), 50);
272
- }, [attach, detach]);
292
+ attach(url, true, role);
293
+ }, [attach]);
273
294
  const requestKeyframe = useCallback(() => sendOp('pty.request_keyframe'), []);
274
295
  // Composer line-input (the input row + Actions inject). Raw keystrokes go via term.onData.
275
296
  const sendInput = useCallback((text: string): boolean => {