@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.
- package/README.md +80 -12
- package/bridge/src/server.mjs +301 -21
- package/bridge/src/smoke.mjs +3 -3
- package/package.json +2 -2
- package/runtime-docs/README.md +3 -2
- package/shell-core/keychain.mjs +35 -5
- package/vscode/extension.js +13 -26
- package/vscode/smoke.mjs +47 -0
- package/web/src/App.test.tsx +119 -1
- package/web/src/App.tsx +58 -27
- package/web/src/components/Actions.tsx +1 -1
- package/web/src/components/Inventory.tsx +61 -14
- package/web/src/components/Library.tsx +1 -1
- package/web/src/components/Sessions.test.tsx +248 -10
- package/web/src/components/Sessions.tsx +236 -65
- package/web/src/components/StartSessionModal.test.tsx +1 -1
- package/web/src/components/StartSessionModal.tsx +7 -1
- package/web/src/components/Welcome.tsx +11 -10
- package/web/src/sessionMonitor.test.tsx +85 -0
- package/web/src/sessionMonitor.ts +72 -0
- package/web/src/sessionRegistry.test.ts +112 -0
- package/web/src/sessionRegistry.ts +192 -0
- package/web/src/styles.css +20 -2
- package/web/src/types.ts +23 -1
- package/web/src/useSession.test.tsx +207 -7
- package/web/src/useSession.ts +353 -193
- package/web/src/util.ts +8 -1
package/web/src/useSession.ts
CHANGED
|
@@ -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
|
|
7
|
-
// Actions tab can inject into
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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) —
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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 (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
requestAnimationFrame(() => fit());
|
|
173
|
-
}, []);
|
|
211
|
+
}
|
|
174
212
|
|
|
175
|
-
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
216
|
-
|
|
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 = (
|
|
219
|
-
if (
|
|
220
|
-
if (gone) return;
|
|
262
|
+
const onGone = () => {
|
|
263
|
+
if (this.connId !== connId || this.ws !== ws || gone) return;
|
|
221
264
|
gone = true;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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',
|
|
238
|
-
ws.addEventListener('error',
|
|
278
|
+
ws.addEventListener('close', onGone);
|
|
279
|
+
ws.addEventListener('error', onGone);
|
|
239
280
|
ws.addEventListener('message', (ev) => {
|
|
240
|
-
if (
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
282
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|