@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -35,9 +35,10 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "postinstall": "node scripts/postinstall.js",
38
- "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
- "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js",
40
- "test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
38
+ "test": "node --test test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
+ "test:watch": "node --test --watch test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js",
40
+ "test:ci": "node --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
41
+ "typecheck": "tsc --noEmit",
41
42
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
42
43
  },
43
44
  "keywords": [
@@ -78,5 +79,9 @@
78
79
  "uuid": "^9.0.0",
79
80
  "ws": "^8.19.0",
80
81
  "zod": "^3.24.0"
82
+ },
83
+ "devDependencies": {
84
+ "@types/node": "^20.19.41",
85
+ "typescript": "^6.0.3"
81
86
  }
82
87
  }
@@ -0,0 +1,100 @@
1
+ const { formatHostLabel } = require('../../session-routing');
2
+ const lifecycle = require('../lifecycle');
3
+
4
+ function detectTerminalProgram(env = process.env) {
5
+ const rawTermProgram = typeof env.TERM_PROGRAM === 'string' ? env.TERM_PROGRAM.trim() : '';
6
+ if (rawTermProgram) {
7
+ return rawTermProgram;
8
+ }
9
+
10
+ if (env.TMUX) {
11
+ return 'tmux';
12
+ }
13
+
14
+ const term = typeof env.TERM === 'string' ? env.TERM.toLowerCase() : '';
15
+ if (term.includes('kitty')) return 'kitty';
16
+ if (term.includes('ghostty')) return 'ghostty';
17
+ if (term.includes('tmux')) return 'tmux';
18
+
19
+ return null;
20
+ }
21
+
22
+ function formatSessionTerminal(session) {
23
+ const terminal = session.terminal || session.termProgram || null;
24
+ const term = session.term || null;
25
+ if (terminal && term) {
26
+ return `${terminal} (${term})`;
27
+ }
28
+ return terminal || term || 'unknown';
29
+ }
30
+
31
+ function formatSessionHealth(session) {
32
+ const status = session.healthStatus || 'UNKNOWN';
33
+ const reason = session.healthReason || null;
34
+ if (reason && reason !== status) {
35
+ return `${status} (${reason})`;
36
+ }
37
+ return status;
38
+ }
39
+
40
+ function enrichSessionIdle(session, nowMs = Date.now()) {
41
+ const idleSeconds = typeof session.idleSeconds === 'number'
42
+ ? session.idleSeconds
43
+ : lifecycle.computeIdleSeconds(session.lastActivityAt, nowMs);
44
+ return {
45
+ ...session,
46
+ idleSeconds,
47
+ idle_seconds: idleSeconds
48
+ };
49
+ }
50
+
51
+ function formatSessionStatusWithIdle(session) {
52
+ const base = formatSessionHealth(session);
53
+ const idleSeconds = typeof session.idleSeconds === 'number' ? session.idleSeconds : null;
54
+ if (idleSeconds !== null && idleSeconds > 60) {
55
+ return `${base} 💤 idle (${lifecycle.formatIdleDuration(idleSeconds)})`;
56
+ }
57
+ return base;
58
+ }
59
+
60
+ function printSessionInfo(session, options = {}) {
61
+ const host = options.host || session.host || '127.0.0.1';
62
+ console.log('\x1b[1mSession Info:\x1b[0m');
63
+ console.log(` - ID: \x1b[36m${session.id}\x1b[0m`);
64
+ console.log(` Host: ${formatHostLabel(host)}`);
65
+ console.log(` Command: ${session.command}`);
66
+ console.log(` Type: ${session.type || 'unknown'}`);
67
+ console.log(` Status: ${formatSessionHealth(session)}`);
68
+ console.log(` Terminal: ${session.terminal || session.termProgram || 'unknown'}`);
69
+ console.log(` TERM: ${session.term || 'n/a'}`);
70
+ console.log(` CWD: ${session.cwd}`);
71
+ console.log(` Clients: ${session.active_clients ?? 0}`);
72
+ if (session.createdAt) {
73
+ console.log(` Started: ${new Date(session.createdAt).toLocaleString()}`);
74
+ }
75
+ if (session.lastActivityAt) {
76
+ console.log(` Last Activity: ${new Date(session.lastActivityAt).toLocaleString()}`);
77
+ }
78
+ if (typeof session.idleSeconds === 'number') {
79
+ console.log(` Idle: ${session.idleSeconds}s`);
80
+ }
81
+ if (session.semantic && session.semantic.phase) {
82
+ console.log(` Phase: ${session.semantic.phase}`);
83
+ }
84
+ if (session.semantic && session.semantic.current_task) {
85
+ console.log(` Current Task: ${session.semantic.current_task}`);
86
+ }
87
+ if (session.semantic && session.semantic.blocker) {
88
+ console.log(` Blocker: ${session.semantic.blocker}`);
89
+ }
90
+ console.log('');
91
+ }
92
+
93
+ module.exports = {
94
+ detectTerminalProgram,
95
+ formatSessionTerminal,
96
+ formatSessionHealth,
97
+ enrichSessionIdle,
98
+ formatSessionStatusWithIdle,
99
+ printSessionInfo
100
+ };
package/src/lifecycle.js CHANGED
@@ -6,6 +6,12 @@ const os = require('node:os');
6
6
 
