@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.
@@ -3,19 +3,24 @@ import { Terminal } from '@xterm/xterm';
3
3
  import { FitAddon } from '@xterm/addon-fit';
4
4
  import type { Role } from './types';
5
5
 
6
- // The pty session connection, lifted to App so the Sessions tab renders it and the
7
- // Actions tab can inject into it. Data plane is browser→executor (the attach_url the
8
- // Bridge issues); control plane stays on the Bridge.
6
+ // The pty session connections, lifted to App so the Sessions tab renders them and the
7
+ // Actions tab can inject into the active one. Data plane is browser→executor (the
8
+ // attach_url the Bridge issues); control plane stays on the Bridge.
9
9
  //
10
- // Output is rendered through an xterm.js terminal so ANSI/VT/OSC sequences — colors,
11
- // tmux redraws, window titles, bracketed paste, shell-integration markers — are
12
- // *interpreted*, not dumped as raw escape bytes (the bug a plain <pre>{output} showed).
13
- // This mirrors the agentic-sandbox dashboard's pty-ws.v1 client: write decoded bytes to
14
- // the terminal, forward keystrokes as pty.session_input, and keep the PTY (tmux) sized
15
- // to the terminal via pty.session_resize.
10
+ // Each (instance, session) keeps its OWN persistent xterm Terminal + WebSocket, mounted
11
+ // once and hidden (not torn down) when another session is shown. Switching sessions is a
12
+ // show/hide — the socket stays connected and the terminal keeps its scrollback — so
13
+ // history is preserved and the current screen is already painted (no reset, no re-attach,
14
+ // no "press enter to repaint"). This is the per-session-terminal model (#1749) that
15
+ // replaced the earlier single-terminal-reset-on-switch design.
16
+ //
17
+ // Output is rendered through xterm.js so ANSI/VT/OSC sequences — colors, tmux redraws,
18
+ // window titles, bracketed paste, shell-integration markers — are *interpreted*, not
19
+ // dumped as raw escape bytes.
16
20
  type WsMsg = { op: string; seq?: number; payload?: { role?: Role; data?: string; code?: string; frames?: { seq: number; payload: { data: string } }[] } };
17
21
 
18
- export interface SessionState { attached: boolean; role: Role; url: string | null }
22
+ export interface SessionTarget { instanceId: string; sessionId: string }
23
+ export interface SessionState { attached: boolean; role: Role; url: string | null; target: SessionTarget | null }
19
24
  export interface ResponseNeededState { needed: boolean; prompt: string; since: string | null; source: string }
20
25
 
21
26
  // The executor rejects resizes below this floor (management/src/ws/connection.rs).
@@ -27,13 +32,14 @@ const RESIZE_FLOOR_ROWS = 5;
27
32
  // up; ~7s of reconnects rides past that without a hard error.
28
33
  const MAX_READY_RETRIES = 6;
29
34
  const READY_RETRY_MS = 1200;
35
+ const FIRST_FRAME_NOTICE_MS = 2000;
36
+ const FIRST_FRAME_DEADLINE_MS = 4000;
30
37
 
31
38
  const textEnc = new TextEncoder();
32
39
  const textDec = new TextDecoder();
33
40
 
34
41
  // base64 → raw bytes. xterm does its own UTF-8 decoding and escape-sequence parsing, so
