@dmsdc-ai/aigentry-telepty 0.5.1 → 0.5.3
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/CHANGELOG.md +14 -0
- package/cli.js +37 -123
- package/daemon.js +332 -428
- package/package.json +9 -4
- package/src/cli/session-view.js +100 -0
- package/src/lifecycle.js +114 -1
- package/src/protocol/http-auth.js +71 -0
- package/src/session-store/persistence.js +88 -0
- package/src/submit-gate.js +157 -0
- package/src/transport/peer-relay.js +51 -0
- package/src/transport/websocket.js +295 -0
- package/terminal-backend.js +339 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { WebSocketServer } = require('ws');
|
|
5
|
+
|
|
6
|
+
function isOpenWebSocket(ws) {
|
|
7
|
+
return Boolean(ws && ws.readyState === 1);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function installWebSocketTransport(deps) {
|
|
11
|
+
const {
|
|
12
|
+
server,
|
|
13
|
+
sessions,
|
|
14
|
+
busClients,
|
|
15
|
+
expectedToken,
|
|
16
|
+
verifyJwt,
|
|
17
|
+
isAllowedPeer,
|
|
18
|
+
initializeBootstrapState,
|
|
19
|
+
findKittySocket,
|
|
20
|
+
findKittyWindowId,
|
|
21
|
+
markSessionConnected,
|
|
22
|
+
scheduleBootstrapPromptPoll,
|
|
23
|
+
emitSessionLifecycleEvent,
|
|
24
|
+
persistSessions,
|
|
25
|
+
appendToOutputRing,
|
|
26
|
+
sessionStateManager,
|
|
27
|
+
isBootstrapGatedSession,
|
|
28
|
+
markBootstrapReady,
|
|
29
|
+
pendingReports,
|
|
30
|
+
fireAutoReport,
|
|
31
|
+
markSessionDisconnected,
|
|
32
|
+
resolveSessionAlias,
|
|
33
|
+
applySessionStateReport,
|
|
34
|
+
relayToPeers,
|
|
35
|
+
busAutoRoute
|
|
36
|
+
} = deps;
|
|
37
|
+
|
|
38
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
39
|
+
|
|
40
|
+
wss.on('connection', (ws, req) => {
|
|
41
|
+
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
42
|
+
const sessionId = url.pathname.split('/').pop();
|
|
43
|
+
const session = sessions[sessionId];
|
|
44
|
+
// ?owner=1 indicates the allow bridge (PTY owner), not an attach viewer
|
|
45
|
+
const isOwnerConnect = url.searchParams.get('owner') === '1';
|
|
46
|
+
|
|
47
|
+
// Ping/pong heartbeat — detect and terminate stale TCP half-open connections (30s interval)
|
|
48
|
+
let isAlive = true;
|
|
49
|
+
ws.on('pong', () => { isAlive = true; });
|
|
50
|
+
const pingInterval = setInterval(() => {
|
|
51
|
+
if (!isAlive) {
|
|
52
|
+
console.log(`[WS] Terminating stale connection (no pong) for ${sessionId}`);
|
|
53
|
+
ws.terminate();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
isAlive = false;
|
|
57
|
+
ws.ping();
|
|
58
|
+
}, 30000);
|
|
59
|
+
|
|
60
|
+
if (!session) {
|
|
61
|
+
const connectedAt = new Date().toISOString();
|
|
62
|
+
// Auto-register wrapped session on WS connect (supports reconnect after daemon restart)
|
|
63
|
+
const autoSession = {
|
|
64
|
+
id: sessionId,
|
|
65
|
+
type: 'wrapped',
|
|
66
|
+
ptyProcess: null,
|
|
67
|
+
ownerWs: ws,
|
|
68
|
+
command: 'wrapped',
|
|
69
|
+
cwd: process.cwd(),
|
|
70
|
+
createdAt: connectedAt,
|
|
71
|
+
lastActivityAt: connectedAt,
|
|
72
|
+
lastConnectedAt: connectedAt,
|
|
73
|
+
lastDisconnectedAt: null,
|
|
74
|
+
clients: new Set([ws]),
|
|
75
|
+
isClosing: false,
|
|
76
|
+
outputRing: [],
|
|
77
|
+
ready: true,
|
|
78
|
+
};
|
|
79
|
+
initializeBootstrapState(autoSession);
|
|
80
|
+
sessions[sessionId] = autoSession;
|
|
81
|
+
console.log(`[WS] Auto-registered wrapped session ${sessionId} on reconnect`);
|
|
82
|
+
// Set tab title via kitty (no \x0c redraw — it causes flickering on multi-session reconnect)
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
const sock = findKittySocket();
|
|
85
|
+
const wid = findKittyWindowId(sock, sessionId);
|
|
86
|
+
if (sock && wid) {
|
|
87
|
+
try {
|
|
88
|
+
require('child_process').execSync(`kitty @ --to unix:${sock} set-tab-title --match id:${wid} '⚡ telepty :: ${sessionId}'`, {
|
|
89
|
+
timeout: 2000, stdio: ['pipe', 'pipe', 'pipe']
|
|
90
|
+
});
|
|
91
|
+
} catch {}
|
|
92
|
+
}
|
|
93
|
+
}, 1000);
|
|
94
|
+
} else {
|
|
95
|
+
session.clients.add(ws);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const activeSession = sessions[sessionId];
|
|
99
|
+
|
|
100
|
+
// For wrapped sessions, first connector OR explicit ?owner=1 claim becomes the owner.
|
|
101
|
+
// ?owner=1 reclaim handles the stale-ownerWs bug: allow bridge reconnects but stale TCP
|
|
102
|
+
// half-open connection still holds ownerWs slot → reconnect wrongly becomes a viewer.
|
|
103
|
+
if (activeSession.type === 'wrapped' && (!activeSession.ownerWs || isOwnerConnect)) {
|
|
104
|
+
const hadDisconnectedOwner = !isOpenWebSocket(activeSession.ownerWs) && activeSession.lastDisconnectedAt;
|
|
105
|
+
if (isOwnerConnect && activeSession.ownerWs && activeSession.ownerWs !== ws) {
|
|
106
|
+
// Terminate the stale owner connection before claiming ownership
|
|
107
|
+
console.log(`[WS] Replacing stale ownerWs for session ${sessionId}`);
|
|
108
|
+
activeSession.ownerWs.terminate();
|
|
109
|
+
}
|
|
110
|
+
activeSession.ownerWs = ws;
|
|
111
|
+
// BUG-C: mint a fresh per-owner token on every claim/reclaim and push it to this owner.
|
|
112
|
+
// The token is the exact "are-you-the-current-owner" discriminator the DELETE guard uses
|
|
113
|
+
// to suppress a stale/displaced owner's teardown (shared-fate fix). Reclaim refreshes it,
|
|
114
|
+
// so the live current owner always holds the current token while a displaced owner keeps a
|
|
115
|
+
// stale one.
|
|
116
|
+
activeSession.ownerToken = crypto.randomUUID();
|
|
117
|
+
try { ws.send(JSON.stringify({ type: 'owner_token', token: activeSession.ownerToken })); } catch {}
|
|
118
|
+
markSessionConnected(activeSession);
|
|
119
|
+
initializeBootstrapState(activeSession);
|
|
120
|
+
console.log(`[WS] Wrap owner ${isOwnerConnect && activeSession.clients.size > 1 ? 're-' : ''}connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
121
|
+
scheduleBootstrapPromptPoll(sessionId, activeSession);
|
|
122
|
+
if (hadDisconnectedOwner) {
|
|
123
|
+
emitSessionLifecycleEvent('session_reconnect', sessionId, activeSession);
|
|
124
|
+
}
|
|
125
|
+
persistSessions();
|
|
126
|
+
} else {
|
|
127
|
+
console.log(`[WS] Client attached to session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
ws.on('message', (message) => {
|
|
131
|
+
try {
|
|
132
|
+
const { type, data, cols, rows } = JSON.parse(message);
|
|
133
|
+
|
|
134
|
+
if (activeSession.type === 'wrapped') {
|
|
135
|
+
if (ws === activeSession.ownerWs) {
|
|
136
|
+
// Owner sending output -> broadcast to other clients + update activity
|
|
137
|
+
if (type === 'output') {
|
|
138
|
+
activeSession.lastActivityAt = new Date().toISOString();
|
|
139
|
+
appendToOutputRing(activeSession, data);
|
|
140
|
+
sessionStateManager.feed(sessionId, data);
|
|
141
|
+
activeSession.clients.forEach(client => {
|
|
142
|
+
if (client !== ws && client.readyState === 1) {
|
|
143
|
+
client.send(JSON.stringify({ type: 'output', data }));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
} else if (type === 'ready') {
|
|
147
|
+
if (isBootstrapGatedSession(activeSession)) {
|
|
148
|
+
markBootstrapReady(sessionId, activeSession, 'bridge_ready');
|
|
149
|
+
} else {
|
|
150
|
+
activeSession.ready = true;
|
|
151
|
+
}
|
|
152
|
+
activeSession.lastActivityAt = new Date().toISOString();
|
|
153
|
+
console.log(`[READY] Session ${sessionId} CLI is ready for inject`);
|
|
154
|
+
// Broadcast readiness to bus (cmux/kitty paths now enabled for this session)
|
|
155
|
+
const readyMsg = JSON.stringify({
|
|
156
|
+
type: 'session_ready',
|
|
157
|
+
session_id: sessionId,
|
|
158
|
+
timestamp: new Date().toISOString()
|
|
159
|
+
});
|
|
160
|
+
busClients.forEach(client => {
|
|
161
|
+
if (client.readyState === 1) client.send(readyMsg);
|
|
162
|
+
});
|
|
163
|
+
// Auto-report: notify source that target completed inject task
|
|
164
|
+
// Legacy ready-signal auto-report path. Skip if onTransition already
|
|
165
|
+
// fired (pendingReports[sessionId].idleNotified === true).
|
|
166
|
+
const pendingReport = pendingReports[sessionId];
|
|
167
|
+
if (pendingReport && !pendingReport.idleNotified) {
|
|
168
|
+
// ready-signal: cli.js bridge emitted a 'ready' WS frame.
|
|
169
|
+
fireAutoReport(sessionId, activeSession, pendingReport, 'ready-signal');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Non-owner client input -> forward to owner as inject
|
|
174
|
+
if (type === 'input' && activeSession.ownerWs && activeSession.ownerWs.readyState === 1) {
|
|
175
|
+
activeSession.ownerWs.send(JSON.stringify({ type: 'inject', data }));
|
|
176
|
+
} else if (type === 'resize' && activeSession.ownerWs && activeSession.ownerWs.readyState === 1) {
|
|
177
|
+
activeSession.ownerWs.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
// Existing spawned session logic
|
|
182
|
+
if (type === 'input') {
|
|
183
|
+
activeSession.ptyProcess.write(data);
|
|
184
|
+
} else if (type === 'resize') {
|
|
185
|
+
activeSession.ptyProcess.resize(cols, rows);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
console.error('[WS] Invalid message format', e);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
ws.on('close', () => {
|
|
194
|
+
clearInterval(pingInterval);
|
|
195
|
+
activeSession.clients.delete(ws);
|
|
196
|
+
if (activeSession.type === 'wrapped' && ws === activeSession.ownerWs) {
|
|
197
|
+
activeSession.ownerWs = null;
|
|
198
|
+
// #29: cancel any pending owner-alive optimistic timer — the owner is gone, so the
|
|
199
|
+
// floor must not flip a disconnected session ready (hygiene; the timer also re-guards
|
|
200
|
+
// on isOpenWebSocket, but clearing avoids a dangling handle).
|
|
201
|
+
if (activeSession.bootstrapOptimisticTimer) {
|
|
202
|
+
clearTimeout(activeSession.bootstrapOptimisticTimer);
|
|
203
|
+
activeSession.bootstrapOptimisticTimer = null;
|
|
204
|
+
}
|
|
205
|
+
markSessionDisconnected(activeSession);
|
|
206
|
+
console.log(`[WS] Wrap owner disconnected from session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
207
|
+
emitSessionLifecycleEvent('session_disconnect', sessionId, activeSession, {
|
|
208
|
+
clients: activeSession.clients.size
|
|
209
|
+
});
|
|
210
|
+
persistSessions();
|
|
211
|
+
} else {
|
|
212
|
+
console.log(`[WS] Client detached from session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const busWss = new WebSocketServer({ noServer: true });
|
|
218
|
+
|
|
219
|
+
busWss.on('connection', (ws, req) => {
|
|
220
|
+
busClients.add(ws);
|
|
221
|
+
console.log('[BUS] New agent connected to event bus');
|
|
222
|
+
|
|
223
|
+
ws.on('message', (message) => {
|
|
224
|
+
try {
|
|
225
|
+
const msg = JSON.parse(message);
|
|
226
|
+
if (msg.type === 'session_state_report') {
|
|
227
|
+
const resolvedId = resolveSessionAlias(msg.session_id || '');
|
|
228
|
+
if (!resolvedId || !sessions[resolvedId]) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const applied = applySessionStateReport(resolvedId, sessions[resolvedId], msg);
|
|
233
|
+
if (!applied.success) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!msg._relayed_from) relayToPeers(applied.event);
|
|
238
|
+
persistSessions();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Broadcast to all other bus clients
|
|
243
|
+
busClients.forEach(client => {
|
|
244
|
+
if (client !== ws && client.readyState === 1) {
|
|
245
|
+
client.send(JSON.stringify(msg));
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Auto-route turn_request events (shared logic with HTTP publish)
|
|
250
|
+
busAutoRoute(msg);
|
|
251
|
+
// Relay to peer daemons (dedup prevents loops)
|
|
252
|
+
if (!msg._relayed_from) relayToPeers(msg);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
console.error('[BUS] Invalid message format', e);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
ws.on('close', () => {
|
|
259
|
+
busClients.delete(ws);
|
|
260
|
+
console.log('[BUS] Agent disconnected from event bus');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (server) server.on('upgrade', (req, socket, head) => {
|
|
265
|
+
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
266
|
+
const token = url.searchParams.get('token');
|
|
267
|
+
|
|
268
|
+
const wsAuthHeader = req.headers['authorization'] || '';
|
|
269
|
+
const wsJwtValid = wsAuthHeader.startsWith('Bearer ') && verifyJwt(wsAuthHeader.slice(7));
|
|
270
|
+
if (!isAllowedPeer(req.socket.remoteAddress) && token !== expectedToken && !wsJwtValid) {
|
|
271
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
272
|
+
socket.destroy();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (url.pathname.startsWith('/api/sessions/')) {
|
|
277
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
278
|
+
wss.emit('connection', ws, req);
|
|
279
|
+
});
|
|
280
|
+
} else if (url.pathname === '/api/bus') {
|
|
281
|
+
busWss.handleUpgrade(req, socket, head, (ws) => {
|
|
282
|
+
busWss.emit('connection', ws, req);
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
socket.destroy();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return { wss, busWss };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
module.exports = {
|
|
293
|
+
installWebSocketTransport,
|
|
294
|
+
isOpenWebSocket
|
|
295
|
+
};
|
package/terminal-backend.js
CHANGED
|
@@ -168,6 +168,344 @@ function isSurfaceAlive(session) {
|
|
|
168
168
|
return present ? 'alive' : 'gone';
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
function getExpectedPtyPid(session) {
|
|
172
|
+
if (!session) return null;
|
|
173
|
+
const candidates = [
|
|
174
|
+
session.ptyPid,
|
|
175
|
+
session.pty_pid,
|
|
176
|
+
session.ptyProcess && session.ptyProcess.pid,
|
|
177
|
+
session.pid
|
|
178
|
+
];
|
|
179
|
+
for (const pid of candidates) {
|
|
180
|
+
const n = Number(pid);
|
|
181
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isPtyPidAlive(pid, options = {}) {
|
|
187
|
+
const processKill = options.processKill || process.kill;
|
|
188
|
+
try {
|
|
189
|
+
processKill(pid, 0);
|
|
190
|
+
return true;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return !!(err && err.code === 'EPERM');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeTty(value) {
|
|
197
|
+
if (typeof value !== 'string') return null;
|
|
198
|
+
const tty = value.trim().replace(/^\/dev\//, '');
|
|
199
|
+
if (!tty || tty === '?' || tty === '??') return null;
|
|
200
|
+
return tty.toLowerCase();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readProcessTty(pid, options = {}) {
|
|
204
|
+
if (typeof options.readProcessTty === 'function') {
|
|
205
|
+
return normalizeTty(options.readProcessTty(pid));
|
|
206
|
+
}
|
|
207
|
+
const execFile = options.execFileSync || execFileSync;
|
|
208
|
+
try {
|
|
209
|
+
return normalizeTty(execFile('ps', ['-o', 'tty=', '-p', String(pid)], {
|
|
210
|
+
timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
|
211
|
+
}));
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function asString(value) {
|
|
218
|
+
if (typeof value === 'string') return value;
|
|
219
|
+
if (typeof value === 'number') return String(value);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getObjectString(obj, keys) {
|
|
224
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
225
|
+
for (const key of keys) {
|
|
226
|
+
const value = asString(obj[key]);
|
|
227
|
+
if (value) return value;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function lower(value) {
|
|
233
|
+
return String(value || '').toLowerCase();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function stringMatchesRef(value, ref) {
|
|
237
|
+
if (!value || !ref) return false;
|
|
238
|
+
return lower(value) === lower(ref);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function collectObjects(value, out = []) {
|
|
242
|
+
if (!value || typeof value !== 'object') return out;
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
for (const item of value) collectObjects(item, out);
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
out.push(value);
|
|
248
|
+
for (const child of Object.values(value)) collectObjects(child, out);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const WORKSPACE_REF_KEYS = [
|
|
253
|
+
'workspaceId', 'workspace_id', 'workspaceRef', 'workspace_ref',
|
|
254
|
+
'id', 'ref', 'uuid'
|
|
255
|
+
];
|
|
256
|
+
const SURFACE_REF_KEYS = [
|
|
257
|
+
'surfaceId', 'surface_id', 'surfaceRef', 'surface_ref',
|
|
258
|
+
'terminalSurfaceId', 'terminal_surface_id',
|
|
259
|
+
'terminalSurfaceRef', 'terminal_surface_ref',
|
|
260
|
+
'id', 'ref', 'uuid'
|
|
261
|
+
];
|
|
262
|
+
const TITLE_KEYS = ['title', 'name', 'label', 'tabTitle', 'tab_title'];
|
|
263
|
+
|
|
264
|
+
function objectType(obj) {
|
|
265
|
+
return lower(getObjectString(obj, ['type', 'kind', 'nodeType', 'node_type']));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getWorkspaceRef(obj) {
|
|
269
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
270
|
+
for (const key of WORKSPACE_REF_KEYS) {
|
|
271
|
+
const value = asString(obj[key]);
|
|
272
|
+
if (!value) continue;
|
|
273
|
+
if (key.toLowerCase().includes('workspace') || lower(value).startsWith('workspace:') || objectType(obj).includes('workspace')) {
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getSurfaceRef(obj) {
|
|
281
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
282
|
+
for (const key of SURFACE_REF_KEYS) {
|
|
283
|
+
const value = asString(obj[key]);
|
|
284
|
+
if (!value) continue;
|
|
285
|
+
if (key.toLowerCase().includes('surface') || lower(value).startsWith('surface:') || objectType(obj).includes('surface')) {
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function getTitle(obj) {
|
|
293
|
+
return getObjectString(obj, TITLE_KEYS);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function titleHasSessionMarker(title, sessionId) {
|
|
297
|
+
if (!title || !sessionId) return false;
|
|
298
|
+
const marker = `telepty :: ${sessionId}`;
|
|
299
|
+
return title === sessionId || title.includes(marker);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isWorkspaceCandidate(obj) {
|
|
303
|
+
return objectType(obj).includes('workspace') || lower(getWorkspaceRef(obj)).startsWith('workspace:');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isSurfaceCandidate(obj) {
|
|
307
|
+
return objectType(obj).includes('surface') || lower(getSurfaceRef(obj)).startsWith('surface:');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function workspaceMatches(obj, session, sessionId) {
|
|
311
|
+
const wid = session.cmuxWorkspaceId;
|
|
312
|
+
if (wid) {
|
|
313
|
+
for (const key of WORKSPACE_REF_KEYS) {
|
|
314
|
+
if (stringMatchesRef(asString(obj[key]), wid)) return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const title = getTitle(obj);
|
|
318
|
+
return title === sessionId || title === `telepty :: ${sessionId}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function findWorkspace(tree, session, sessionId) {
|
|
322
|
+
const matches = collectObjects(tree).filter((obj) => workspaceMatches(obj, session, sessionId));
|
|
323
|
+
if (matches.length === 0) return null;
|
|
324
|
+
return matches.find(isWorkspaceCandidate) || matches[0];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isSelectedSurface(obj, selectedRef) {
|
|
328
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
329
|
+
if (selectedRef && stringMatchesRef(getSurfaceRef(obj), selectedRef)) return true;
|
|
330
|
+
if (obj.selected === true || obj.active === true || obj.focused === true || obj.isSelected === true) return true;
|
|
331
|
+
const status = lower(getObjectString(obj, ['status', 'state']));
|
|
332
|
+
if (status.includes('selected') || status.includes('active') || status.includes('focused')) return true;
|
|
333
|
+
const title = getTitle(obj);
|
|
334
|
+
return !!(title && title.includes('[selected]'));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getSelectedSurfaceRef(workspace) {
|
|
338
|
+
if (!workspace || typeof workspace !== 'object') return null;
|
|
339
|
+
const selectedKeys = [
|
|
340
|
+
'selectedSurface', 'selected_surface',
|
|
341
|
+
'selectedSurfaceRef', 'selected_surface_ref',
|
|
342
|
+
'activeSurface', 'active_surface',
|
|
343
|
+
'activeSurfaceRef', 'active_surface_ref',
|
|
344
|
+
'focusedSurface', 'focused_surface',
|
|
345
|
+
'focusedSurfaceRef', 'focused_surface_ref',
|
|
346
|
+
'terminalSurface', 'terminal_surface'
|
|
347
|
+
];
|
|
348
|
+
for (const key of selectedKeys) {
|
|
349
|
+
const value = workspace[key];
|
|
350
|
+
if (value && typeof value === 'object') return getSurfaceRef(value);
|
|
351
|
+
const stringValue = asString(value);
|
|
352
|
+
if (stringValue) return stringValue;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function getSelectedSurfaceObject(workspace) {
|
|
358
|
+
if (!workspace || typeof workspace !== 'object') return null;
|
|
359
|
+
const selectedKeys = [
|
|
360
|
+
'selectedSurface', 'selected_surface',
|
|
361
|
+
'activeSurface', 'active_surface',
|
|
362
|
+
'focusedSurface', 'focused_surface',
|
|
363
|
+
'terminalSurface', 'terminal_surface'
|
|
364
|
+
];
|
|
365
|
+
for (const key of selectedKeys) {
|
|
366
|
+
const value = workspace[key];
|
|
367
|
+
if (value && typeof value === 'object') return value;
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function findSelectedSurface(workspace) {
|
|
373
|
+
if (!workspace || typeof workspace !== 'object') return null;
|
|
374
|
+
const direct = getSelectedSurfaceObject(workspace);
|
|
375
|
+
if (direct) return direct;
|
|
376
|
+
if (isSurfaceCandidate(workspace) && isSelectedSurface(workspace)) return workspace;
|
|
377
|
+
const selectedRef = getSelectedSurfaceRef(workspace);
|
|
378
|
+
const surfaces = collectObjects(workspace).filter(isSurfaceCandidate);
|
|
379
|
+
const selected = surfaces.find((surface) => isSelectedSurface(surface, selectedRef));
|
|
380
|
+
if (selected) return selected;
|
|
381
|
+
if (selectedRef) return { ref: selectedRef };
|
|
382
|
+
return surfaces.length === 1 ? surfaces[0] : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function findTtyValue(value) {
|
|
386
|
+
if (!value || typeof value !== 'object') return null;
|
|
387
|
+
if (Array.isArray(value)) {
|
|
388
|
+
for (const item of value) {
|
|
389
|
+
const tty = findTtyValue(item);
|
|
390
|
+
if (tty) return tty;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
for (const [key, child] of Object.entries(value)) {
|
|
395
|
+
if (key.toLowerCase().includes('tty')) {
|
|
396
|
+
const tty = normalizeTty(asString(child));
|
|
397
|
+
if (tty) return tty;
|
|
398
|
+
}
|
|
399
|
+
if (child && typeof child === 'object') {
|
|
400
|
+
const nested = findTtyValue(child);
|
|
401
|
+
if (nested) return nested;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function formatObservedSurface(surface) {
|
|
408
|
+
const ref = getSurfaceRef(surface);
|
|
409
|
+
const title = getTitle(surface);
|
|
410
|
+
const tty = findTtyValue(surface);
|
|
411
|
+
const parts = [];
|
|
412
|
+
if (ref) parts.push(ref);
|
|
413
|
+
if (title) {
|
|
414
|
+
const compact = title.length > 80 ? `${title.slice(0, 77)}...` : title;
|
|
415
|
+
parts.push(`"${compact}"`);
|
|
416
|
+
}
|
|
417
|
+
if (tty) parts.push(`tty=${tty}`);
|
|
418
|
+
return parts.length > 0 ? parts.join(' ') : null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function readCmuxTree(options = {}) {
|
|
422
|
+
const execFile = options.execFileSync || execFileSync;
|
|
423
|
+
try {
|
|
424
|
+
execFile('cmux', ['ping'], { timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
425
|
+
} catch {
|
|
426
|
+
return { ok: false, reason: 'cmux_unreachable' };
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const raw = execFile('cmux', ['tree', '--json', '--all'], {
|
|
430
|
+
timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
|
431
|
+
});
|
|
432
|
+
return { ok: true, tree: JSON.parse(raw) };
|
|
433
|
+
} catch {
|
|
434
|
+
return { ok: false, reason: 'cmux_tree_unavailable' };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// cmux currently does not expose a direct "foreground PTY for selected surface" contract.
|
|
439
|
+
// Prefer any tty surfaced by metadata, and only fall back to the telepty title/ref marker
|
|
440
|
+
// proxy. Unknown observations stay unknown so INV-17 never emits on indeterminate state.
|
|
441
|
+
function detectSurfaceMismatch(session, options = {}) {
|
|
442
|
+
const sessionId = options.sessionId || session?.id;
|
|
443
|
+
if (!session || session.type !== 'wrapped' || session.backend !== 'cmux') {
|
|
444
|
+
return { status: 'unknown', reason: 'not_wrapped_cmux' };
|
|
445
|
+
}
|
|
446
|
+
if (!isCmuxRef(session.cmuxWorkspaceId)) {
|
|
447
|
+
return { status: 'unknown', reason: 'missing_workspace' };
|
|
448
|
+
}
|
|
449
|
+
const expectedPtyPid = getExpectedPtyPid(session);
|
|
450
|
+
if (!expectedPtyPid) {
|
|
451
|
+
return { status: 'unknown', reason: 'missing_expected_pty', expectedPtyPid: null };
|
|
452
|
+
}
|
|
453
|
+
if (!isPtyPidAlive(expectedPtyPid, options)) {
|
|
454
|
+
return { status: 'unknown', reason: 'expected_pty_dead', expectedPtyPid };
|
|
455
|
+
}
|
|
456
|
+
const expectedTty = readProcessTty(expectedPtyPid, options);
|
|
457
|
+
if (!expectedTty) {
|
|
458
|
+
return { status: 'unknown', reason: 'expected_tty_unknown', expectedPtyPid };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const treeResult = readCmuxTree(options);
|
|
462
|
+
if (!treeResult.ok) {
|
|
463
|
+
return { status: 'unknown', reason: treeResult.reason, expectedPtyPid, expectedTty };
|
|
464
|
+
}
|
|
465
|
+
const workspace = findWorkspace(treeResult.tree, session, sessionId);
|
|
466
|
+
if (!workspace) {
|
|
467
|
+
return { status: 'unknown', reason: 'workspace_not_found', expectedPtyPid, expectedTty };
|
|
468
|
+
}
|
|
469
|
+
const surface = findSelectedSurface(workspace);
|
|
470
|
+
if (!surface) {
|
|
471
|
+
return { status: 'unknown', reason: 'selected_surface_not_found', expectedPtyPid, expectedTty };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const observedSurface = formatObservedSurface(surface);
|
|
475
|
+
if (!observedSurface) {
|
|
476
|
+
return { status: 'unknown', reason: 'observed_surface_unknown', expectedPtyPid, expectedTty };
|
|
477
|
+
}
|
|
478
|
+
const observedSurfaceRef = getSurfaceRef(surface);
|
|
479
|
+
const observedSurfaceTitle = getTitle(surface);
|
|
480
|
+
const observedSurfaceTty = findTtyValue(surface);
|
|
481
|
+
const common = {
|
|
482
|
+
expectedPtyPid,
|
|
483
|
+
expectedTty,
|
|
484
|
+
observedSurface,
|
|
485
|
+
observedSurfaceRef: observedSurfaceRef || null,
|
|
486
|
+
observedSurfaceTitle: observedSurfaceTitle || null,
|
|
487
|
+
observedSurfaceTty: observedSurfaceTty || null
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
if (observedSurfaceTty) {
|
|
491
|
+
if (observedSurfaceTty === expectedTty) {
|
|
492
|
+
return { status: 'match', reason: 'tty_match', method: 'tty', ...common };
|
|
493
|
+
}
|
|
494
|
+
return { status: 'mismatch', reason: 'tty_mismatch', method: 'tty', ...common };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (session.cmuxSurfaceId && observedSurfaceRef && !stringMatchesRef(observedSurfaceRef, session.cmuxSurfaceId)) {
|
|
498
|
+
return { status: 'mismatch', reason: 'surface_ref_mismatch', method: 'title', ...common };
|
|
499
|
+
}
|
|
500
|
+
if (observedSurfaceTitle && !titleHasSessionMarker(observedSurfaceTitle, sessionId)) {
|
|
501
|
+
return { status: 'mismatch', reason: 'title_marker_missing', method: 'title', ...common };
|
|
502
|
+
}
|
|
503
|
+
if (!observedSurfaceTitle && !observedSurfaceRef) {
|
|
504
|
+
return { status: 'unknown', reason: 'title_fallback_unavailable', ...common };
|
|
505
|
+
}
|
|
506
|
+
return { status: 'match', reason: 'title_marker_match', method: 'title', ...common };
|
|
507
|
+
}
|
|
508
|
+
|
|
171
509
|
// Terminal-surface CLOSE is owned by the orchestrator's Workspace Host adapter
|
|
172
510
|
// (workspace-host.sh `wh_close`), per the 2026-05-30 surface-ownership verdict — telepty
|
|
173
511
|
// probes liveness and emits `surface_orphaned`, it does not actuate surface close on the
|
|
@@ -201,5 +539,6 @@ module.exports = {
|
|
|
201
539
|
invalidateCache,
|
|
202
540
|
clearCache,
|
|
203
541
|
isSurfaceAlive,
|
|
542
|
+
detectSurfaceMismatch,
|
|
204
543
|
closeSurface
|
|
205
544
|
};
|