7
7
  const { killWindowsProcess } = require('./win-kill-process');
8
8
 
9
+ // #17: grace window before a cmux session whose workspace was explicitly closed (bridge
10
+ // survived → headless zombie) is reclaimed. Shorter than the 300s disconnect-GC: the surface
11
+ // is confirmed gone (not merely disconnected). The window absorbs cmux transient hiccups.
12
+ const SURFACE_ORPHAN_SECONDS = Math.max(5, Number(process.env.TELEPTY_SURFACE_ORPHAN_SECONDS || 30));
13
+ const SURFACE_MISMATCH_SECONDS = Math.max(1, Number(process.env.TELEPTY_SURFACE_MISMATCH_SECONDS || 10));
14
+
9
15
  const DURATION_RE = /^(\d+)(ms|s|m|h|d)$/i;
10
16
  const UNIT_MS = {
11
17
  ms: 1,
@@ -223,7 +229,110 @@ function selectCleanOlderThanTargets(sessions, options = {}) {
223
229
  return targets;
224
230
  }
225
231
 
232
+ // #17: pure verdict→action mapping for the surface-liveness GC, exposed for unit-testing.
233
+ // Returns 'mark' (start the grace window), 'reclaim' (grace elapsed → teardown), 'recover'
234
+ // (surface returned within grace → clear), or 'skip'. INV-17: 'unknown' (cmux unreachable)
235
+ // always maps to 'skip' — GC nothing. Pure, no side effects; the caller performs the action.
236
+ function decideSurfaceGc(liveness, session, nowMs, graceSeconds = SURFACE_ORPHAN_SECONDS) {
237
+ if (liveness === 'gone') {
238
+ if (!session.surfaceGoneAt) return 'mark';
239
+ const goneSeconds = Math.floor((nowMs - new Date(session.surfaceGoneAt).getTime()) / 1000);
240
+ return goneSeconds >= graceSeconds ? 'reclaim' : 'skip';
241
+ }
242
+ if (liveness === 'alive' && session.surfaceGoneAt) return 'recover';
243
+ return 'skip';
244
+ }
245
+
246
+ function clearSurfaceMismatchState(session) {
247
+ if (!session) return;
248
+ session.surfaceMismatchAt = null;
249
+ session.surfaceMismatchObserved = null;
250
+ session.surfaceMismatchEmitted = false;
251
+ }
252
+
253
+ function getExpectedPtyPidFromProbe(session, probe) {
254
+ const candidates = [
255
+ probe && probe.expectedPtyPid,
256
+ session && session.ptyPid,
257
+ session && session.pty_pid,
258
+ session && session.ptyProcess && session.ptyProcess.pid,
259
+ session && session.pid
260
+ ];
261
+ for (const pid of candidates) {
262
+ const n = Number(pid);
263
+ if (Number.isInteger(n) && n > 0) return n;
264
+ }
265
+ return null;
266
+ }
267
+
268
+ function buildSurfaceMismatchExtra(sessionId, session, probe, mismatchSeconds) {
269
+ return {
270
+ sid: sessionId,
271
+ backend: 'cmux',
272
+ cmuxWorkspaceId: session && session.cmuxWorkspaceId ? session.cmuxWorkspaceId : null,
273
+ expectedPtyPid: getExpectedPtyPidFromProbe(session, probe),
274
+ observedSurface: probe && probe.observedSurface ? probe.observedSurface : null,
275
+ mismatchSeconds
276
+ };
277
+ }
278
+
279
+ function getSurfaceMismatchObservedKey(probe) {
280
+ if (!probe || typeof probe !== 'object') return null;
281
+ return probe.observedSurfaceKey || probe.observedSurface || null;
282
+ }
283
+
284
+ function applySurfaceMismatchProbe(sessionId, session, probe, options = {}) {
285
+ if (!session) return { action: 'skip', reason: 'missing_session' };
286
+ const nowMs = options.nowMs ?? Date.now();
287
+ const debounceSeconds = options.debounceSeconds ?? SURFACE_MISMATCH_SECONDS;
288
+
289
+ if (!probe || probe.status !== 'mismatch') {
290
+ const hadState = !!(session.surfaceMismatchAt || session.surfaceMismatchObserved || session.surfaceMismatchEmitted);
291
+ clearSurfaceMismatchState(session);
292
+ return { action: hadState ? 'recover' : 'skip', reason: (probe && (probe.reason || probe.status)) || 'not_mismatch' };
293
+ }
294
+
295
+ const observedKey = getSurfaceMismatchObservedKey(probe);
296
+ if (!observedKey) {
297
+ clearSurfaceMismatchState(session);
298
+ return { action: 'skip', reason: 'observed_surface_unknown' };
299
+ }
300
+
301
+ if (!session.surfaceMismatchAt || session.surfaceMismatchObserved !== observedKey) {
302
+ session.surfaceMismatchAt = new Date(nowMs).toISOString();
303
+ session.surfaceMismatchObserved = observedKey;
304
+ session.surfaceMismatchEmitted = false;
305
+ return { action: 'mark', reason: probe.reason || 'mismatch', mismatchSeconds: 0 };
306
+ }
307
+
308
+ const startedMs = new Date(session.surfaceMismatchAt).getTime();
309
+ if (!Number.isFinite(startedMs)) {
310
+ session.surfaceMismatchAt = new Date(nowMs).toISOString();
311
+ session.surfaceMismatchObserved = observedKey;
312
+ session.surfaceMismatchEmitted = false;
313
+ return { action: 'mark', reason: 'invalid_start_reset', mismatchSeconds: 0 };
314
+ }
315
+
316
+ const mismatchSeconds = Math.max(0, Math.floor((nowMs - startedMs) / 1000));
317
+ if (session.surfaceMismatchEmitted) {
318
+ return { action: 'skip', reason: 'already_emitted', mismatchSeconds };
319
+ }
320
+ if (mismatchSeconds < debounceSeconds) {
321
+ return { action: 'skip', reason: 'debouncing', mismatchSeconds };
322
+ }
323
+
324
+ const extra = buildSurfaceMismatchExtra(sessionId, session, probe, mismatchSeconds);
325
+ let event = null;
326
+ if (typeof options.emit === 'function') {
327
+ event = options.emit(extra);
328
+ }
329
+ session.surfaceMismatchEmitted = true;
330
+ return { action: 'emit', reason: probe.reason || 'mismatch', mismatchSeconds, extra, event };
331
+ }
332
+
226
333
  module.exports = {
334
+ SURFACE_ORPHAN_SECONDS,
335
+ SURFACE_MISMATCH_SECONDS,
227
336
  parseDuration,
228
337
  computeIdleSeconds,
229
338
  formatIdleDuration,
@@ -233,5 +342,9 @@ module.exports = {
233
342
  cleanupSessionArtifacts,
234
343
  effectiveIdleTtlMs,
235
344
  selectIdleTtlVictims,
236
- selectCleanOlderThanTargets
345
+ selectCleanOlderThanTargets,
346
+ decideSurfaceGc,
347
+ clearSurfaceMismatchState,
348
+ buildSurfaceMismatchExtra,
349
+ applySurfaceMismatchProbe
237
350
  };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ function createVerifyJwt(JWT_SECRET) {
6
+ function verifyJwt(token) {
7
+ if (!JWT_SECRET || !token) return false;
8
+ try {
9
+ // Simple HS256 JWT verification (no external deps)
10
+ const [headerB64, payloadB64, sigB64] = token.split('.');
11
+ if (!headerB64 || !payloadB64 || !sigB64) return false;
12
+ const expected = crypto.createHmac('sha256', JWT_SECRET)
13
+ .update(`${headerB64}.${payloadB64}`).digest('base64url');
14
+ if (sigB64 !== expected) return false;
15
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
16
+ if (payload.exp && Date.now() / 1000 > payload.exp) return false;
17
+ return payload;
18
+ } catch { return false; }
19
+ }
20
+
21
+ return verifyJwt;
22
+ }
23
+
24
+ function createIsAllowedPeer(PEER_ALLOWLIST) {
25
+ function isAllowedPeer(ip) {
26
+ if (!ip) return false;
27
+ const cleanIp = ip.replace('::ffff:', '');
28
+ // Localhost always allowed (includes SSH tunnel traffic)
29
+ if (cleanIp === '127.0.0.1' || ip === '::1') return true;
30
+ // Peer allowlist
31
+ if (PEER_ALLOWLIST.length > 0) return PEER_ALLOWLIST.includes(cleanIp);
32
+ // No allowlist = allow all authenticated
33
+ return true;
34
+ }
35
+
36
+ return isAllowedPeer;
37
+ }
38
+
39
+ function createAuthMiddleware(options) {
40
+ const EXPECTED_TOKEN = options.expectedToken;
41
+ const isAllowedPeer = options.isAllowedPeer;
42
+ const verifyJwt = options.verifyJwt;
43
+
44
+ return (req, res, next) => {
45
+ const clientIp = req.ip;
46
+
47
+ if (isAllowedPeer(clientIp)) {
48
+ return next(); // Trust local and allowlisted peers (SSH tunnels arrive as localhost)
49
+ }
50
+
51
+ const token = req.headers['x-telepty-token'] || req.query.token;
52
+ if (token === EXPECTED_TOKEN) {
53
+ return next();
54
+ }
55
+
56
+ // JWT Bearer token
57
+ const authHeader = req.headers['authorization'] || '';
58
+ if (authHeader.startsWith('Bearer ') && verifyJwt(authHeader.slice(7))) {
59
+ return next();
60
+ }
61
+
62
+ console.warn(`[AUTH] Rejected unauthorized request from ${clientIp}`);
63
+ res.status(401).json({ error: 'Unauthorized: Invalid or missing token.', code: 'PERMISSION_DENIED' });
64
+ };
65
+ }
66
+
67
+ module.exports = {
68
+ createAuthMiddleware,
69
+ createIsAllowedPeer,
70
+ createVerifyJwt
71
+ };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ function defaultSessionPersistPath(homeDir = os.homedir()) {
8
+ return path.join(homeDir, '.config', 'aigentry-telepty', 'sessions.json');
9
+ }
10
+
11
+ function serializePersistedSessions(sessions) {
12
+ const data = {};
13
+ for (const [id, s] of Object.entries(sessions)) {
14
+ data[id] = {
15
+ id,
16
+ type: s.type,
17
+ command: s.command,
18
+ cwd: s.cwd,
19
+ backend: s.backend || null,
20
+ cmuxWorkspaceId: s.cmuxWorkspaceId || null,
21
+ cmuxSurfaceId: s.cmuxSurfaceId || null,
22
+ termProgram: s.termProgram || null,
23
+ term: s.term || null,
24
+ delivery: s.delivery || null,
25
+ deliveryEndpoint: s.deliveryEndpoint || null,
26
+ createdAt: s.createdAt,
27
+ lastActivityAt: s.lastActivityAt || null,
28
+ lastConnectedAt: s.lastConnectedAt || null,
29
+ lastDisconnectedAt: s.lastDisconnectedAt || null,
30
+ lastStateReportAt: s.lastStateReportAt || null,
31
+ stateReport: s.stateReport || null,
32
+ idleTtl: s.idleTtl || null,
33
+ idleTtlMs: s.idleTtlMs == null ? null : s.idleTtlMs,
34
+ ownerPid: s.ownerPid || null,
35
+ ptyPid: s.ptyPid || null
36
+ };
37
+ }
38
+ return data;
39
+ }
40
+
41
+ function savePersistedSessions(sessions, persistPath = defaultSessionPersistPath()) {
42
+ try {
43
+ const data = serializePersistedSessions(sessions);
44
+ fs.mkdirSync(path.dirname(persistPath), { recursive: true });
45
+ fs.writeFileSync(persistPath, JSON.stringify(data, null, 2));
46
+ } catch {}
47
+ }
48
+
49
+ function loadPersistedSessions(persistPath = defaultSessionPersistPath()) {
50
+ try {
51
+ if (!fs.existsSync(persistPath)) return {};
52
+ return JSON.parse(fs.readFileSync(persistPath, 'utf8'));
53
+ } catch { return {}; }
54
+ }
55
+
56
+ function buildRestoredWrappedSession(id, meta, options = {}) {
57
+ if (meta.type !== 'wrapped') return null;
58
+
59
+ const cwd = options.cwd || process.cwd();
60
+ const nowIso = options.nowIso || (() => new Date().toISOString());
61
+ return {
62
+ id, type: 'wrapped', ptyProcess: null, ownerWs: null,
63
+ command: meta.command || 'wrapped', cwd: meta.cwd || cwd,
64
+ backend: meta.backend || 'kitty',
65
+ cmuxWorkspaceId: meta.cmuxWorkspaceId || null,
66
+ cmuxSurfaceId: meta.cmuxSurfaceId || null,
67
+ termProgram: meta.termProgram || null,
68
+ term: meta.term || null,
69
+ createdAt: meta.createdAt || nowIso(),
70
+ lastActivityAt: meta.lastActivityAt || nowIso(),
71
+ lastConnectedAt: meta.lastConnectedAt || null,
72
+ lastDisconnectedAt: meta.lastDisconnectedAt || meta.lastActivityAt || nowIso(),
73
+ lastStateReportAt: meta.lastStateReportAt || null,
74
+ stateReport: meta.stateReport || null,
75
+ idleTtl: meta.idleTtl || null,
76
+ idleTtlMs: meta.idleTtlMs == null ? null : meta.idleTtlMs,
77
+ ownerPid: meta.ownerPid || null,
78
+ ptyPid: meta.ptyPid || null,
79
+ clients: new Set(), isClosing: false, outputRing: [], ready: true, };
80
+ }
81
+
82
+ module.exports = {
83
+ defaultSessionPersistPath,
84
+ serializePersistedSessions,
85
+ savePersistedSessions,
86
+ loadPersistedSessions,
87
+ buildRestoredWrappedSession
88
+ };
@@ -8,6 +8,7 @@
8
8
  // Exports:
9
9
  // - awaitReplReady(sessionId, stateManager, opts) → Promise<{ ready, last_state, waited_ms, reason? }>
10
10
  // - verifyBodyConsumed(session, bodyText, opts) → Promise<{ consumed, waited_ms, reason? }>
11
+ // - confirmSubmitAccepted(session, bodyText, opts) → Promise<{ accepted, retryable, waited_ms, reason? }>
11
12
  // - isReady(state, minConfidence) (test surface)
12
13
  // - isFailed(state) (test surface)
13
14
  // - READY_STATES, FAIL_STATES (test surface)
@@ -21,6 +22,7 @@ const READY_STATES = new Set(['idle', 'waiting']);
21
22
 
22
23
  // States where waiting will never produce readiness; resolve immediately.
23
24
  const FAIL_STATES = new Set(['dead', 'error', 'restarting']);
25
+ const ACCEPTED_AFTER_SUBMIT_STATES = new Set(['working', 'thinking']);
24
26
 
25
27
  function isReady(state, minConfidence) {
26
28
  if (!state) return false;
@@ -167,6 +169,158 @@ async function verifyBodyConsumed(session, bodyText, opts = {}) {
167
169
  }
168
170
  }
169
171
 
172
+ /**
173
+ * Confirm that a submitted prompt was accepted by the target TUI.
174
+ *
175
+ * Accepted signals short-circuit immediately:
176
+ * - the injected body is absent from the current screen/output tail;
177
+ * - the session transitions to working/thinking after the CR was sent.
178
+ *
179
+ * The only retryable failure is confirmed-unsubmitted: the body stayed visible
180
+ * for the whole bounded window. Ambiguous/no-observable cases are treated as
181
+ * success for back-compat, but marked `ambiguous` so callers can report that
182
+ * the success was optimistic. This keeps CR resend idempotent: resend only
183
+ * when `retryable === true`.
184
+ *
185
+ * @param {{ outputRing?: string[], backend?: string, cmuxWorkspaceId?: string|null }} session
186
+ * @param {string} bodyText
187
+ * @param {{ timeoutMs?: number, intervalMs?: number, tailBytes?: number, stripAnsi?: Function, readScreen?: Function, tailLines?: number, getState?: Function, submittedAtMs?: number, now?: Function, sleep?: Function }} [opts]
188
+ * @returns {Promise<{ accepted: boolean, retryable: boolean, waited_ms: number, reason?: string, ambiguous?: boolean, visibility?: object, state?: object }>}
189
+ */
190
+ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
191
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 1500;
192
+ const intervalMs = Number.isFinite(opts.intervalMs) ? opts.intervalMs : 50;
193
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
194
+ const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
195
+ const getState = typeof opts.getState === 'function' ? opts.getState : null;
196
+ const submittedAtMs = Number.isFinite(opts.submittedAtMs) ? opts.submittedAtMs : now();
197
+ const start = now();
198
+
199
+ let lastVisibility = null;
200
+ let everVisible = false;
201
+
202
+ while (true) {
203
+ const state = getState ? getState() : null;
204
+ if (isAcceptedSubmitState(state, submittedAtMs)) {
205
+ return {
206
+ accepted: true,
207
+ retryable: false,
208
+ waited_ms: now() - start,
209
+ reason: `state_${state.state}`,
210
+ state,
211
+ visibility: lastVisibility || undefined,
212
+ };
213
+ }
214
+
215
+ const visibility = observeBodyVisibility(session, bodyText, opts);
216
+ lastVisibility = visibility;
217
+ if (visibility.reason === 'empty_body') {
218
+ return {
219
+ accepted: true,
220
+ retryable: false,
221
+ waited_ms: now() - start,
222
+ reason: 'empty_body',
223
+ visibility,
224
+ };
225
+ }
226
+ if (!visibility.observable) {
227
+ return {
228
+ accepted: true,
229
+ retryable: false,
230
+ waited_ms: now() - start,
231
+ reason: visibility.reason || 'no_observable',
232
+ ambiguous: true,
233
+ visibility,
234
+ };
235
+ }
236
+ if (visibility.visible) {
237
+ everVisible = true;
238
+ } else {
239
+ return {
240
+ accepted: true,
241
+ retryable: false,
242
+ waited_ms: now() - start,
243
+ reason: everVisible ? 'body_consumed' : 'body_absent',
244
+ visibility,
245
+ };
246
+ }
247
+
248
+ if (now() - start >= timeoutMs) {
249
+ return {
250
+ accepted: false,
251
+ retryable: true,
252
+ waited_ms: now() - start,
253
+ reason: 'body_still_visible',
254
+ visibility,
255
+ state: state || undefined,
256
+ };
257
+ }
258
+
259
+ await sleep(intervalMs);
260
+ }
261
+ }
262
+
263
+ function isAcceptedSubmitState(state, submittedAtMs) {
264
+ if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
265
+ if (!Number.isFinite(submittedAtMs)) {
266
+ return true;
267
+ }
268
+ if (Number.isFinite(state.since_ms) && state.since_ms >= submittedAtMs) {
269
+ return true;
270
+ }
271
+ const lastOutputMs = state.last_output_at ? new Date(state.last_output_at).getTime() : NaN;
272
+ if (Number.isFinite(lastOutputMs) && lastOutputMs >= submittedAtMs) {
273
+ return true;
274
+ }
275
+ if (Number.isFinite(state.since_ms) && state.since_ms < submittedAtMs) {
276
+ return false;
277
+ }
278
+ return false;
279
+ }
280
+
281
+ function observeBodyVisibility(session, bodyText, opts = {}) {
282
+ const needle = normalize(bodyText);
283
+ if (!needle) {
284
+ return { observable: true, visible: false, source: 'body', reason: 'empty_body' };
285
+ }
286
+
287
+ const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
288
+ const screen = readCurrentScreen(session, opts);
289
+ if (typeof screen === 'string' && screen.length > 0) {
290
+ const haystack = normalize(stripAnsi(screen));
291
+ return {
292
+ observable: true,
293
+ visible: haystack.indexOf(needle) !== -1,
294
+ source: 'screen',
295
+ };
296
+ }
297
+
298
+ if (!session || !Array.isArray(session.outputRing)) {
299
+ return { observable: false, visible: false, source: 'none', reason: 'no_ring' };
300
+ }
301
+
302
+ const tailBytes = Number.isFinite(opts.tailBytes) ? opts.tailBytes : 8192;
303
+ const haystack = normalize(stripAnsi(readTail(session, tailBytes)));
304
+ return {
305
+ observable: true,
306
+ visible: haystack.indexOf(needle) !== -1,
307
+ source: 'output_ring',
308
+ };
309
+ }
310
+
311
+ function readCurrentScreen(session, opts = {}) {
312
+ const readScreen = typeof opts.readScreen === 'function'
313
+ ? opts.readScreen
314
+ : (session && session.backend === 'cmux' && session.cmuxWorkspaceId ? defaultReadScreen : null);
315
+ if (!readScreen || !session || !session.cmuxWorkspaceId) return null;
316
+ const tailLines = Number.isFinite(opts.tailLines) ? opts.tailLines : 30;
317
+ try {
318
+ return readScreen(session.cmuxWorkspaceId, tailLines);
319
+ } catch (_err) {
320
+ return null;
321
+ }
322
+ }
323
+
170
324
  function normalize(s) {
171
325
  return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
172
326
  }
@@ -266,10 +420,13 @@ function defaultReadScreen(workspaceId, lines) {
266
420
  module.exports = {
267
421
  awaitReplReady,
268
422
  verifyBodyConsumed,
423
+ confirmSubmitAccepted,
424
+ observeBodyVisibility,
269
425
  awaitPromptSymbol,
270
426
  defaultReadScreen,
271
427
  isReady,
272
428
  isFailed,
273
429
  READY_STATES,
274
430
  FAIL_STATES,
431
+ ACCEPTED_AFTER_SUBMIT_STATES,
275
432
  };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ function relayPeersFromEnv(env = process.env) {
6
+ return (env.TELEPTY_RELAY_PEERS || '').split(',').map(s => s.trim()).filter(Boolean);
7
+ }
8
+
9
+ function createPeerRelay(options) {
10
+ const {
11
+ relayPeers,
12
+ relaySeen = new Set(),
13
+ machineId,
14
+ expectedToken,
15
+ getPort,
16
+ fetchImpl = fetch,
17
+ randomUUID = crypto.randomUUID,
18
+ timeoutSignal = (ms) => AbortSignal.timeout(ms)
19
+ } = options;
20
+
21
+ return function relayToPeers(msg) {
22
+ if (relayPeers.length === 0) return;
23
+ if (!msg.message_id) msg.message_id = randomUUID();
24
+ if (relaySeen.has(msg.message_id)) return; // already relayed
25
+ relaySeen.add(msg.message_id);
26
+ // Prevent unbounded growth
27
+ if (relaySeen.size > 10000) {
28
+ const arr = [...relaySeen];
29
+ arr.splice(0, 5000);
30
+ relaySeen.clear();
31
+ arr.forEach(id => relaySeen.add(id));
32
+ }
33
+
34
+ msg.source_host = msg.source_host || machineId;
35
+ msg._relayed_from = machineId;
36
+
37
+ for (const peer of relayPeers) {
38
+ fetchImpl(`http://${peer}:${getPort()}/api/bus/publish`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json', 'x-telepty-token': expectedToken },
41
+ body: JSON.stringify(msg),
42
+ signal: timeoutSignal(3000)
43
+ }).catch(() => {}); // fire-and-forget
44
+ }
45
+ };
46
+ }
47
+
48
+ module.exports = {
49
+ createPeerRelay,
50
+ relayPeersFromEnv
51
+ };