35
- // it must receive bytes (Uint8Array) — handing it a Latin-1 string is exactly what made
36
- // the escape sequences render as literal text.
42
+ // it must receive bytes (Uint8Array) — a Latin-1 string renders escapes as literal text.
37
43
  const b64ToBytes = (b64: string): Uint8Array => {
38
44
  try {
39
45
  const bin = atob(b64);
@@ -51,6 +57,19 @@ const toB64 = (s: string): string => {
51
57
  return btoa(bin);
52
58
  };
53
59
 
60
+ export function sessionTargetFromAttachUrl(url: string | null): SessionTarget | null {
61
+ if (!url) return null;
62
+ const pattern = /\/agents\/([^/]+)\/sessions\/([^/]+)\/attach/;
63
+ try {
64
+ const parsed = new URL(url);
65
+ const match = parsed.pathname.match(pattern);
66
+ return match ? { instanceId: decodeURIComponent(match[1]), sessionId: decodeURIComponent(match[2]) } : null;
67
+ } catch {
68
+ const match = url.match(pattern);
69
+ return match ? { instanceId: decodeURIComponent(match[1]), sessionId: decodeURIComponent(match[2]) } : null;
70
+ }
71
+ }
72
+
54
73
  export function stripTerminalAutoResponses(data: string): string {
55
74
  return data
56
75
  .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
@@ -81,225 +100,366 @@ function interactivePromptFrom(output: string): string {
81
100
  return lines.slice(-10).join('\n').slice(0, 900);
82
101
  }
83
102
 
84
- export function useSession() {
85
- const wsRef = useRef<WebSocket | null>(null);
86
- const lastSeq = useRef(0);
87
- const termRef = useRef<Terminal | null>(null);
88
- const fitRef = useRef<FitAddon | null>(null);
89
- const roRef = useRef<ResizeObserver | null>(null);
90
- const roleRef = useRef<Role>(null); // current role, read by term.onData without re-subscribing
91
- const outputTailRef = useRef('');
92
- const connectionIdRef = useRef(0);
93
- // Retry-through-readiness state (#1669): a freshly-launched VM/container can
94
- // accept the pty-ws attach, send 0 frames, and close within ~2s because the
95
- // agent's PTY/tmux isn't streamable yet. Rather than show a hard
96
- // [connection error], reconnect a few times until the first frame arrives.
97
- const gotFrameRef = useRef(false); // any output/keyframe seen on the current attach
98
- const retryRef = useRef(0); // reconnect attempts since the last user-initiated attach
99
- const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
100
- const closedByUserRef = useRef(false); // detach()/new attach — suppress reconnect
101
- const [state, setState] = useState<SessionState>({ attached: false, role: null, url: null });
102
- const [responseNeeded, setResponseNeeded] = useState<ResponseNeededState>({ needed: false, prompt: '', since: null, source: 'pty' });
103
+ function targetKey(t: SessionTarget | null, url: string): string {
104
+ return t ? `${t.instanceId}:${t.sessionId}` : `url:${url}`;
105
+ }
106
+
107
+ type PromptSink = (r: ResponseNeededState | null) => void;
103
108
 
104
- const fit = () => { try { fitRef.current?.fit(); } catch { /* container hidden / zero-sized */ } };
105
- const noteOutput = (bytes: Uint8Array) => {
109
+ // One persistent PTY connection: its own Terminal, wrapper element, and WebSocket, with
110
+ // the readiness-retry lifecycle (#1669/#1746). Created once per session and kept alive —
111
+ // hidden, not disposed — when another session is shown, so scrollback and the live stream
112
+ // survive session switches.
113
+ class PtyConnection {
114
+ readonly term: Terminal;
115
+ readonly fit: FitAddon;
116
+ readonly wrapper: HTMLDivElement;
117
+ url: string;
118
+ target: SessionTarget | null;
119
+ role: Role = null;
120
+ attached = false;
121
+ private ws: WebSocket | null = null;
122
+ private lastSeq = 0;
123
+ private connId = 0;
124
+ private closedByUser = false;
125
+ private gotFrame = false;
126
+ private retries = 0;
127
+ private outputTail = '';
128
+ private ro: ResizeObserver | null = null;
129
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
130
+ private noticeTimer: ReturnType<typeof setTimeout> | null = null;
131
+ private deadlineTimer: ReturnType<typeof setTimeout> | null = null;
132
+ disposed = false;
133
+
134
+ constructor(
135
+ url: string,
136
+ target: SessionTarget | null,
137
+ private readonly onChange: () => void,
138
+ private readonly onPrompt: PromptSink,
139
+ private readonly isActive: () => boolean,
140
+ ) {
141
+ this.url = url;
142
+ this.target = target;
143
+ this.term = new Terminal({
144
+ convertEol: false,
145
+ scrollback: 5000,
146
+ cursorBlink: false,
147
+ disableStdin: true, // read-only until role_assigned grants control
148
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
149
+ fontSize: 13,
150
+ theme: { background: '#0a0c10', foreground: '#cdd3de' },
151
+ });
152
+ this.fit = new FitAddon();
153
+ this.term.loadAddon(this.fit);
154
+ this.wrapper = document.createElement('div');
155
+ this.wrapper.className = 'pty-surface';
156
+ this.wrapper.style.width = '100%';
157
+ this.wrapper.style.height = '100%';
158
+ try { this.term.open(this.wrapper); } catch { /* jsdom */ }
159
+ this.term.onData((data) => {
160
+ if (this.role !== 'controller') return;
161
+ const userData = stripTerminalAutoResponses(data);
162
+ if (!userData) return;
163
+ if (this.isActive()) this.onPrompt(null);
164
+ this.send('pty.session_input', { data: toB64(userData) });
165
+ });
166
+ this.term.onResize(({ cols, rows }) => {
167
+ if (this.role !== 'controller') return;
168
+ if (cols < RESIZE_FLOOR_COLS || rows < RESIZE_FLOOR_ROWS) return;
169
+ this.send('pty.session_resize', { cols, rows });
170
+ });
171
+ }
172
+
173
+ private fitSafe() { try { this.fit.fit(); } catch { /* hidden / zero-sized */ } }
174
+
175
+ private write(bytes: Uint8Array) {
106
176
  const text = textDec.decode(bytes, { stream: true });
107
- if (!text) return;
108
- outputTailRef.current = (outputTailRef.current + text).slice(-6000);
109
- const prompt = interactivePromptFrom(outputTailRef.current);
110
- if (prompt) {
111
- setResponseNeeded((prev) => (
112
- prev.needed && prev.prompt === prompt
113
- ? prev
114
- : { needed: true, prompt, since: new Date().toISOString(), source: 'pty' }
115
- ));
116
- }
117
- };
118
- const write = (bytes: Uint8Array) => {
119
- noteOutput(bytes);
120
- try { termRef.current?.write(bytes); } catch { /* term not open */ }
121
- };
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 */ } };
125
- const clearResponseNeeded = () => setResponseNeeded({ needed: false, prompt: '', since: null, source: 'pty' });
126
-
127
- // Mount the terminal into the host element (ref callback from the Sessions tab).
128
- // Idempotent: the Terminal is created once and reused across attaches. A ResizeObserver
129
- // re-fits when the host gains size (the tab starts hidden/zero-sized, then becomes
130
- // visible) and on any later layout change, keeping the PTY dimensions honest.
131
- const openTerminal = useCallback((el: HTMLDivElement | null) => {
132
- if (!el) return;
133
- if (!termRef.current) {
134
- const term = new Terminal({
135
- convertEol: false, // the PTY/tmux emits its own CR/LF
136
- scrollback: 2000,
137
- cursorBlink: false,
138
- // Read-only until control is granted: observe must not capture keystrokes
139
- // at all (not just drop them on send). Flipped to false on controller.
140
- disableStdin: true,
141
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
142
- fontSize: 13,
143
- theme: { background: '#0a0c10', foreground: '#cdd3de' },
144
- });
145
- const fitAddon = new FitAddon();
146
- term.loadAddon(fitAddon);
147
- termRef.current = term;
148
- fitRef.current = fitAddon;
149
- // Forward keystrokes to the PTY only while driving.
150
- term.onData((data) => {
151
- if (roleRef.current !== 'controller') return;
152
- const userData = stripTerminalAutoResponses(data);
153
- if (!userData) return;
154
- clearResponseNeeded();
155
- sendOp('pty.session_input', { data: toB64(userData) });
156
- });
157
- // Keep tmux sized to the terminal so redraws don't wrap/overflow.
158
- term.onResize(({ cols, rows }) => {
159
- if (roleRef.current !== 'controller') return;
160
- if (cols < RESIZE_FLOOR_COLS || rows < RESIZE_FLOOR_ROWS) return;
161
- sendOp('pty.session_resize', { cols, rows });
162
- });
177
+ if (text) {
178
+ this.outputTail = (this.outputTail + text).slice(-6000);
179
+ const prompt = interactivePromptFrom(this.outputTail);
180
+ if (prompt && this.isActive()) {
181
+ this.onPrompt({ needed: true, prompt, since: new Date().toISOString(), source: 'pty' });
182
+ }
163
183
  }
164
- try {
165
- if (!el.querySelector('.xterm')) termRef.current.open(el);
166
- } catch { /* jsdom / detached node */ }
167
- if (!roRef.current && typeof ResizeObserver !== 'undefined') {
168
- roRef.current = new ResizeObserver(() => fit());
169
- roRef.current.observe(el);
184
+ try { this.term.write(bytes); } catch { /* term not open */ }
185
+ }
186
+
187
+ private send(op: string, payload?: unknown) {
188
+ try { this.ws?.send(JSON.stringify(payload === undefined ? { op } : { op, payload })); } catch { /* closed */ }
189
+ }
190
+ private sendOn(ws: WebSocket, op: string, payload?: unknown) {
191
+ try { ws.send(JSON.stringify(payload === undefined ? { op } : { op, payload })); } catch { /* closed */ }
192
+ }
193
+
194
+ private clearTimers() {
195
+ if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = null; }
196
+ if (this.noticeTimer) { clearTimeout(this.noticeTimer); this.noticeTimer = null; }
197
+ if (this.deadlineTimer) { clearTimeout(this.deadlineTimer); this.deadlineTimer = null; }
198
+ }
199
+ private clearFrameTimers() {
200
+ if (this.noticeTimer) { clearTimeout(this.noticeTimer); this.noticeTimer = null; }
201
+ if (this.deadlineTimer) { clearTimeout(this.deadlineTimer); this.deadlineTimer = null; }
202
+ }
203
+
204
+ // Reflect visibility. Only the shown surface gets laid out + refit.
205
+ setVisible(visible: boolean) {
206
+ this.wrapper.style.display = visible ? 'block' : 'none';
207
+ if (visible) {
208
+ this.fitSafe();
209
+ requestAnimationFrame(() => this.fitSafe());
170
210
  }
171
- fit();
172
- requestAnimationFrame(() => fit());
173
- }, []);
211
+ }
174
212
 
175
- // Refit on window resize (ResizeObserver covers container changes; this covers the rest).
176
- useEffect(() => {
177
- const onResize = () => fit();
178
- window.addEventListener('resize', onResize);
179
- return () => window.removeEventListener('resize', onResize);
180
- }, []);
213
+ onContainerResize() { if (this.wrapper.style.display !== 'none') this.fitSafe(); }
181
214
 
182
- // Dispose on unmount.
183
- useEffect(() => () => {
184
- try { roRef.current?.disconnect(); } catch { /* */ }
185
- try { termRef.current?.dispose(); } catch { /* */ }
186
- closedByUserRef.current = true;
187
- if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
188
- connectionIdRef.current += 1;
189
- wsRef.current?.close();
190
- }, []);
215
+ // Connect (or re-join, e.g. observer→controller upgrade or a manual replay). Idempotent
216
+ // enough to call again on the same live connection to re-request role/replay.
217
+ connect(requestedRole: Exclude<Role, null>, replay: boolean, replayFromOverride?: number) {
218
+ this.clearTimers();
219
+ const connId = ++this.connId;
220
+ this.closedByUser = false;
221
+ this.retries = 0;
222
+ this.gotFrame = false;
223
+ this.ws?.close();
224
+ if (!replay) { this.lastSeq = 0; this.outputTail = ''; try { this.term.reset(); } catch { /* */ } }
225
+ this.role = null;
226
+ this.term.options.disableStdin = true;
227
+ this.attached = false;
228
+ if (this.isActive()) this.onPrompt(null);
229
+ this.onChange();
191
230
 
192
- const attach = useCallback((url: string, replay = false, requestedRole: Exclude<Role, null> = 'observer') => {
193
- if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
194
- const connectionId = connectionIdRef.current + 1;
195
- connectionIdRef.current = connectionId;
196
- closedByUserRef.current = false;
197
- retryRef.current = 0;
198
- gotFrameRef.current = false;
199
- wsRef.current?.close();
200
- if (!replay) lastSeq.current = 0;
201
- roleRef.current = null;
202
- if (termRef.current) termRef.current.options.disableStdin = true; // read-only until role_assigned grants control
203
- clearResponseNeeded();
204
- outputTailRef.current = '';
205
- setState({ attached: false, role: null, url });
206
- if (!replay) { try { termRef.current?.reset(); } catch { /* */ } }
207
-
208
- // Open (or re-open, on a readiness retry) the data-plane socket.
209
- const connect = () => {
210
- if (connectionIdRef.current !== connectionId || closedByUserRef.current) return;
211
- const ws = new WebSocket(replay ? `${url}?replay_from=${lastSeq.current}` : url);
212
- wsRef.current = ws;
213
- let gone = false; // a failing socket fires BOTH 'error' and 'close' — handle once
231
+ const open = () => {
232
+ if (this.connId !== connId || this.closedByUser || this.disposed) return;
233
+ const ws = new WebSocket(this.url);
234
+ this.ws = ws;
235
+ let gone = false;
214
236
  ws.addEventListener('open', () => {
215
- if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
216
- setState((s) => ({ ...s, attached: true, url }));
237
+ if (this.connId !== connId || this.ws !== ws) return;
238
+ this.attached = true;
239
+ this.onChange();
240
+ this.clearFrameTimers();
241
+ this.noticeTimer = setTimeout(() => {
242
+ if (this.connId !== connId || this.ws !== ws || this.closedByUser || this.gotFrame) return;
243
+ this.write(textEnc.encode('\r\n[attached — no output yet]\r\n'));
244
+ }, FIRST_FRAME_NOTICE_MS);
245
+ this.deadlineTimer = setTimeout(() => {
246
+ if (this.connId !== connId || this.ws !== ws || this.closedByUser || this.gotFrame) return;
247
+ if (this.role === 'controller') {
248
+ this.write(textEnc.encode(`\r\n[attached — no output after ${Math.round(FIRST_FRAME_DEADLINE_MS / 1000)}s; requesting repaint]\r\n`));
249
+ this.sendOn(ws, 'pty.request_keyframe');
250
+ return;
251
+ }
252
+ if (this.retries >= MAX_READY_RETRIES) {
253
+ this.write(textEnc.encode(`\r\n[attached — no output after ${Math.round(FIRST_FRAME_DEADLINE_MS / 1000)}s]\r\n`));
254
+ return;
255
+ }
256
+ this.retries += 1;
257
+ this.write(textEnc.encode(`\r\n[attached — no output after ${Math.round(FIRST_FRAME_DEADLINE_MS / 1000)}s; requesting repaint]\r\n`));
258
+ try { ws.close(); } catch { /* */ }
259
+ open();
260
+ }, FIRST_FRAME_DEADLINE_MS);
217
261
  });
218
- const onGone = (kind: 'close' | 'error') => {
219
- if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
220
- if (gone) return;
262
+ const onGone = () => {
263
+ if (this.connId !== connId || this.ws !== ws || gone) return;
221
264
  gone = true;
222
- roleRef.current = null;
223
- setState((s) => ({ ...s, attached: false, role: null }));
224
- // Clean detach, or we were already streaming → leave it (a real end/drop).
225
- if (closedByUserRef.current || gotFrameRef.current) return;
226
- // Early empty close/error before the first frame → the agent's PTY isn't
227
- // streamable yet. Reconnect through the readiness window rather than error.
228
- if (retryRef.current < MAX_READY_RETRIES) {
229
- retryRef.current += 1;
230
- if (retryRef.current === 1) write(textEnc.encode('\r\n[waiting for session…]\r\n'));
231
- retryTimerRef.current = setTimeout(connect, READY_RETRY_MS);
265
+ this.clearFrameTimers();
266
+ this.role = null;
267
+ this.attached = false;
268
+ this.onChange();
269
+ if (this.closedByUser || this.gotFrame) return;
270
+ if (this.retries < MAX_READY_RETRIES) {
271
+ this.retries += 1;
272
+ if (this.retries === 1) this.write(textEnc.encode('\r\n[waiting for session…]\r\n'));
273
+ this.retryTimer = setTimeout(open, READY_RETRY_MS);
232
274
  return;
233
275
  }
234
- void kind;
235
- write(textEnc.encode('\r\n[connection error — session did not become ready]\r\n'));
276
+ this.write(textEnc.encode('\r\n[connection error — session did not become ready]\r\n'));
236
277
  };
237
- ws.addEventListener('close', () => onGone('close'));
238
- ws.addEventListener('error', () => onGone('error'));
278
+ ws.addEventListener('close', onGone);
279
+ ws.addEventListener('error', onGone);
239
280
  ws.addEventListener('message', (ev) => {
240
- if (connectionIdRef.current !== connectionId || wsRef.current !== ws) return;
281
+ if (this.connId !== connId || this.ws !== ws) return;
241
282
  let m: WsMsg;
242
- try { m = JSON.parse(ev.data as string); } catch { return; }
283
+ try { m = JSON.parse((ev as MessageEvent).data as string); } catch { return; }
243
284
  switch (m.op) {
244
285
  case 'binding_hello': {
245
- sendOn(ws, 'pty.join_session', { role: requestedRole, ...(replay ? { replay_from: lastSeq.current } : {}) });
246
- // Tell the PTY our current dimensions up front so the first tmux redraw fits.
247
- const t = termRef.current;
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 });
286
+ const replayFrom = replayFromOverride ?? (replay ? this.lastSeq : 0);
287
+ this.sendOn(ws, 'pty.join_session', { role: requestedRole, replay_from: replayFrom });
288
+ if (requestedRole === 'controller' && this.term.cols >= RESIZE_FLOOR_COLS && this.term.rows >= RESIZE_FLOOR_ROWS) {
289
+ this.sendOn(ws, 'pty.session_resize', { cols: this.term.cols, rows: this.term.rows });
290
+ }
249
291
  break;
250
292
  }
251
293
  case 'role_assigned': {
252
294
  const role = m.payload?.role ?? null;
253
- roleRef.current = role;
254
- setState((s) => ({ ...s, role }));
255
- // Only a controller may type into the terminal; observers are read-only.
256
- if (termRef.current) termRef.current.options.disableStdin = role !== 'controller';
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.
260
- requestAnimationFrame(() => fit());
295
+ this.role = role;
296
+ this.term.options.disableStdin = role !== 'controller';
297
+ this.onChange();
298
+ requestAnimationFrame(() => this.fitSafe());
261
299
  break;
262
300
  }
263
301
  case 'output':
264
- gotFrameRef.current = true; retryRef.current = 0; // first frame → readiness reached
265
- if (m.seq) lastSeq.current = Math.max(lastSeq.current, m.seq);
266
- write(b64ToBytes(m.payload?.data ?? ''));
302
+ this.clearFrameTimers();
303
+ this.gotFrame = true; this.retries = 0;
304
+ if (m.seq) this.lastSeq = Math.max(this.lastSeq, m.seq);
305
+ this.write(b64ToBytes(m.payload?.data ?? ''));
267
306
  break;
268
307
  case 'keyframe':
269
- gotFrameRef.current = true; retryRef.current = 0;
270
- for (const f of m.payload?.frames ?? []) { if (f.seq) lastSeq.current = Math.max(lastSeq.current, f.seq); write(b64ToBytes(f.payload.data)); }
308
+ this.clearFrameTimers();
309
+ this.gotFrame = true; this.retries = 0;
310
+ for (const f of m.payload?.frames ?? []) { if (f.seq) this.lastSeq = Math.max(this.lastSeq, f.seq); this.write(b64ToBytes(f.payload.data)); }
271
311
  break;
272
312
  case 'error':
273
- write(textEnc.encode(`\r\n[${m.payload?.code ?? 'error'}]\r\n`));
313
+ this.write(textEnc.encode(`\r\n[${m.payload?.code ?? 'error'}]\r\n`));
274
314
  break;
275
315
  }
276
316
  });
277
317
  };
278
- connect();
318
+ open();
319
+ }
320
+
321
+ requestKeyframe() { this.send('pty.request_keyframe'); }
322
+
323
+ sendInput(text: string): boolean {
324
+ if (!this.ws || this.role !== 'controller' || !text) return false;
325
+ if (this.isActive()) this.onPrompt(null);
326
+ this.send('pty.session_input', { data: toB64(text + '\r\n') });
327
+ return true;
328
+ }
329
+
330
+ // Detach the live socket but keep the terminal buffer (used when the whole session ends).
331
+ close() {
332
+ this.closedByUser = true;
333
+ this.connId += 1;
334
+ this.role = null;
335
+ this.attached = false;
336
+ this.term.options.disableStdin = true;
337
+ this.clearTimers();
338
+ this.ws?.close();
339
+ this.ws = null;
340
+ this.onChange();
341
+ }
342
+
343
+ dispose() {
344
+ this.disposed = true;
345
+ this.close();
346
+ try { this.ro?.disconnect(); } catch { /* */ }
347
+ try { this.term.dispose(); } catch { /* */ }
348
+ try { this.wrapper.remove(); } catch { /* */ }
349
+ }
350
+
351
+ observe(container: HTMLElement) {
352
+ if (this.ro || typeof ResizeObserver === 'undefined') return;
353
+ this.ro = new ResizeObserver(() => this.onContainerResize());
354
+ this.ro.observe(container);
355
+ }
356
+ }
357
+
358
+ export function useSession() {
359
+ const connsRef = useRef<Map<string, PtyConnection>>(new Map());
360
+ const activeKeyRef = useRef<string>('');
361
+ const containerRef = useRef<HTMLDivElement | null>(null);
362
+ const [state, setState] = useState<SessionState>({ attached: false, role: null, url: null, target: null });
363
+ const [responseNeeded, setResponseNeeded] = useState<ResponseNeededState>({ needed: false, prompt: '', since: null, source: 'pty' });
364
+
365
+ const active = () => connsRef.current.get(activeKeyRef.current) ?? null;
366
+
367
+ const syncState = useCallback(() => {
368
+ const c = connsRef.current.get(activeKeyRef.current);
369
+ if (!c) { setState({ attached: false, role: null, url: null, target: null }); return; }
370
+ setState({ attached: c.attached, role: c.role, url: c.url, target: c.target });
279
371
  }, []);
280
372
 
281
- const detach = useCallback(() => {
282
- closedByUserRef.current = true;
283
- connectionIdRef.current += 1;
284
- roleRef.current = null;
285
- if (termRef.current) termRef.current.options.disableStdin = true; // detached → read-only
286
- if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
287
- wsRef.current?.close();
288
- wsRef.current = null;
373
+ const setPrompt = useCallback((r: ResponseNeededState | null) => {
374
+ setResponseNeeded(r ?? { needed: false, prompt: '', since: null, source: 'pty' });
289
375
  }, []);
290
- const replay = useCallback((url: string, requestedRole?: Exclude<Role, null>) => {
291
- const role = requestedRole ?? roleRef.current ?? 'observer';
292
- attach(url, true, role);
376
+
377
+ const showOnly = useCallback((key: string) => {
378
+ activeKeyRef.current = key;
379
+ for (const [k, c] of connsRef.current) c.setVisible(k === key);
380
+ setPrompt(null);
381
+ syncState();
382
+ }, [setPrompt, syncState]);
383
+
384
+ const attach = useCallback((url: string, replay = false, requestedRole: Exclude<Role, null> = 'observer', target?: SessionTarget | null) => {
385
+ const tgt = target ?? sessionTargetFromAttachUrl(url);
386
+ const key = targetKey(tgt, url);
387
+ let conn = connsRef.current.get(key);
388
+ if (!conn) {
389
+ conn = new PtyConnection(url, tgt, syncState, setPrompt, () => activeKeyRef.current === key);
390
+ connsRef.current.set(key, conn);
391
+ if (containerRef.current) {
392
+ containerRef.current.appendChild(conn.wrapper);
393
+ conn.observe(containerRef.current);
394
+ }
395
+ conn.connect(requestedRole, replay);
396
+ } else {
397
+ // Existing session: keep it alive. Re-join only when this is an explicit
398
+ // replay (repaint) or a role upgrade — otherwise just re-show it, preserving
399
+ // its scrollback and live stream.
400
+ conn.url = url;
401
+ if (replay || (requestedRole === 'controller' && conn.role !== 'controller')) {
402
+ conn.connect(requestedRole, replay);
403
+ }
404
+ }
405
+ showOnly(key);
406
+ }, [showOnly, setPrompt, syncState]);
407
+
408
+ const detach = useCallback(() => {
409
+ const c = active();
410
+ if (!c) return;
411
+ const key = activeKeyRef.current;
412
+ c.dispose();
413
+ connsRef.current.delete(key);
414
+ // Fall back to any other live session, else nothing.
415
+ const next = connsRef.current.keys().next();
416
+ if (!next.done) showOnly(next.value);
417
+ else { activeKeyRef.current = ''; setPrompt(null); syncState(); }
418
+ }, [showOnly, setPrompt, syncState]);
419
+
420
+ const replay = useCallback((url: string, requestedRole?: Exclude<Role, null>, target?: SessionTarget | null) => {
421
+ const tgt = target ?? sessionTargetFromAttachUrl(url);
422
+ const existing = connsRef.current.get(targetKey(tgt, url));
423
+ const role = requestedRole ?? existing?.role ?? 'observer';
424
+ attach(url, true, role, tgt);
293
425
  }, [attach]);
294
- const requestKeyframe = useCallback(() => sendOp('pty.request_keyframe'), []);
295
- // Composer line-input (the input row + Actions inject). Raw keystrokes go via term.onData.
296
- const sendInput = useCallback((text: string): boolean => {
297
- if (!wsRef.current || roleRef.current !== 'controller' || !text) return false;
298
- clearResponseNeeded();
299
- sendOp('pty.session_input', { data: toB64(text + '\r\n') });
300
- return true;
426
+
427
+ const requestKeyframe = useCallback(() => { active()?.requestKeyframe(); }, []);
428
+
429
+ const sendInput = useCallback((text: string, target?: SessionTarget | null): boolean => {
430
+ const c = active();
431
+ if (!c) return false;
432
+ if (target && (!c.target || c.target.instanceId !== target.instanceId || c.target.sessionId !== target.sessionId)) {
433
+ try { c.term.write(textEnc.encode(`\r\n[inject refused: target ${target.instanceId}:${target.sessionId} does not match attached session ${c.target ? `${c.target.instanceId}:${c.target.sessionId}` : 'none'}]\r\n`)); } catch { /* */ }
434
+ return false;
435
+ }
436
+ return c.sendInput(text);
437
+ }, []);
438
+
439
+ // Container ref from the Sessions tab. Per-session terminals are appended here; only the
440
+ // active one is shown. Reparents any connections created before the container mounted.
441
+ const openTerminal = useCallback((el: HTMLDivElement | null) => {
442
+ if (!el) { containerRef.current = null; return; }
443
+ containerRef.current = el;
444
+ for (const [k, c] of connsRef.current) {
445
+ if (c.wrapper.parentElement !== el) el.appendChild(c.wrapper);
446
+ c.observe(el);
447
+ c.setVisible(k === activeKeyRef.current);
448
+ }
301
449
  }, []);
302
450
 
451
+ useEffect(() => {
452
+ const onResize = () => { for (const c of connsRef.current.values()) c.onContainerResize(); };
453
+ window.addEventListener('resize', onResize);
454
+ return () => window.removeEventListener('resize', onResize);
455
+ }, []);
456
+
457
+ const conns = connsRef.current;
458
+ useEffect(() => () => {
459
+ for (const c of conns.values()) c.dispose();
460
+ conns.clear();
461
+ }, [conns]);
462
+
303
463
  return { state, responseNeeded, attach, detach, replay, requestKeyframe, sendInput, openTerminal, isController: state.role === 'controller' };
304
464
  }
305
465
 
package/web/src/util.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  export const fmtId = (id: string): string => (id && id.length > 12 ? id.slice(0, 8) + '…' : id);
2
2
 
3
+ export function runtimeFamily(kind: string | undefined): 'host' | 'container' | 'vm' | 'other' {
4
+ const normalized = String(kind ?? '').toLowerCase();
5
+ if (normalized === 'host') return 'host';
6
+ if (normalized === 'container' || normalized === 'docker') return 'container';
7
+ if (normalized === 'vm' || normalized === 'qemu' || normalized === 'kvm') return 'vm';
8
+ return 'other';
9
+ }
10
+
3
11
  // How a picked capability is referenced when inserted into a session composer:
4
12
  // - agents → @name (stable convention across hosts)
5
13
  // - skills → the bare skill name. AIWG's discover-first protocol resolves a skill
@@ -14,4 +22,3 @@ export function capRef(type: string, name: string): string {
14
22
  if (type === 'command') return `/${name}`;
15
23
  return name;
16
24
  }
17
-