@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
package/daemon.js
CHANGED
|
@@ -3,12 +3,14 @@ const cors = require('cors');
|
|
|
3
3
|
const pty = require('node-pty');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
|
-
const { WebSocketServer } = require('ws');
|
|
7
6
|
const { getConfig } = require('./auth');
|
|
8
7
|
const pkg = require('./package.json');
|
|
9
8
|
const { claimDaemonState, clearDaemonState, isProcessRunning } = require('./daemon-control');
|
|
10
9
|
const { checkEntitlement } = require('./entitlement');
|
|
11
10
|
const terminalBackend = require('./terminal-backend');
|
|
11
|
+
const { installWebSocketTransport, isOpenWebSocket } = require('./src/transport/websocket');
|
|
12
|
+
const { createPeerRelay, relayPeersFromEnv } = require('./src/transport/peer-relay');
|
|
13
|
+
const { createAuthMiddleware, createIsAllowedPeer, createVerifyJwt } = require('./src/protocol/http-auth');
|
|
12
14
|
const { FileMailbox } = require('./src/mailbox/index');
|
|
13
15
|
const { DeliveryEngine } = require('./src/mailbox/delivery');
|
|
14
16
|
const { UnixSocketNotifier } = require('./src/mailbox/notifier');
|
|
@@ -17,33 +19,18 @@ const { classifyReportPrompt, buildAutoSummary } = require('./src/report-enforce
|
|
|
17
19
|
const submitGate = require('./src/submit-gate');
|
|
18
20
|
const readyRegistry = require('./src/prompt-symbol-registry');
|
|
19
21
|
const lifecycle = require('./src/lifecycle');
|
|
22
|
+
const { SURFACE_ORPHAN_SECONDS, SURFACE_MISMATCH_SECONDS, decideSurfaceGc, applySurfaceMismatchProbe } = lifecycle;
|
|
20
23
|
const { loadTeleptyConfig } = require('./src/config-file');
|
|
24
|
+
const sessionPersistence = require('./src/session-store/persistence');
|
|
21
25
|
|
|
22
26
|
const config = getConfig();
|
|
23
27
|
const EXPECTED_TOKEN = config.authToken;
|
|
24
28
|
const MACHINE_ID = process.env.TELEPTY_MACHINE_ID || os.hostname();
|
|
25
29
|
const net = require('net');
|
|
26
30
|
const fs = require('fs');
|
|
27
|
-
const SESSION_PERSIST_PATH =
|
|
31
|
+
const SESSION_PERSIST_PATH = sessionPersistence.defaultSessionPersistPath();
|
|
28
32
|
const SESSION_STALE_SECONDS = Math.max(1, Number(process.env.TELEPTY_SESSION_STALE_SECONDS || 60));
|
|
29
33
|
const SESSION_CLEANUP_SECONDS = Math.max(SESSION_STALE_SECONDS, Number(process.env.TELEPTY_SESSION_CLEANUP_SECONDS || 300));
|
|
30
|
-
// #17: grace window before a cmux session whose workspace was explicitly closed (bridge
|
|
31
|
-
// survived → headless zombie) is reclaimed. Shorter than the 300s disconnect-GC: the surface
|
|
32
|
-
// is confirmed gone (not merely disconnected). The window absorbs cmux transient hiccups.
|
|
33
|
-
const SURFACE_ORPHAN_SECONDS = Math.max(5, Number(process.env.TELEPTY_SURFACE_ORPHAN_SECONDS || 30));
|
|
34
|
-
// #17: pure verdict→action mapping for the surface-liveness GC, exposed for unit-testing.
|
|
35
|
-
// Returns 'mark' (start the grace window), 'reclaim' (grace elapsed → teardown), 'recover'
|
|
36
|
-
// (surface returned within grace → clear), or 'skip'. INV-17: 'unknown' (cmux unreachable)
|
|
37
|
-
// always maps to 'skip' — GC nothing. Pure, no side effects; the caller performs the action.
|
|
38
|
-
function decideSurfaceGc(liveness, session, nowMs, graceSeconds = SURFACE_ORPHAN_SECONDS) {
|
|
39
|
-
if (liveness === 'gone') {
|
|
40
|
-
if (!session.surfaceGoneAt) return 'mark';
|
|
41
|
-
const goneSeconds = Math.floor((nowMs - new Date(session.surfaceGoneAt).getTime()) / 1000);
|
|
42
|
-
return goneSeconds >= graceSeconds ? 'reclaim' : 'skip';
|
|
43
|
-
}
|
|
44
|
-
if (liveness === 'alive' && session.surfaceGoneAt) return 'recover';
|
|
45
|
-
return 'skip';
|
|
46
|
-
}
|
|
47
34
|
const DELIVERY_TIMEOUT_MS = Math.max(100, Number(process.env.TELEPTY_DELIVERY_TIMEOUT_MS || 5000));
|
|
48
35
|
const HEALTH_POLL_MS = Math.max(100, Number(process.env.TELEPTY_HEALTH_POLL_MS || 10000));
|
|
49
36
|
const IDLE_REAPER_POLL_MS = Math.max(100, Number(process.env.TELEPTY_IDLE_REAPER_POLL_MS || 60000));
|
|
@@ -84,6 +71,15 @@ sessionStateManager.onTransition((sessionId, from, to, detail) => {
|
|
|
84
71
|
extra: { auto_state: to, auto_state_from: from, auto_detail: detail }
|
|
85
72
|
});
|
|
86
73
|
|
|
74
|
+
const transitionPendingReport = getPendingReport(sessionId);
|
|
75
|
+
if ((to === 'working' || to === 'thinking') && transitionPendingReport) {
|
|
76
|
+
const pendingReport = transitionPendingReport;
|
|
77
|
+
if (!pendingReport.submitExpected || pendingReport.submitStartedAt) {
|
|
78
|
+
pendingReport.sawWorkingAfterInject = true;
|
|
79
|
+
pendingReport.workingAfterInjectAt = new Date().toISOString();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
87
83
|
// Fire TASK_IDLE_NO_REPORT on idle transition (for sessions with pendingReports).
|
|
88
84
|
// Session still needs to self-inject a content REPORT — this event only observes.
|
|
89
85
|
// Legacy TASK_COMPLETE text-inject is also fired for back-compat (0.2.x grandfather).
|
|
@@ -119,43 +115,11 @@ sessionStateManager.onTransition((sessionId, from, to, detail) => {
|
|
|
119
115
|
});
|
|
120
116
|
|
|
121
117
|
function persistSessions() {
|
|
122
|
-
|
|
123
|
-
const data = {};
|
|
124
|
-
for (const [id, s] of Object.entries(sessions)) {
|
|
125
|
-
data[id] = {
|
|
126
|
-
id,
|
|
127
|
-
type: s.type,
|
|
128
|
-
command: s.command,
|
|
129
|
-
cwd: s.cwd,
|
|
130
|
-
backend: s.backend || null,
|
|
131
|
-
cmuxWorkspaceId: s.cmuxWorkspaceId || null,
|
|
132
|
-
cmuxSurfaceId: s.cmuxSurfaceId || null,
|
|
133
|
-
termProgram: s.termProgram || null,
|
|
134
|
-
term: s.term || null,
|
|
135
|
-
delivery: s.delivery || null,
|
|
136
|
-
deliveryEndpoint: s.deliveryEndpoint || null,
|
|
137
|
-
createdAt: s.createdAt,
|
|
138
|
-
lastActivityAt: s.lastActivityAt || null,
|
|
139
|
-
lastConnectedAt: s.lastConnectedAt || null,
|
|
140
|
-
lastDisconnectedAt: s.lastDisconnectedAt || null,
|
|
141
|
-
lastStateReportAt: s.lastStateReportAt || null,
|
|
142
|
-
stateReport: s.stateReport || null,
|
|
143
|
-
idleTtl: s.idleTtl || null,
|
|
144
|
-
idleTtlMs: s.idleTtlMs == null ? null : s.idleTtlMs,
|
|
145
|
-
ownerPid: s.ownerPid || null,
|
|
146
|
-
ptyPid: s.ptyPid || null
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
fs.mkdirSync(require('path').dirname(SESSION_PERSIST_PATH), { recursive: true });
|
|
150
|
-
fs.writeFileSync(SESSION_PERSIST_PATH, JSON.stringify(data, null, 2));
|
|
151
|
-
} catch {}
|
|
118
|
+
sessionPersistence.savePersistedSessions(sessions, SESSION_PERSIST_PATH);
|
|
152
119
|
}
|
|
153
120
|
|
|
154
121
|
function loadPersistedSessions() {
|
|
155
|
-
|
|
156
|
-
if (!fs.existsSync(SESSION_PERSIST_PATH)) return {};
|
|
157
|
-
return JSON.parse(fs.readFileSync(SESSION_PERSIST_PATH, 'utf8'));
|
|
158
|
-
} catch { return {}; }
|
|
122
|
+
return sessionPersistence.loadPersistedSessions(SESSION_PERSIST_PATH);
|
|
159
123
|
}
|
|
160
124
|
|
|
161
125
|
const app = express();
|
|
@@ -166,63 +130,19 @@ app.use(express.json());
|
|
|
166
130
|
const PEER_ALLOWLIST = (process.env.TELEPTY_PEER_ALLOWLIST || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
167
131
|
|
|
168
132
|
// Cross-machine bus relay: forward bus events to peer daemons
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
RELAY_SEEN.add(msg.message_id);
|
|
177
|
-
// Prevent unbounded growth
|
|
178
|
-
if (RELAY_SEEN.size > 10000) {
|
|
179
|
-
const arr = [...RELAY_SEEN];
|
|
180
|
-
arr.splice(0, 5000);
|
|
181
|
-
RELAY_SEEN.clear();
|
|
182
|
-
arr.forEach(id => RELAY_SEEN.add(id));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
msg.source_host = msg.source_host || MACHINE_ID;
|
|
186
|
-
msg._relayed_from = MACHINE_ID;
|
|
187
|
-
|
|
188
|
-
for (const peer of RELAY_PEERS) {
|
|
189
|
-
fetch(`http://${peer}:${PORT}/api/bus/publish`, {
|
|
190
|
-
method: 'POST',
|
|
191
|
-
headers: { 'Content-Type': 'application/json', 'x-telepty-token': EXPECTED_TOKEN },
|
|
192
|
-
body: JSON.stringify(msg),
|
|
193
|
-
signal: AbortSignal.timeout(3000)
|
|
194
|
-
}).catch(() => {}); // fire-and-forget
|
|
195
|
-
}
|
|
196
|
-
}
|
|
133
|
+
const relayToPeers = createPeerRelay({
|
|
134
|
+
relayPeers: relayPeersFromEnv(process.env),
|
|
135
|
+
relaySeen: new Set(), // dedup by message_id
|
|
136
|
+
machineId: MACHINE_ID,
|
|
137
|
+
expectedToken: EXPECTED_TOKEN,
|
|
138
|
+
getPort: () => PORT
|
|
139
|
+
});
|
|
197
140
|
|
|
198
141
|
// JWT auth: set TELEPTY_JWT_SECRET to enable. Tokens in Authorization: Bearer <token>
|
|
199
142
|
const JWT_SECRET = process.env.TELEPTY_JWT_SECRET || null;
|
|
200
143
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
// Simple HS256 JWT verification (no external deps)
|
|
205
|
-
const [headerB64, payloadB64, sigB64] = token.split('.');
|
|
206
|
-
if (!headerB64 || !payloadB64 || !sigB64) return false;
|
|
207
|
-
const expected = crypto.createHmac('sha256', JWT_SECRET)
|
|
208
|
-
.update(`${headerB64}.${payloadB64}`).digest('base64url');
|
|
209
|
-
if (sigB64 !== expected) return false;
|
|
210
|
-
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
211
|
-
if (payload.exp && Date.now() / 1000 > payload.exp) return false;
|
|
212
|
-
return payload;
|
|
213
|
-
} catch { return false; }
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function isAllowedPeer(ip) {
|
|
217
|
-
if (!ip) return false;
|
|
218
|
-
const cleanIp = ip.replace('::ffff:', '');
|
|
219
|
-
// Localhost always allowed (includes SSH tunnel traffic)
|
|
220
|
-
if (cleanIp === '127.0.0.1' || ip === '::1') return true;
|
|
221
|
-
// Peer allowlist
|
|
222
|
-
if (PEER_ALLOWLIST.length > 0) return PEER_ALLOWLIST.includes(cleanIp);
|
|
223
|
-
// No allowlist = allow all authenticated
|
|
224
|
-
return true;
|
|
225
|
-
}
|
|
144
|
+
const verifyJwt = createVerifyJwt(JWT_SECRET);
|
|
145
|
+
const isAllowedPeer = createIsAllowedPeer(PEER_ALLOWLIST);
|
|
226
146
|
|
|
227
147
|
// Health check – no auth required
|
|
228
148
|
app.get('/api/health', (req, res) => {
|
|
@@ -230,27 +150,7 @@ app.get('/api/health', (req, res) => {
|
|
|
230
150
|
});
|
|
231
151
|
|
|
232
152
|
// Authentication Middleware
|
|
233
|
-
app.use((
|
|
234
|
-
const clientIp = req.ip;
|
|
235
|
-
|
|
236
|
-
if (isAllowedPeer(clientIp)) {
|
|
237
|
-
return next(); // Trust local and allowlisted peers (SSH tunnels arrive as localhost)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const token = req.headers['x-telepty-token'] || req.query.token;
|
|
241
|
-
if (token === EXPECTED_TOKEN) {
|
|
242
|
-
return next();
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// JWT Bearer token
|
|
246
|
-
const authHeader = req.headers['authorization'] || '';
|
|
247
|
-
if (authHeader.startsWith('Bearer ') && verifyJwt(authHeader.slice(7))) {
|
|
248
|
-
return next();
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
console.warn(`[AUTH] Rejected unauthorized request from ${clientIp}`);
|
|
252
|
-
res.status(401).json({ error: 'Unauthorized: Invalid or missing token.', code: 'PERMISSION_DENIED' });
|
|
253
|
-
});
|
|
153
|
+
app.use(createAuthMiddleware({ isAllowedPeer, expectedToken: EXPECTED_TOKEN, verifyJwt }));
|
|
254
154
|
|
|
255
155
|
const PORT = process.env.PORT || 3848;
|
|
256
156
|
|
|
@@ -268,7 +168,7 @@ if (require.main === module) {
|
|
|
268
168
|
}
|
|
269
169
|
}
|
|
270
170
|
|
|
271
|
-
const pendingReports =
|
|
171
|
+
const pendingReports = Object.create(null); // {targetSessionId: {source, injectedAt, injectId}}
|
|
272
172
|
const AUTO_REPORT_IDLE_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_IDLE_SECONDS) || 10;
|
|
273
173
|
// #32: a legacy auto-report can fire ~0.0s after the inject (silence-timeout / ready-signal)
|
|
274
174
|
// even when the inject never reached the target TUI — indistinguishable from a real completion
|
|
@@ -276,6 +176,62 @@ const AUTO_REPORT_IDLE_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_IDLE_SEC
|
|
|
276
176
|
// completion; the text-inject is relabeled so a stuck/hung target is never reported as DONE.
|
|
277
177
|
const AUTO_REPORT_MIN_REAL_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_MIN_REAL_SECONDS) || 1.0;
|
|
278
178
|
|
|
179
|
+
function pendingReportHasSubmitEvidence(pendingReport) {
|
|
180
|
+
return !!(pendingReport && (
|
|
181
|
+
pendingReport.submitConfirmedAt ||
|
|
182
|
+
pendingReport.sawWorkingAfterInject ||
|
|
183
|
+
(pendingReport.submitConfirm && pendingReport.submitConfirm.accepted === true)
|
|
184
|
+
));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getPendingReport(sessionId, registry = pendingReports) {
|
|
188
|
+
if (typeof sessionId !== 'string') return null;
|
|
189
|
+
if (sessionId === '__proto__' || sessionId === 'prototype' || sessionId === 'constructor') return null;
|
|
190
|
+
if (!registry || !Object.prototype.hasOwnProperty.call(registry, sessionId)) return null;
|
|
191
|
+
return registry[sessionId];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function markPendingReportSubmitStarted(sessionId, bodyText) {
|
|
195
|
+
const pendingReport = getPendingReport(sessionId);
|
|
196
|
+
if (!pendingReport) return;
|
|
197
|
+
pendingReport.submitExpected = true;
|
|
198
|
+
pendingReport.submitInProgress = true;
|
|
199
|
+
pendingReport.submitStartedAt = new Date().toISOString();
|
|
200
|
+
if (typeof bodyText === 'string') {
|
|
201
|
+
pendingReport.injectedBodyPreview = bodyText.slice(0, 500);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function markPendingReportSubmitConfirmed(sessionId, confirm) {
|
|
206
|
+
const pendingReport = getPendingReport(sessionId);
|
|
207
|
+
if (!pendingReport) return;
|
|
208
|
+
pendingReport.submitExpected = true;
|
|
209
|
+
pendingReport.submitInProgress = false;
|
|
210
|
+
pendingReport.submitFinishedAt = new Date().toISOString();
|
|
211
|
+
pendingReport.submitConfirmedAt = pendingReport.submitFinishedAt;
|
|
212
|
+
pendingReport.submitConfirm = {
|
|
213
|
+
accepted: true,
|
|
214
|
+
reason: confirm && confirm.reason ? confirm.reason : 'confirmed',
|
|
215
|
+
attempts: confirm && confirm.attempts ? confirm.attempts : undefined,
|
|
216
|
+
ambiguous: !!(confirm && confirm.ambiguous),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function markPendingReportSubmitUnconfirmed(sessionId, confirm) {
|
|
221
|
+
const pendingReport = getPendingReport(sessionId);
|
|
222
|
+
if (!pendingReport) return;
|
|
223
|
+
pendingReport.submitExpected = true;
|
|
224
|
+
pendingReport.submitInProgress = false;
|
|
225
|
+
pendingReport.submitFinishedAt = new Date().toISOString();
|
|
226
|
+
pendingReport.submitUnconfirmedAt = pendingReport.submitFinishedAt;
|
|
227
|
+
pendingReport.submitConfirm = {
|
|
228
|
+
accepted: false,
|
|
229
|
+
reason: confirm && confirm.reason ? confirm.reason : 'submit_unconfirmed',
|
|
230
|
+
attempts: confirm && confirm.attempts ? confirm.attempts : undefined,
|
|
231
|
+
retryable: !!(confirm && confirm.retryable),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
279
235
|
// #32: single provenance-tagged auto-report path (was 3 byte-identical builders at the
|
|
280
236
|
// onTransition-idle / silence-timeout / ready-signal sites). `trigger` distinguishes the
|
|
281
237
|
// originating path; sub-floor elapsed is relabeled TASK_IDLE_UNCONFIRMED instead of TASK_COMPLETE.
|
|
@@ -285,15 +241,46 @@ const AUTO_REPORT_MIN_REAL_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_MIN_
|
|
|
285
241
|
// for the production callers, which pass no deps.
|
|
286
242
|
function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps = {}) {
|
|
287
243
|
const _now = deps.now || Date.now;
|
|
244
|
+
const _setTimeout = deps.setTimeout || setTimeout;
|
|
288
245
|
const _broadcast = deps.broadcastSessionEvent || broadcastSessionEvent;
|
|
289
246
|
const _resolveAlias = deps.resolveSessionAlias || resolveSessionAlias;
|
|
290
247
|
const _sessions = deps.sessions || sessions;
|
|
248
|
+
const _pendingReports = deps.pendingReports || pendingReports;
|
|
291
249
|
const _deliver = deps.deliverInjectionToSession || deliverInjectionToSession;
|
|
292
250
|
|
|
293
|
-
pendingReport.idleNotified = true;
|
|
294
|
-
pendingReport.idleAt = new Date(_now()).toISOString();
|
|
295
251
|
const elapsedNum = (_now() - new Date(pendingReport.injectedAt).getTime()) / 1000;
|
|
296
252
|
const elapsed = elapsedNum.toFixed(1);
|
|
253
|
+
const hasSubmitEvidence = pendingReportHasSubmitEvidence(pendingReport);
|
|
254
|
+
|
|
255
|
+
if (trigger === 'ready-signal' && pendingReport.submitExpected) {
|
|
256
|
+
if (hasSubmitEvidence) {
|
|
257
|
+
console.log(`[AUTO-REPORT] ${targetId} ready-signal suppressed; submit already confirmed`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const shouldWaitForSubmit = pendingReport.submitInProgress === true || elapsedNum < AUTO_REPORT_MIN_REAL_SECONDS;
|
|
262
|
+
if (shouldWaitForSubmit) {
|
|
263
|
+
if (!pendingReport.readySignalTimer) {
|
|
264
|
+
const floorDelayMs = Math.max(50, Math.ceil((AUTO_REPORT_MIN_REAL_SECONDS - elapsedNum) * 1000));
|
|
265
|
+
const delayMs = pendingReport.submitInProgress === true ? Math.min(250, Math.max(50, floorDelayMs)) : floorDelayMs;
|
|
266
|
+
pendingReport.readySignalTimer = _setTimeout(() => {
|
|
267
|
+
pendingReport.readySignalTimer = null;
|
|
268
|
+
const currentPending = getPendingReport(targetId, _pendingReports);
|
|
269
|
+
if (!currentPending || currentPending.idleNotified) return;
|
|
270
|
+
if (pendingReportHasSubmitEvidence(currentPending)) {
|
|
271
|
+
console.log(`[AUTO-REPORT] ${targetId} ready-signal dwell suppressed; submit confirmed`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
fireAutoReport(targetId, _sessions[targetId] || targetSession, currentPending, 'ready-signal', deps);
|
|
275
|
+
}, delayMs);
|
|
276
|
+
}
|
|
277
|
+
console.log(`[AUTO-REPORT] ${targetId} ready-signal deferred; awaiting submit confirmation`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
pendingReport.idleNotified = true;
|
|
283
|
+
pendingReport.idleAt = new Date(_now()).toISOString();
|
|
297
284
|
|
|
298
285
|
// Richer bus event (observability) — now also carries the trigger provenance.
|
|
299
286
|
_broadcast('TASK_IDLE_NO_REPORT', targetId, targetSession, {
|
|
@@ -311,7 +298,9 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
|
|
|
311
298
|
const srcSession = _sessions[srcId];
|
|
312
299
|
if (!srcSession) return;
|
|
313
300
|
|
|
314
|
-
const confirmed =
|
|
301
|
+
const confirmed = trigger === 'ready-signal' && pendingReport.submitExpected
|
|
302
|
+
? false
|
|
303
|
+
: (elapsedNum >= AUTO_REPORT_MIN_REAL_SECONDS || hasSubmitEvidence);
|
|
315
304
|
const injTag = pendingReport.injectId ? ` inject=${pendingReport.injectId}` : '';
|
|
316
305
|
const reportMsg = confirmed
|
|
317
306
|
? `TASK_COMPLETE: ${targetId} is now idle after processing inject (${elapsed}s, via ${trigger}${injTag})`
|
|
@@ -346,10 +335,6 @@ function respondWithError(res, httpStatus, code, error, extra = {}) {
|
|
|
346
335
|
return res.status(httpStatus).json(buildErrorBody(code, error, extra));
|
|
347
336
|
}
|
|
348
337
|
|
|
349
|
-
function isOpenWebSocket(ws) {
|
|
350
|
-
return Boolean(ws && ws.readyState === 1);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
338
|
function normalizeNullableText(value) {
|
|
354
339
|
if (value === undefined || value === null) {
|
|
355
340
|
return null;
|
|
@@ -592,9 +577,57 @@ async function executeBootstrapInject(sessionId, session, op) {
|
|
|
592
577
|
};
|
|
593
578
|
}
|
|
594
579
|
|
|
580
|
+
function parseSubmitRetryOptions(body = {}, injectedBody = null) {
|
|
581
|
+
const hasExplicitRetries = body && body.retries !== undefined && body.retries !== null;
|
|
582
|
+
const retries = Math.min(Math.max(Number(hasExplicitRetries ? body.retries : (injectedBody ? 1 : 0)) || 0, 0), 3);
|
|
583
|
+
const retryDelayMs = Math.min(Math.max(Number(body?.retry_delay_ms) || 500, 100), 2000);
|
|
584
|
+
const verifyTimeoutMs = Math.min(Math.max(Number(body?.verify_timeout_ms) || 1500, 200), 5000);
|
|
585
|
+
return { retries, retryDelayMs, verifyTimeoutMs };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function buildSubmitVerify(confirm) {
|
|
589
|
+
if (!confirm) return null;
|
|
590
|
+
return {
|
|
591
|
+
consumed: confirm.accepted === true,
|
|
592
|
+
waited_ms: confirm.waited_ms || 0,
|
|
593
|
+
reason: confirm.reason || null,
|
|
594
|
+
source: confirm.visibility && confirm.visibility.source ? confirm.visibility.source : undefined,
|
|
595
|
+
ambiguous: confirm.ambiguous === true || undefined,
|
|
596
|
+
retryable: confirm.retryable === true || undefined,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function buildSubmitConfirmOptions(id, session, submittedAtMs, verifyTimeoutMs) {
|
|
601
|
+
return {
|
|
602
|
+
timeoutMs: verifyTimeoutMs,
|
|
603
|
+
intervalMs: 50,
|
|
604
|
+
submittedAtMs,
|
|
605
|
+
stripAnsi: stripAnsiState,
|
|
606
|
+
getState: () => sessionStateManager.getState(id),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function confirmSubmitAfterDispatch(id, session, injectedBody, submittedAtMs, verifyTimeoutMs) {
|
|
611
|
+
if (!injectedBody || injectedBody.length === 0) {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
return submitGate.confirmSubmitAccepted(session, injectedBody, buildSubmitConfirmOptions(id, session, submittedAtMs, verifyTimeoutMs));
|
|
615
|
+
}
|
|
616
|
+
|
|
595
617
|
async function executeBootstrapSubmit(sessionId, session, op) {
|
|
596
|
-
const
|
|
618
|
+
const body = op.body || {};
|
|
619
|
+
const injectedBody = typeof body.injected_body === 'string' ? body.injected_body : null;
|
|
620
|
+
const { retries, retryDelayMs, verifyTimeoutMs } = parseSubmitRetryOptions(body, injectedBody);
|
|
621
|
+
if (injectedBody) {
|
|
622
|
+
markPendingReportSubmitStarted(sessionId, injectedBody);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const submittedAtMs = Date.now();
|
|
626
|
+
let strategy = terminalLevelSubmit(sessionId, session);
|
|
597
627
|
if (!strategy) {
|
|
628
|
+
if (injectedBody) {
|
|
629
|
+
markPendingReportSubmitUnconfirmed(sessionId, { reason: 'strategy_failed', attempts: 0, retryable: false });
|
|
630
|
+
}
|
|
598
631
|
return {
|
|
599
632
|
status: 503,
|
|
600
633
|
body: {
|
|
@@ -606,14 +639,47 @@ async function executeBootstrapSubmit(sessionId, session, op) {
|
|
|
606
639
|
}
|
|
607
640
|
};
|
|
608
641
|
}
|
|
642
|
+
let attempts = 1;
|
|
643
|
+
let confirm = await confirmSubmitAfterDispatch(sessionId, session, injectedBody, submittedAtMs, verifyTimeoutMs);
|
|
644
|
+
while (confirm && !confirm.accepted && confirm.retryable && attempts <= retries) {
|
|
645
|
+
await sleep(retryDelayMs);
|
|
646
|
+
const retrySubmittedAtMs = Date.now();
|
|
647
|
+
const retryStrategy = terminalLevelSubmit(sessionId, session);
|
|
648
|
+
if (!retryStrategy) break;
|
|
649
|
+
strategy = retryStrategy;
|
|
650
|
+
attempts++;
|
|
651
|
+
confirm = await confirmSubmitAfterDispatch(sessionId, session, injectedBody, retrySubmittedAtMs, verifyTimeoutMs);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (confirm && !confirm.accepted) {
|
|
655
|
+
markPendingReportSubmitUnconfirmed(sessionId, { ...confirm, attempts });
|
|
656
|
+
return {
|
|
657
|
+
status: 504,
|
|
658
|
+
body: {
|
|
659
|
+
error: 'Submit body still visible after bounded confirmation retry',
|
|
660
|
+
reason: 'submit_unconfirmed',
|
|
661
|
+
strategy,
|
|
662
|
+
attempts,
|
|
663
|
+
gated: false,
|
|
664
|
+
verify: buildSubmitVerify(confirm),
|
|
665
|
+
confirm,
|
|
666
|
+
bootstrap_queued: true
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (injectedBody) {
|
|
672
|
+
markPendingReportSubmitConfirmed(sessionId, { ...(confirm || { reason: 'empty_body' }), attempts });
|
|
673
|
+
}
|
|
609
674
|
return {
|
|
610
675
|
status: 200,
|
|
611
676
|
body: {
|
|
612
677
|
success: true,
|
|
613
678
|
strategy,
|
|
614
|
-
attempts
|
|
679
|
+
attempts,
|
|
615
680
|
gated: false,
|
|
616
|
-
verify:
|
|
681
|
+
verify: buildSubmitVerify(confirm),
|
|
682
|
+
confirm,
|
|
617
683
|
bootstrap_queued: true
|
|
618
684
|
}
|
|
619
685
|
};
|
|
@@ -1372,29 +1438,11 @@ console.log(`[DAEMON] Terminal backend: ${DETECTED_TERMINAL}`);
|
|
|
1372
1438
|
// Restore persisted session metadata (wrapped sessions await reconnect)
|
|
1373
1439
|
const _persisted = loadPersistedSessions();
|
|
1374
1440
|
for (const [id, meta] of Object.entries(_persisted)) {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
cmuxWorkspaceId: meta.cmuxWorkspaceId || null,
|
|
1381
|
-
cmuxSurfaceId: meta.cmuxSurfaceId || null,
|
|
1382
|
-
termProgram: meta.termProgram || null,
|
|
1383
|
-
term: meta.term || null,
|
|
1384
|
-
createdAt: meta.createdAt || new Date().toISOString(),
|
|
1385
|
-
lastActivityAt: meta.lastActivityAt || new Date().toISOString(),
|
|
1386
|
-
lastConnectedAt: meta.lastConnectedAt || null,
|
|
1387
|
-
lastDisconnectedAt: meta.lastDisconnectedAt || meta.lastActivityAt || new Date().toISOString(),
|
|
1388
|
-
lastStateReportAt: meta.lastStateReportAt || null,
|
|
1389
|
-
stateReport: meta.stateReport || null,
|
|
1390
|
-
idleTtl: meta.idleTtl || null,
|
|
1391
|
-
idleTtlMs: meta.idleTtlMs == null ? null : meta.idleTtlMs,
|
|
1392
|
-
ownerPid: meta.ownerPid || null,
|
|
1393
|
-
ptyPid: meta.ptyPid || null,
|
|
1394
|
-
clients: new Set(), isClosing: false, outputRing: [], ready: true, };
|
|
1395
|
-
initializeBootstrapState(sessions[id]);
|
|
1396
|
-
console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
|
|
1397
|
-
}
|
|
1441
|
+
const restored = sessionPersistence.buildRestoredWrappedSession(id, meta, { cwd: process.cwd() });
|
|
1442
|
+
if (!restored) continue;
|
|
1443
|
+
sessions[id] = restored;
|
|
1444
|
+
initializeBootstrapState(sessions[id]);
|
|
1445
|
+
console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
|
|
1398
1446
|
}
|
|
1399
1447
|
const STRIPPED_SESSION_ENV_KEYS = [
|
|
1400
1448
|
'CLAUDECODE',
|
|
@@ -2075,15 +2123,13 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2075
2123
|
const session = sessions[resolvedId];
|
|
2076
2124
|
const id = resolvedId;
|
|
2077
2125
|
|
|
2078
|
-
const retries = Math.min(Math.max(Number(req.body?.retries) || 0, 0), 3);
|
|
2079
|
-
const retryDelayMs = Math.min(Math.max(Number(req.body?.retry_delay_ms) || 500, 100), 2000);
|
|
2080
2126
|
const preDelayMs = Math.min(Math.max(Number(req.body?.pre_delay_ms) || 0, 0), 1000);
|
|
2081
2127
|
// Default raised 5000 → 10000 (0.3.1) to cover empirical claude REPL
|
|
2082
2128
|
// ready window (3-6s on fresh spawn) with margin. Upper clamp raised
|
|
2083
2129
|
// 15000 → 30000 for the rare extreme-cold case.
|
|
2084
2130
|
const gateTimeoutMs = Math.min(Math.max(Number(req.body?.gate_timeout_ms) || 10000, 500), 30000);
|
|
2085
|
-
const verifyTimeoutMs = Math.min(Math.max(Number(req.body?.verify_timeout_ms) || 1500, 200), 5000);
|
|
2086
2131
|
const injectedBody = typeof req.body?.injected_body === 'string' ? req.body.injected_body : null;
|
|
2132
|
+
const { retries, retryDelayMs, verifyTimeoutMs } = parseSubmitRetryOptions(req.body || {}, injectedBody);
|
|
2087
2133
|
const minConfidence = req.body?.min_confidence != null
|
|
2088
2134
|
? Math.min(Math.max(Number(req.body.min_confidence), 0), 1)
|
|
2089
2135
|
: undefined;
|
|
@@ -2095,6 +2141,10 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2095
2141
|
|
|
2096
2142
|
console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}${gateOff ? ' [gate=off]' : ''}`);
|
|
2097
2143
|
|
|
2144
|
+
if (injectedBody) {
|
|
2145
|
+
markPendingReportSubmitStarted(id, injectedBody);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2098
2148
|
// #471 (0.4.5): force=true must bypass the bootstrap gate. Without `!force`
|
|
2099
2149
|
// here the per-request escape hatch (cli.js --submit-force) is enqueued and
|
|
2100
2150
|
// 504s before the force-bypass block below ever runs.
|
|
@@ -2107,6 +2157,13 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2107
2157
|
drainBootstrapQueue(id, session);
|
|
2108
2158
|
}
|
|
2109
2159
|
const queuedSubmit = await waitForBootstrapSubmit(op, session, gateTimeoutMs);
|
|
2160
|
+
if (queuedSubmit.status >= 400 && injectedBody) {
|
|
2161
|
+
markPendingReportSubmitUnconfirmed(id, {
|
|
2162
|
+
reason: queuedSubmit.body && queuedSubmit.body.reason ? queuedSubmit.body.reason : 'bootstrap_submit_failed',
|
|
2163
|
+
attempts: queuedSubmit.body && queuedSubmit.body.attempts ? queuedSubmit.body.attempts : 0,
|
|
2164
|
+
retryable: false
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2110
2167
|
return res.status(queuedSubmit.status).json(queuedSubmit.body);
|
|
2111
2168
|
}
|
|
2112
2169
|
|
|
@@ -2128,11 +2185,20 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2128
2185
|
// escape-hatch but at request scope.
|
|
2129
2186
|
// See: docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md §3.1
|
|
2130
2187
|
if (force) {
|
|
2188
|
+
if (injectedBody) {
|
|
2189
|
+
markPendingReportSubmitStarted(id, injectedBody);
|
|
2190
|
+
}
|
|
2131
2191
|
const strategy = terminalLevelSubmit(id, session);
|
|
2132
2192
|
if (strategy) {
|
|
2193
|
+
if (injectedBody) {
|
|
2194
|
+
markPendingReportSubmitConfirmed(id, { reason: 'force', attempts: 1 });
|
|
2195
|
+
}
|
|
2133
2196
|
emitSubmitBus({ strategy, attempts: 1, gated: false, forced: true });
|
|
2134
2197
|
return res.json({ success: true, strategy, attempts: 1, gated: false, forced: true });
|
|
2135
2198
|
}
|
|
2199
|
+
if (injectedBody) {
|
|
2200
|
+
markPendingReportSubmitUnconfirmed(id, { reason: 'strategy_failed', attempts: 0, retryable: false });
|
|
2201
|
+
}
|
|
2136
2202
|
return res.status(503).json({
|
|
2137
2203
|
error: 'Submit failed via all strategies (kitty/cmux/pty)',
|
|
2138
2204
|
strategy: 'none',
|
|
@@ -2144,6 +2210,9 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2144
2210
|
|
|
2145
2211
|
// ── Legacy escape-hatch path: blind pre-delay + retries (0.2.x behavior) ──
|
|
2146
2212
|
if (gateOff) {
|
|
2213
|
+
if (injectedBody) {
|
|
2214
|
+
markPendingReportSubmitStarted(id, injectedBody);
|
|
2215
|
+
}
|
|
2147
2216
|
if (preDelayMs > 0) {
|
|
2148
2217
|
await new Promise(resolve => setTimeout(resolve, preDelayMs));
|
|
2149
2218
|
}
|
|
@@ -2155,9 +2224,15 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2155
2224
|
legacyAttempts++;
|
|
2156
2225
|
}
|
|
2157
2226
|
if (legacyStrategy) {
|
|
2227
|
+
if (injectedBody) {
|
|
2228
|
+
markPendingReportSubmitConfirmed(id, { reason: 'gate_off', attempts: legacyAttempts });
|
|
2229
|
+
}
|
|
2158
2230
|
emitSubmitBus({ strategy: legacyStrategy, attempts: legacyAttempts, gated: false });
|
|
2159
2231
|
return res.json({ success: true, strategy: legacyStrategy, attempts: legacyAttempts, gated: false });
|
|
2160
2232
|
}
|
|
2233
|
+
if (injectedBody) {
|
|
2234
|
+
markPendingReportSubmitUnconfirmed(id, { reason: 'strategy_failed', attempts: legacyAttempts, retryable: false });
|
|
2235
|
+
}
|
|
2161
2236
|
return res.status(503).json({
|
|
2162
2237
|
error: 'Submit failed via all strategies (kitty/cmux/pty)',
|
|
2163
2238
|
strategy: 'none',
|
|
@@ -2225,9 +2300,16 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2225
2300
|
}
|
|
2226
2301
|
|
|
2227
2302
|
// Step 2: dispatch Enter via existing kitty → cmux → PTY chain.
|
|
2303
|
+
if (injectedBody) {
|
|
2304
|
+
markPendingReportSubmitStarted(id, injectedBody);
|
|
2305
|
+
}
|
|
2306
|
+
let submittedAtMs = Date.now();
|
|
2228
2307
|
let strategy = terminalLevelSubmit(id, session);
|
|
2229
2308
|
let attempts = strategy ? 1 : 0;
|
|
2230
2309
|
if (!strategy) {
|
|
2310
|
+
if (injectedBody) {
|
|
2311
|
+
markPendingReportSubmitUnconfirmed(id, { reason: 'strategy_failed', attempts: 0, retryable: false });
|
|
2312
|
+
}
|
|
2231
2313
|
return res.status(503).json({
|
|
2232
2314
|
error: 'Submit failed via all strategies (kitty/cmux/pty)',
|
|
2233
2315
|
strategy: 'none',
|
|
@@ -2238,47 +2320,51 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2238
2320
|
});
|
|
2239
2321
|
}
|
|
2240
2322
|
|
|
2241
|
-
// Step 3:
|
|
2323
|
+
// Step 3: confirm the submit was accepted (only when caller provided body).
|
|
2242
2324
|
// Without `injected_body`, this is a bare Enter press (`telepty enter` or
|
|
2243
|
-
// `telepty send-key` without force) — there is nothing to
|
|
2244
|
-
// shot is enough.
|
|
2325
|
+
// `telepty send-key` without force) — there is nothing to confirm and one
|
|
2326
|
+
// shot is enough. A retry is idempotent only when the body is still visible.
|
|
2245
2327
|
let verify = null;
|
|
2328
|
+
let confirm = null;
|
|
2246
2329
|
if (injectedBody && injectedBody.length > 0) {
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
stripAnsi: stripAnsiState,
|
|
2250
|
-
});
|
|
2251
|
-
if (!verify.consumed) {
|
|
2330
|
+
confirm = await confirmSubmitAfterDispatch(id, session, injectedBody, submittedAtMs, verifyTimeoutMs);
|
|
2331
|
+
while (confirm && !confirm.accepted && confirm.retryable && attempts <= retries) {
|
|
2252
2332
|
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
2333
|
+
submittedAtMs = Date.now();
|
|
2253
2334
|
const retryStrategy = terminalLevelSubmit(id, session);
|
|
2254
|
-
if (retryStrategy)
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
timeoutMs: verifyTimeoutMs,
|
|
2259
|
-
stripAnsi: stripAnsiState,
|
|
2260
|
-
});
|
|
2261
|
-
}
|
|
2335
|
+
if (!retryStrategy) break;
|
|
2336
|
+
strategy = retryStrategy;
|
|
2337
|
+
attempts++;
|
|
2338
|
+
confirm = await confirmSubmitAfterDispatch(id, session, injectedBody, submittedAtMs, verifyTimeoutMs);
|
|
2262
2339
|
}
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2340
|
+
verify = buildSubmitVerify(confirm);
|
|
2341
|
+
|
|
2342
|
+
if (confirm && !confirm.accepted) {
|
|
2343
|
+
const reason = gatedDispatchAfterTimeout ? 'gated_dispatch_unconsumed' : 'submit_unconfirmed';
|
|
2267
2344
|
const failBody = {
|
|
2268
|
-
error:
|
|
2269
|
-
|
|
2345
|
+
error: gatedDispatchAfterTimeout
|
|
2346
|
+
? 'Submit gated-timeout and body not consumed after best-effort dispatch'
|
|
2347
|
+
: 'Submit body still visible after bounded confirmation retry',
|
|
2348
|
+
reason,
|
|
2270
2349
|
last_state: gateResult.last_state,
|
|
2271
2350
|
strategy,
|
|
2272
2351
|
attempts,
|
|
2273
2352
|
gated: true,
|
|
2274
2353
|
gate_wait_ms: gateResult.waited_ms,
|
|
2275
2354
|
verify,
|
|
2355
|
+
confirm,
|
|
2276
2356
|
gated_dispatch_after_timeout: true,
|
|
2277
2357
|
...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
|
|
2278
2358
|
};
|
|
2359
|
+
if (!gatedDispatchAfterTimeout) {
|
|
2360
|
+
delete failBody.gated_dispatch_after_timeout;
|
|
2361
|
+
}
|
|
2362
|
+
markPendingReportSubmitUnconfirmed(id, { ...confirm, attempts });
|
|
2279
2363
|
emitSubmitBus(failBody);
|
|
2280
2364
|
return res.status(504).json(failBody);
|
|
2281
2365
|
}
|
|
2366
|
+
|
|
2367
|
+
markPendingReportSubmitConfirmed(id, { ...(confirm || { reason: 'empty_body' }), attempts });
|
|
2282
2368
|
}
|
|
2283
2369
|
|
|
2284
2370
|
const responseBody = {
|
|
@@ -2288,6 +2374,7 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
2288
2374
|
gated: true,
|
|
2289
2375
|
gate_wait_ms: gateResult.waited_ms,
|
|
2290
2376
|
verify,
|
|
2377
|
+
confirm,
|
|
2291
2378
|
...(gatedDispatchAfterTimeout ? { gated_dispatch_after_timeout: true } : {}),
|
|
2292
2379
|
...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
|
|
2293
2380
|
};
|
|
@@ -2432,6 +2519,9 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
|
|
|
2432
2519
|
source: from,
|
|
2433
2520
|
injectedAt: injectTimestamp,
|
|
2434
2521
|
injectId: inject_id,
|
|
2522
|
+
submitExpected: !!no_enter,
|
|
2523
|
+
noEnter: !!no_enter,
|
|
2524
|
+
injectedBodyPreview: prompt.slice(0, 500),
|
|
2435
2525
|
awaitingReport: true,
|
|
2436
2526
|
idleNotified: false
|
|
2437
2527
|
};
|
|
@@ -2511,6 +2601,11 @@ app.get('/api/pendingReports/:id', (req, res) => {
|
|
|
2511
2601
|
idle_notified: !!entry.idleNotified,
|
|
2512
2602
|
idle_at: entry.idleAt || null,
|
|
2513
2603
|
awaiting_report: !!entry.awaitingReport,
|
|
2604
|
+
submit_expected: !!entry.submitExpected,
|
|
2605
|
+
submit_in_progress: !!entry.submitInProgress,
|
|
2606
|
+
submit_confirmed_at: entry.submitConfirmedAt || null,
|
|
2607
|
+
submit_unconfirmed_at: entry.submitUnconfirmedAt || null,
|
|
2608
|
+
saw_working_after_inject: !!entry.sawWorkingAfterInject,
|
|
2514
2609
|
auto_summary: autoSummary
|
|
2515
2610
|
});
|
|
2516
2611
|
});
|
|
@@ -2662,6 +2757,18 @@ app.delete('/api/sessions/:id', (req, res) => {
|
|
|
2662
2757
|
const session = sessions[resolvedId];
|
|
2663
2758
|
const id = resolvedId;
|
|
2664
2759
|
if (session.isClosing) return res.json({ success: true, status: 'closing' });
|
|
2760
|
+
// BUG-C (shared-fate): a wrapped session can be co-bound by a stale/displaced owner bridge
|
|
2761
|
+
// (duplicate --id). A DELETE carrying a token that is NOT the current owner's, while a live
|
|
2762
|
+
// owner ws is still open, is that stale bridge exiting — it must NOT tear down the live owner.
|
|
2763
|
+
// Detach-only (no-op): leave the record and every client untouched. Tokenless callers
|
|
2764
|
+
// (operator `telepty delete`, ghost clean) and matching-token current-owner exits are
|
|
2765
|
+
// unaffected. Forceful kills go through POST /:id/kill (teardownSessionById), not here.
|
|
2766
|
+
const ownerToken = req.query.owner_token;
|
|
2767
|
+
if (session.type === 'wrapped'
|
|
2768
|
+
&& ownerToken && session.ownerToken && ownerToken !== session.ownerToken
|
|
2769
|
+
&& isOpenWebSocket(session.ownerWs)) {
|
|
2770
|
+
return res.json({ success: true, status: 'stale-detached' });
|
|
2771
|
+
}
|
|
2665
2772
|
try {
|
|
2666
2773
|
session.isClosing = true;
|
|
2667
2774
|
if (session.type === 'wrapped') {
|
|
@@ -3236,6 +3343,19 @@ if (require.main === module) setInterval(() => {
|
|
|
3236
3343
|
// once), so this GCs NOTHING in that case — preserving the #486/#488 survival guarantee.
|
|
3237
3344
|
if (session.type === 'wrapped' && session.backend === 'cmux' && session.cmuxWorkspaceId
|
|
3238
3345
|
&& isOpenWebSocket(session.ownerWs)) {
|
|
3346
|
+
const mismatchProbe = terminalBackend.detectSurfaceMismatch(session, { sessionId: id });
|
|
3347
|
+
const mismatchAction = applySurfaceMismatchProbe(id, session, mismatchProbe, {
|
|
3348
|
+
nowMs: now,
|
|
3349
|
+
emit: (extra) => broadcastSessionEvent('surface_mismatched', id, session, { nowMs: now, extra })
|
|
3350
|
+
});
|
|
3351
|
+
if (mismatchAction.action === 'mark') {
|
|
3352
|
+
console.log(`[SURFACE-MISMATCH] mismatch candidate for ${id} (${mismatchProbe.observedSurface}) — ${SURFACE_MISMATCH_SECONDS}s debounce started`);
|
|
3353
|
+
} else if (mismatchAction.action === 'emit') {
|
|
3354
|
+
console.log(`[SURFACE-MISMATCH] emitted surface_mismatched for ${id}: ${mismatchProbe.observedSurface} (${mismatchAction.mismatchSeconds}s)`);
|
|
3355
|
+
} else if (mismatchAction.action === 'recover') {
|
|
3356
|
+
console.log(`[SURFACE-MISMATCH] ${id} recovered/indeterminate — clearing mismatch debounce`);
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3239
3359
|
const liveness = terminalBackend.isSurfaceAlive(session);
|
|
3240
3360
|
const gcAction = decideSurfaceGc(liveness, session, now);
|
|
3241
3361
|
if (gcAction === 'mark') {
|
|
@@ -3317,250 +3437,33 @@ if (server) server.on('error', async (error) => {
|
|
|
3317
3437
|
throw error;
|
|
3318
3438
|
});
|
|
3319
3439
|
|
|
3320
|
-
|
|
3321
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
3322
|
-
|
|
3323
|
-
wss.on('connection', (ws, req) => {
|
|
3324
|
-
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
3325
|
-
const sessionId = url.pathname.split('/').pop();
|
|
3326
|
-
const session = sessions[sessionId];
|
|
3327
|
-
// ?owner=1 indicates the allow bridge (PTY owner), not an attach viewer
|
|
3328
|
-
const isOwnerConnect = url.searchParams.get('owner') === '1';
|
|
3329
|
-
|
|
3330
|
-
// Ping/pong heartbeat — detect and terminate stale TCP half-open connections (30s interval)
|
|
3331
|
-
let isAlive = true;
|
|
3332
|
-
ws.on('pong', () => { isAlive = true; });
|
|
3333
|
-
const pingInterval = setInterval(() => {
|
|
3334
|
-
if (!isAlive) {
|
|
3335
|
-
console.log(`[WS] Terminating stale connection (no pong) for ${sessionId}`);
|
|
3336
|
-
ws.terminate();
|
|
3337
|
-
return;
|
|
3338
|
-
}
|
|
3339
|
-
isAlive = false;
|
|
3340
|
-
ws.ping();
|
|
3341
|
-
}, 30000);
|
|
3342
|
-
|
|
3343
|
-
if (!session) {
|
|
3344
|
-
const connectedAt = new Date().toISOString();
|
|
3345
|
-
// Auto-register wrapped session on WS connect (supports reconnect after daemon restart)
|
|
3346
|
-
const autoSession = {
|
|
3347
|
-
id: sessionId,
|
|
3348
|
-
type: 'wrapped',
|
|
3349
|
-
ptyProcess: null,
|
|
3350
|
-
ownerWs: ws,
|
|
3351
|
-
command: 'wrapped',
|
|
3352
|
-
cwd: process.cwd(),
|
|
3353
|
-
createdAt: connectedAt,
|
|
3354
|
-
lastActivityAt: connectedAt,
|
|
3355
|
-
lastConnectedAt: connectedAt,
|
|
3356
|
-
lastDisconnectedAt: null,
|
|
3357
|
-
clients: new Set([ws]),
|
|
3358
|
-
isClosing: false,
|
|
3359
|
-
outputRing: [],
|
|
3360
|
-
ready: true,
|
|
3361
|
-
};
|
|
3362
|
-
initializeBootstrapState(autoSession);
|
|
3363
|
-
sessions[sessionId] = autoSession;
|
|
3364
|
-
console.log(`[WS] Auto-registered wrapped session ${sessionId} on reconnect`);
|
|
3365
|
-
// Set tab title via kitty (no \x0c redraw — it causes flickering on multi-session reconnect)
|
|
3366
|
-
setTimeout(() => {
|
|
3367
|
-
const sock = findKittySocket();
|
|
3368
|
-
const wid = findKittyWindowId(sock, sessionId);
|
|
3369
|
-
if (sock && wid) {
|
|
3370
|
-
try {
|
|
3371
|
-
require('child_process').execSync(`kitty @ --to unix:${sock} set-tab-title --match id:${wid} '⚡ telepty :: ${sessionId}'`, {
|
|
3372
|
-
timeout: 2000, stdio: ['pipe', 'pipe', 'pipe']
|
|
3373
|
-
});
|
|
3374
|
-
} catch {}
|
|
3375
|
-
}
|
|
3376
|
-
}, 1000);
|
|
3377
|
-
} else {
|
|
3378
|
-
session.clients.add(ws);
|
|
3379
|
-
}
|
|
3380
|
-
|
|
3381
|
-
const activeSession = sessions[sessionId];
|
|
3382
|
-
|
|
3383
|
-
// For wrapped sessions, first connector OR explicit ?owner=1 claim becomes the owner.
|
|
3384
|
-
// ?owner=1 reclaim handles the stale-ownerWs bug: allow bridge reconnects but stale TCP
|
|
3385
|
-
// half-open connection still holds ownerWs slot → reconnect wrongly becomes a viewer.
|
|
3386
|
-
if (activeSession.type === 'wrapped' && (!activeSession.ownerWs || isOwnerConnect)) {
|
|
3387
|
-
const hadDisconnectedOwner = !isOpenWebSocket(activeSession.ownerWs) && activeSession.lastDisconnectedAt;
|
|
3388
|
-
if (isOwnerConnect && activeSession.ownerWs && activeSession.ownerWs !== ws) {
|
|
3389
|
-
// Terminate the stale owner connection before claiming ownership
|
|
3390
|
-
console.log(`[WS] Replacing stale ownerWs for session ${sessionId}`);
|
|
3391
|
-
activeSession.ownerWs.terminate();
|
|
3392
|
-
}
|
|
3393
|
-
activeSession.ownerWs = ws;
|
|
3394
|
-
markSessionConnected(activeSession);
|
|
3395
|
-
initializeBootstrapState(activeSession);
|
|
3396
|
-
console.log(`[WS] Wrap owner ${isOwnerConnect && activeSession.clients.size > 1 ? 're-' : ''}connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
3397
|
-
scheduleBootstrapPromptPoll(sessionId, activeSession);
|
|
3398
|
-
if (hadDisconnectedOwner) {
|
|
3399
|
-
emitSessionLifecycleEvent('session_reconnect', sessionId, activeSession);
|
|
3400
|
-
}
|
|
3401
|
-
persistSessions();
|
|
3402
|
-
} else {
|
|
3403
|
-
console.log(`[WS] Client attached to session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
3404
|
-
}
|
|
3405
|
-
|
|
3406
|
-
ws.on('message', (message) => {
|
|
3407
|
-
try {
|
|
3408
|
-
const { type, data, cols, rows } = JSON.parse(message);
|
|
3409
|
-
|
|
3410
|
-
if (activeSession.type === 'wrapped') {
|
|
3411
|
-
if (ws === activeSession.ownerWs) {
|
|
3412
|
-
// Owner sending output -> broadcast to other clients + update activity
|
|
3413
|
-
if (type === 'output') {
|
|
3414
|
-
activeSession.lastActivityAt = new Date().toISOString();
|
|
3415
|
-
appendToOutputRing(activeSession, data);
|
|
3416
|
-
sessionStateManager.feed(sessionId, data);
|
|
3417
|
-
activeSession.clients.forEach(client => {
|
|
3418
|
-
if (client !== ws && client.readyState === 1) {
|
|
3419
|
-
client.send(JSON.stringify({ type: 'output', data }));
|
|
3420
|
-
}
|
|
3421
|
-
});
|
|
3422
|
-
} else if (type === 'ready') {
|
|
3423
|
-
if (isBootstrapGatedSession(activeSession)) {
|
|
3424
|
-
markBootstrapReady(sessionId, activeSession, 'bridge_ready');
|
|
3425
|
-
} else {
|
|
3426
|
-
activeSession.ready = true;
|
|
3427
|
-
}
|
|
3428
|
-
activeSession.lastActivityAt = new Date().toISOString();
|
|
3429
|
-
console.log(`[READY] Session ${sessionId} CLI is ready for inject`);
|
|
3430
|
-
// Broadcast readiness to bus (cmux/kitty paths now enabled for this session)
|
|
3431
|
-
const readyMsg = JSON.stringify({
|
|
3432
|
-
type: 'session_ready',
|
|
3433
|
-
session_id: sessionId,
|
|
3434
|
-
timestamp: new Date().toISOString()
|
|
3435
|
-
});
|
|
3436
|
-
busClients.forEach(client => {
|
|
3437
|
-
if (client.readyState === 1) client.send(readyMsg);
|
|
3438
|
-
});
|
|
3439
|
-
// Auto-report: notify source that target completed inject task
|
|
3440
|
-
// Legacy ready-signal auto-report path. Skip if onTransition already
|
|
3441
|
-
// fired (pendingReports[sessionId].idleNotified === true).
|
|
3442
|
-
const pendingReport = pendingReports[sessionId];
|
|
3443
|
-
if (pendingReport && !pendingReport.idleNotified) {
|
|
3444
|
-
// ready-signal: cli.js bridge emitted a 'ready' WS frame.
|
|
3445
|
-
fireAutoReport(sessionId, activeSession, pendingReport, 'ready-signal');
|
|
3446
|
-
}
|
|
3447
|
-
}
|
|
3448
|
-
} else {
|
|
3449
|
-
// Non-owner client input -> forward to owner as inject
|
|
3450
|
-
if (type === 'input' && activeSession.ownerWs && activeSession.ownerWs.readyState === 1) {
|
|
3451
|
-
activeSession.ownerWs.send(JSON.stringify({ type: 'inject', data }));
|
|
3452
|
-
} else if (type === 'resize' && activeSession.ownerWs && activeSession.ownerWs.readyState === 1) {
|
|
3453
|
-
activeSession.ownerWs.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
3454
|
-
}
|
|
3455
|
-
}
|
|
3456
|
-
} else {
|
|
3457
|
-
// Existing spawned session logic
|
|
3458
|
-
if (type === 'input') {
|
|
3459
|
-
activeSession.ptyProcess.write(data);
|
|
3460
|
-
} else if (type === 'resize') {
|
|
3461
|
-
activeSession.ptyProcess.resize(cols, rows);
|
|
3462
|
-
}
|
|
3463
|
-
}
|
|
3464
|
-
} catch (e) {
|
|
3465
|
-
console.error('[WS] Invalid message format', e);
|
|
3466
|
-
}
|
|
3467
|
-
});
|
|
3468
|
-
|
|
3469
|
-
ws.on('close', () => {
|
|
3470
|
-
clearInterval(pingInterval);
|
|
3471
|
-
activeSession.clients.delete(ws);
|
|
3472
|
-
if (activeSession.type === 'wrapped' && ws === activeSession.ownerWs) {
|
|
3473
|
-
activeSession.ownerWs = null;
|
|
3474
|
-
// #29: cancel any pending owner-alive optimistic timer — the owner is gone, so the
|
|
3475
|
-
// floor must not flip a disconnected session ready (hygiene; the timer also re-guards
|
|
3476
|
-
// on isOpenWebSocket, but clearing avoids a dangling handle).
|
|
3477
|
-
if (activeSession.bootstrapOptimisticTimer) {
|
|
3478
|
-
clearTimeout(activeSession.bootstrapOptimisticTimer);
|
|
3479
|
-
activeSession.bootstrapOptimisticTimer = null;
|
|
3480
|
-
}
|
|
3481
|
-
markSessionDisconnected(activeSession);
|
|
3482
|
-
console.log(`[WS] Wrap owner disconnected from session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
3483
|
-
emitSessionLifecycleEvent('session_disconnect', sessionId, activeSession, {
|
|
3484
|
-
clients: activeSession.clients.size
|
|
3485
|
-
});
|
|
3486
|
-
persistSessions();
|
|
3487
|
-
} else {
|
|
3488
|
-
console.log(`[WS] Client detached from session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
3489
|
-
}
|
|
3490
|
-
});
|
|
3491
|
-
});
|
|
3492
|
-
|
|
3493
|
-
const busWss = new WebSocketServer({ noServer: true });
|
|
3494
3440
|
const busClients = new Set();
|
|
3495
3441
|
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
if (client !== ws && client.readyState === 1) {
|
|
3522
|
-
client.send(JSON.stringify(msg));
|
|
3523
|
-
}
|
|
3524
|
-
});
|
|
3525
|
-
|
|
3526
|
-
// Auto-route turn_request events (shared logic with HTTP publish)
|
|
3527
|
-
busAutoRoute(msg);
|
|
3528
|
-
// Relay to peer daemons (dedup prevents loops)
|
|
3529
|
-
if (!msg._relayed_from) relayToPeers(msg);
|
|
3530
|
-
} catch (e) {
|
|
3531
|
-
console.error('[BUS] Invalid message format', e);
|
|
3532
|
-
}
|
|
3533
|
-
});
|
|
3534
|
-
|
|
3535
|
-
ws.on('close', () => {
|
|
3536
|
-
busClients.delete(ws);
|
|
3537
|
-
console.log('[BUS] Agent disconnected from event bus');
|
|
3538
|
-
});
|
|
3539
|
-
});
|
|
3540
|
-
|
|
3541
|
-
if (server) server.on('upgrade', (req, socket, head) => {
|
|
3542
|
-
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
3543
|
-
const token = url.searchParams.get('token');
|
|
3544
|
-
|
|
3545
|
-
const wsAuthHeader = req.headers['authorization'] || '';
|
|
3546
|
-
const wsJwtValid = wsAuthHeader.startsWith('Bearer ') && verifyJwt(wsAuthHeader.slice(7));
|
|
3547
|
-
if (!isAllowedPeer(req.socket.remoteAddress) && token !== EXPECTED_TOKEN && !wsJwtValid) {
|
|
3548
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
3549
|
-
socket.destroy();
|
|
3550
|
-
return;
|
|
3551
|
-
}
|
|
3552
|
-
|
|
3553
|
-
if (url.pathname.startsWith('/api/sessions/')) {
|
|
3554
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
3555
|
-
wss.emit('connection', ws, req);
|
|
3556
|
-
});
|
|
3557
|
-
} else if (url.pathname === '/api/bus') {
|
|
3558
|
-
busWss.handleUpgrade(req, socket, head, (ws) => {
|
|
3559
|
-
busWss.emit('connection', ws, req);
|
|
3560
|
-
});
|
|
3561
|
-
} else {
|
|
3562
|
-
socket.destroy();
|
|
3563
|
-
}
|
|
3442
|
+
installWebSocketTransport({
|
|
3443
|
+
server,
|
|
3444
|
+
sessions,
|
|
3445
|
+
busClients,
|
|
3446
|
+
expectedToken: EXPECTED_TOKEN,
|
|
3447
|
+
verifyJwt,
|
|
3448
|
+
isAllowedPeer,
|
|
3449
|
+
initializeBootstrapState,
|
|
3450
|
+
findKittySocket,
|
|
3451
|
+
findKittyWindowId,
|
|
3452
|
+
markSessionConnected,
|
|
3453
|
+
scheduleBootstrapPromptPoll,
|
|
3454
|
+
emitSessionLifecycleEvent,
|
|
3455
|
+
persistSessions,
|
|
3456
|
+
appendToOutputRing,
|
|
3457
|
+
sessionStateManager,
|
|
3458
|
+
isBootstrapGatedSession,
|
|
3459
|
+
markBootstrapReady,
|
|
3460
|
+
pendingReports,
|
|
3461
|
+
fireAutoReport,
|
|
3462
|
+
markSessionDisconnected,
|
|
3463
|
+
resolveSessionAlias,
|
|
3464
|
+
applySessionStateReport,
|
|
3465
|
+
relayToPeers,
|
|
3466
|
+
busAutoRoute
|
|
3564
3467
|
});
|
|
3565
3468
|
|
|
3566
3469
|
function shutdown(code) {
|
|
@@ -3589,4 +3492,5 @@ module.exports = {
|
|
|
3589
3492
|
shouldApplyOwnerAliveFloor, // #29: owner-alive optimistic-floor decision (deps DI: isProcessRunning/...)
|
|
3590
3493
|
scheduleBootstrapPromptPoll, // #29: arms the floor timer (deps DI: setTimeout/...)
|
|
3591
3494
|
decideSurfaceGc, // #17: surface-liveness verdict→action (incl. INV-17 unknown→skip)
|
|
3495
|
+
applySurfaceMismatchProbe, // surface_mismatched debounce + payload helper (deps DI: emit/clock)
|
|
3592
3496
|
};
|