@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/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 = require('path').join(os.homedir(), '.config', 'aigentry-telepty', 'sessions.json');
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
- try {
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
- try {
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 RELAY_PEERS = (process.env.TELEPTY_RELAY_PEERS || '').split(',').map(s => s.trim()).filter(Boolean);
170
- const RELAY_SEEN = new Set(); // dedup by message_id
171
-
172
- function relayToPeers(msg) {
173
- if (RELAY_PEERS.length === 0) return;
174
- if (!msg.message_id) msg.message_id = crypto.randomUUID();
175
- if (RELAY_SEEN.has(msg.message_id)) return; // already relayed
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
- function verifyJwt(token) {
202
- if (!JWT_SECRET || !token) return false;
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((req, res, next) => {
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 = {}; // {targetSessionId: {source, injectedAt, injectId}}
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 = elapsedNum >= AUTO_REPORT_MIN_REAL_SECONDS;
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 strategy = terminalLevelSubmit(sessionId, session);
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: 1,
679
+ attempts,
615
680
  gated: false,
616
- verify: null,
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
- if (meta.type === 'wrapped') {
1376
- sessions[id] = {
1377
- id, type: 'wrapped', ptyProcess: null, ownerWs: null,
1378
- command: meta.command || 'wrapped', cwd: meta.cwd || process.cwd(),
1379
- backend: meta.backend || 'kitty',
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: verify body consumption (only when the caller provided the body).
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 verify and one
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
- verify = await submitGate.verifyBodyConsumed(session, injectedBody, {
2248
- timeoutMs: verifyTimeoutMs,
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
- strategy = retryStrategy;
2256
- attempts++;
2257
- verify = await submitGate.verifyBodyConsumed(session, injectedBody, {
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
- // Honest 504: gate timed out AND the body never left the input box even
2264
- // after the best-effort dispatch + verify. Distinguishable from the legacy
2265
- // `gate_timeout` reason (which dropped dispatch entirely).
2266
- if (gatedDispatchAfterTimeout && !verify.consumed) {
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: 'Submit gated-timeout and body not consumed after best-effort dispatch',
2269
- reason: 'gated_dispatch_unconsumed',
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
- busWss.on('connection', (ws, req) => {
3497
- busClients.add(ws);
3498
- console.log('[BUS] New agent connected to event bus');
3499
-
3500
- ws.on('message', (message) => {
3501
- try {
3502
- const msg = JSON.parse(message);
3503
- if (msg.type === 'session_state_report') {
3504
- const resolvedId = resolveSessionAlias(msg.session_id || '');
3505
- if (!resolvedId || !sessions[resolvedId]) {
3506
- return;
3507
- }
3508
-
3509
- const applied = applySessionStateReport(resolvedId, sessions[resolvedId], msg);
3510
- if (!applied.success) {
3511
- return;
3512
- }
3513
-
3514
- if (!msg._relayed_from) relayToPeers(applied.event);
3515
- persistSessions();
3516
- return;
3517
- }
3518
-
3519
- // Broadcast to all other bus clients
3520
- busClients.forEach(client => {
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
  };