@dmsdc-ai/aigentry-telepty 0.4.5 → 0.5.0

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 CHANGED
@@ -4,6 +4,60 @@ All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2026-05-30
8
+
9
+ ### Changed — Surface-ownership boundary (ADR 2026-05-30)
10
+
11
+ - **Orchestrator Workspace Host now owns surface close/focus; telepty no longer
12
+ actuates them.** Daemon focus actuation and `focusSurface` are removed
13
+ (focus moves to the orchestrator `wh_focus` path). `closeSurface` is gated
14
+ behind `AIGENTRY_TELEPTY_SELF_CLOSE_SURFACE` — a no-op on the managed path,
15
+ opt-in for standalone use. The `isSurfaceAlive` cmux liveness probe,
16
+ `decideSurfaceGc`, and session-side zombie reclaim are retained; telepty now
17
+ emits a `surface_orphaned` bus event for the orchestrator reconciler to
18
+ actuate the close. INV-17 / #486 preserved (probe unknown → skip gate intact).
19
+
20
+ ### Fixed — Lifecycle / bootstrap / skill-loading (tasks #35 #20 #32 #17 #29 #31 #19)
21
+
22
+ - **#35 / #20 — Codex skill loading.** Single-quote the `description` YAML
23
+ scalars in the bundled `SKILL.md` files so a Korean `키워드:` colon is no
24
+ longer parsed as a nested mapping; Codex now loads all bundled skills.
25
+ - **#32 — auto-report consolidation.** Three byte-identical auto-report
26
+ builders are consolidated into one provenance-tagged `fireAutoReport`;
27
+ sub-1s elapsed is relabelled `TASK_IDLE_UNCONFIRMED` so a stuck target is
28
+ never reported as `TASK_COMPLETE`.
29
+ - **#17 — surface-liveness GC.** New cmux surface-liveness probe
30
+ (`isSurfaceAlive`, INV-17 unknown-on-unreachable gate) plus `decideSurfaceGc`
31
+ and session-side SURFACE-GC reclaim of the headless zombie; the cli.js bridge
32
+ terminates (no reconnect) on a 1000 `Session destroyed` close.
33
+ - **#29 — Warp bootstrap.** Non-cmux (Warp) owner-alive optimistic bootstrap
34
+ floor (mirrors `runStartupBootstrapRestore`) plus `TERM_PROGRAM=WarpTerminal`
35
+ backend classification — fixes inject-queues-forever on Warp.
36
+ - **#31 — bootstrap timeout.** Actionable bootstrap-timeout
37
+ (`failBootstrapQueueOnTimeout` flushes the queue instead of hanging).
38
+ - **#19 — Windows codex PATH.** Verified already-fixed by `874d14a`
39
+ (no code change).
40
+
41
+ ### Security — Snyk cli.js posture (task #26)
42
+
43
+ - **Fixed — 3 path-traversal findings** (`fs.readFileSync`/`fs.readdirSync` on the
44
+ `--config=` / `--dir=` (`telepty session start`) and `--context` (`telepty
45
+ deliberate`) CLI path arguments). A new `sanitizePathArg()` rejects empty input,
46
+ null-byte injection, and `..` traversal segments, then normalizes via
47
+ `path.resolve()`; applied at each `fs.*` call site. Snyk path-traversal count is
48
+ now **0**.
49
+ - **Hardened — self-update default** (`runUpdateInstall`): the default
50
+ `npm install -g` now runs via `execFileSync` with a fixed argument array (no
51
+ shell), removing the default-path command-injection surface.
52
+ - **By-design waivers (operator-trusted, no privilege boundary; pre-existing
53
+ baseline, not introduced by this work):** two `IndirectCommandInjection`
54
+ findings remain and are accepted by design — (1) `pty.spawn` of the
55
+ operator/user-chosen CLI, which *is* the `telepty allow` feature; and (2) the
56
+ explicit `TELEPTY_UPDATE_COMMAND` self-update override, an operator-set env var
57
+ (setting it already implies shell control, so no boundary is crossed). Both are
58
+ annotated in code so they are not mistaken for an oversight. **Net: 0
59
+ newly-introduced and 0 non-by-design findings.**
60
+
7
61
  ## [0.4.5] - 2026-05-26
8
62
 
9
63
  ### Fixed — Stale-daemon, restart-recovery, force-bypass, codex matcher (tasks #469 #470 #471 #472)
package/cli.js CHANGED
@@ -5,7 +5,7 @@ const os = require('os');
5
5
  const fs = require('fs');
6
6
  const { constants: osConstants } = require('os');
7
7
  const WebSocket = require('ws');
8
- const { execSync, spawn } = require('child_process');
8
+ const { execSync, execFileSync, spawn } = require('child_process');
9
9
  const readline = require('readline');
10
10
  const prompts = require('prompts');
11
11
  const updateNotifier = require('update-notifier');
@@ -488,13 +488,51 @@ async function promptWithRecovery(promptConfig) {
488
488
  return response;
489
489
  }
490
490
 
491
+ // #26: validate a filesystem-path CLI argument before it reaches fs.*. Rejects empty input
492
+ // and null-byte injection, then normalizes to a canonical absolute path (path.resolve folds
493
+ // out any `..` traversal segments). telepty is a local CLI, so the path is operator-chosen and
494
+ // arbitrary locations are legitimate — this hardens against malformed/encoded traversal input
495
+ // rather than confining to a base directory.
496
+ function sanitizePathArg(input, label = 'path') {
497
+ if (typeof input !== 'string' || input.length === 0 || input.includes('\0') || input.includes('..')) {
498
+ throw new Error(`Invalid ${label} path argument`);
499
+ }
500
+ return path.resolve(input);
501
+ }
502
+
503
+ // #29: per-session backend classification, exposed for unit-testing. `env` and the kitty-socket
504
+ // probe are injected (findKittySocketCli is nested in main()), so a test can drive each branch
505
+ // without real env/sockets. Behavior matches the original inline ternary exactly.
506
+ function classifyBackend(env, findKitty) {
507
+ if (env.TERM_PROGRAM === 'WarpTerminal') return 'warp';
508
+ if (env.CMUX_WORKSPACE_ID) return 'cmux';
509
+ return findKitty() ? 'kitty' : 'pty';
510
+ }
511
+
512
+ // #17 (OQ-2): decide whether a daemon WS 'close' is the daemon's explicit session-destroy
513
+ // (code 1000 'Session destroyed') — in which case the bridge must terminate, not reconnect.
514
+ // Pure predicate, exposed for unit-testing; `reason` may be a Buffer (ws) or string.
515
+ function isDaemonDestroyClose(code, reason) {
516
+ const reasonText = reason ? reason.toString() : '';
517
+ return code === 1000 && reasonText === 'Session destroyed';
518
+ }
519
+
491
520
  function runUpdateInstall() {
492
521
  if (process.env.TELEPTY_SKIP_PACKAGE_UPDATE === '1') {
493
522
  return;
494
523
  }
495
524
 
496
- const updateCommand = process.env.TELEPTY_UPDATE_COMMAND || 'npm install -g @dmsdc-ai/aigentry-telepty@latest';
497
- execSync(updateCommand, { stdio: 'inherit' });
525
+ // #26: default self-update runs npm with a fixed arg array via execFileSync (no shell →
526
+ // no command injection). An explicit operator-supplied TELEPTY_UPDATE_COMMAND is a trusted
527
+ // env override (the operator deliberately chose to run a custom shell command — setting the
528
+ // env already implies shell control, so no privilege boundary is crossed). This execSync is
529
+ // accepted-by-design, consistent with the documented Snyk baseline waiver (CHANGELOG).
530
+ const override = process.env.TELEPTY_UPDATE_COMMAND;
531
+ if (override) {
532
+ execSync(override, { stdio: 'inherit' });
533
+ return;
534
+ }
535
+ execFileSync('npm', ['install', '-g', '@dmsdc-ai/aigentry-telepty@latest'], { stdio: 'inherit' });
498
536
  }
499
537
 
500
538
  async function repairLocalDaemon(options = {}) {
@@ -1082,7 +1120,9 @@ async function main() {
1082
1120
  return files.length > 0;
1083
1121
  } catch { return false; }
1084
1122
  }
1085
- const detectedBackend = process.env.CMUX_WORKSPACE_ID ? 'cmux' : (findKittySocketCli() ? 'kitty' : 'pty');
1123
+ // #29: classify Warp first (TERM_PROGRAM=WarpTerminal) for honest telemetry + a named
1124
+ // branch (Warp readiness uses the #29 owner-alive floor, not a cmux read-screen poll).
1125
+ const detectedBackend = classifyBackend(process.env, findKittySocketCli);
1086
1126
 
1087
1127
  // Register session with daemon
1088
1128
  const terminalProgram = detectTerminalProgram(process.env);
@@ -1182,6 +1222,10 @@ async function main() {
1182
1222
  // Windows: walk %PATHEXT% so bare names (`claude`, `codex`, `gemini`)
1183
1223
  // resolve to their npm-global `.cmd`/`.ps1` shims. POSIX: no-op. (#25)
1184
1224
  const resolvedCommand = resolveWindowsExecutable(command, process.env);
1225
+ // #26 (Snyk waiver, accepted-by-design): spawning the operator/user-chosen CLI IS the
1226
+ // `telepty allow` feature — `command` comes from the local CLI invocation, not an
1227
+ // untrusted boundary, so this is not an exploitable injection. Pre-existing baseline
1228
+ // finding; not fixable without removing `telepty allow`.
1185
1229
  child = pty.spawn(resolvedCommand, cmdArgs, {
1186
1230
  name: 'xterm-256color',
1187
1231
  cols: process.stdout.columns || 80,
@@ -1401,8 +1445,19 @@ async function main() {
1401
1445
  }
1402
1446
  });
1403
1447
 
1404
- daemonWs.on('close', () => {
1448
+ daemonWs.on('close', (code, reason) => {
1405
1449
  wsReady = false;
1450
+ // #17 (OQ-2): the daemon explicitly destroyed this session (manual kill or the
1451
+ // surface-gone GC) → close code 1000 'Session destroyed'. Terminate the bridge
1452
+ // instead of reconnecting; otherwise the orphan bridge re-registers and defeats the
1453
+ // GC. Daemon restarts / network drops use other codes (e.g. 1006) and still reconnect,
1454
+ // preserving the #487/#488 survive-and-reattach guarantee.
1455
+ if (isDaemonDestroyClose(code, reason)) {
1456
+ if (closeAllowSession()) {
1457
+ exitAllowSession(0);
1458
+ }
1459
+ return;
1460
+ }
1406
1461
  scheduleReconnect();
1407
1462
  });
1408
1463
 
@@ -1464,8 +1519,11 @@ async function main() {
1464
1519
  return true;
1465
1520
  }
1466
1521
 
1467
- function exitAllowSession(code) {
1468
- setTimeout(() => process.exit(code), 25);
1522
+ function exitAllowSession(code, exitCtx) {
1523
+ if (exitCtx) {
1524
+ logSessionDeath(exitCtx.exitCode, exitCtx.signal, exitCtx.duration);
1525
+ }
1526
+ process.exit(code);
1469
1527
  }
1470
1528
 
1471
1529
  // Intercept terminal title escape sequences and prefix with session ID
@@ -1562,14 +1620,25 @@ async function main() {
1562
1620
  }
1563
1621
  attachChildExitHandler();
1564
1622
 
1565
- for (const signalName of ['SIGTERM', 'SIGHUP', 'SIGQUIT']) {
1623
+ process.on('SIGHUP', () => {
1624
+ // Explicit no-op: decouples telepty-allow lifecycle from parent terminal app.
1625
+ // Node default for SIGHUP is process.exit; this handler overrides that default.
1626
+ // See ADR 2026-05-27-cmux-telepty-session-boundary §4.
1627
+ });
1628
+ process.stdout.on('error', () => {});
1629
+
1630
+ for (const signalName of ['SIGTERM', 'SIGQUIT']) {
1566
1631
  const handler = () => {
1567
1632
  closeAllowSession();
1568
1633
  try {
1569
1634
  child.kill(signalName);
1570
1635
  } catch {}
1571
1636
  const signalCode = osConstants.signals[signalName] || 1;
1572
- exitAllowSession(128 + signalCode);
1637
+ exitAllowSession(128 + signalCode, {
1638
+ exitCode: null,
1639
+ signal: signalName,
1640
+ duration: Date.now() - sessionStartTime
1641
+ });
1573
1642
  };
1574
1643
  allowSignalHandlers.set(signalName, handler);
1575
1644
  process.on(signalName, handler);
@@ -2525,11 +2594,12 @@ async function main() {
2525
2594
  // Discover project folders (subdirectories with .git)
2526
2595
  let projects;
2527
2596
  if (configPath) {
2528
- projects = JSON.parse(fs.readFileSync(configPath, 'utf8')).projects;
2597
+ projects = JSON.parse(fs.readFileSync(sanitizePathArg(configPath, 'config'), 'utf8')).projects;
2529
2598
  } else {
2530
- projects = fs.readdirSync(projectsDir, { withFileTypes: true })
2531
- .filter(d => d.isDirectory() && fs.existsSync(path.join(projectsDir, d.name, '.git')))
2532
- .map(d => ({ name: d.name, cwd: path.join(projectsDir, d.name) }));
2599
+ const resolvedDir = sanitizePathArg(projectsDir, 'dir');
2600
+ projects = fs.readdirSync(sanitizePathArg(projectsDir, 'dir'), { withFileTypes: true })
2601
+ .filter(d => d.isDirectory() && fs.existsSync(path.join(resolvedDir, d.name, '.git')))
2602
+ .map(d => ({ name: d.name, cwd: path.join(resolvedDir, d.name) }));
2533
2603
  }
2534
2604
 
2535
2605
  if (projects.length === 0) {
@@ -2836,7 +2906,7 @@ async function main() {
2836
2906
  let contextContent = null;
2837
2907
  if (contextPath) {
2838
2908
  try {
2839
- contextContent = fs.readFileSync(contextPath, 'utf-8');
2909
+ contextContent = fs.readFileSync(sanitizePathArg(contextPath, 'context'), 'utf-8');
2840
2910
  } catch (err) {
2841
2911
  console.error(`Failed to read context file: ${err.message}`);
2842
2912
  process.exit(1);
@@ -3448,4 +3518,15 @@ Discuss the following topic from your project's perspective. Engage with other s
3448
3518
  `);
3449
3519
  }
3450
3520
 
3451
- main();
3521
+ // Guard the entry point so a test can `require('./cli.js')` to reach the exported pure helpers
3522
+ // without dispatching the argv command. Behavior when run as the CLI is unchanged.
3523
+ if (require.main === module) {
3524
+ main();
3525
+ }
3526
+
3527
+ // Minimal test surface (no logic change) — pure decisions exposed for unit-testing.
3528
+ module.exports = {
3529
+ classifyBackend, // #29: TERM_PROGRAM/CMUX/kitty → backend string
3530
+ isDaemonDestroyClose, // #17 OQ-2: 1000 'Session destroyed' → terminate-not-reconnect
3531
+ sanitizePathArg, // #26: path-arg validation/normalization
3532
+ };
package/daemon.js CHANGED
@@ -27,10 +27,29 @@ const fs = require('fs');
27
27
  const SESSION_PERSIST_PATH = require('path').join(os.homedir(), '.config', 'aigentry-telepty', 'sessions.json');
28
28
  const SESSION_STALE_SECONDS = Math.max(1, Number(process.env.TELEPTY_SESSION_STALE_SECONDS || 60));
29
29
  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
+ }
30
47
  const DELIVERY_TIMEOUT_MS = Math.max(100, Number(process.env.TELEPTY_DELIVERY_TIMEOUT_MS || 5000));
31
48
  const HEALTH_POLL_MS = Math.max(100, Number(process.env.TELEPTY_HEALTH_POLL_MS || 10000));
32
49
  const IDLE_REAPER_POLL_MS = Math.max(100, Number(process.env.TELEPTY_IDLE_REAPER_POLL_MS || 60000));
33
50
  const BOOTSTRAP_READY_TIMEOUT_MS = Math.max(500, Number(process.env.TELEPTY_BOOTSTRAP_READY_TIMEOUT_MS || 30000));
51
+ // Surface FOCUS is owned by the orchestrator's Workspace Host adapter (`wh_focus`), per the
52
+ // 2026-05-30 surface-ownership verdict — telepty no longer foregrounds surfaces.
34
53
  const WRAPPED_SUBMIT_DELAY_MS = 500;
35
54
 
36
55
  // Session state machine manager — auto-detects session state from PTY output
@@ -73,30 +92,8 @@ sessionStateManager.onTransition((sessionId, from, to, detail) => {
73
92
  // Mark as idle-notified (but keep the entry — REPORT is still pending).
74
93
  // Entry is cleared when REPORT arrives (via inject endpoint) OR session dies.
75
94
  if (pendingReport.idleNotified) return; // only fire once
76
- pendingReport.idleNotified = true;
77
- pendingReport.idleAt = new Date().toISOString();
78
-
79
- const elapsed = ((Date.now() - new Date(pendingReport.injectedAt).getTime()) / 1000).toFixed(1);
80
-
81
- // New bus event: TASK_IDLE_NO_REPORT (richer observability)
82
- broadcastSessionEvent('TASK_IDLE_NO_REPORT', sessionId, session, {
83
- extra: {
84
- source: pendingReport.source,
85
- inject_id: pendingReport.injectId,
86
- elapsed_secs: Number(elapsed),
87
- injected_at: pendingReport.injectedAt
88
- }
89
- });
90
- console.log(`[ENFORCE-REPORT] ${sessionId} idle after ${elapsed}s — awaiting REPORT from ${pendingReport.source}`);
91
-
92
- // Legacy text-inject for back-compat (grandfather period 0.2.x)
93
- const reportMsg = `TASK_COMPLETE: ${sessionId} is now idle after processing inject (${elapsed}s)`;
94
- const srcId = resolveSessionAlias(pendingReport.source) || pendingReport.source;
95
- const srcSession = sessions[srcId];
96
- if (srcSession) {
97
- deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
98
- console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (legacy text-inject)`);
99
- }
95
+ // real-idle: the state manager observed a genuine busy→idle transition.
96
+ fireAutoReport(sessionId, session, pendingReport, 'real-idle');
100
97
  }
101
98
 
102
99
  // Fire TASK_DEAD_NO_REPORT when session dies with a pending report
@@ -260,15 +257,68 @@ const PORT = process.env.PORT || 3848;
260
257
  const HOST = process.env.HOST || '0.0.0.0';
261
258
  process.title = 'telepty-daemon';
262
259
 
263
- const daemonClaim = claimDaemonState({ host: HOST, port: Number(PORT), version: pkg.version });
264
- if (!daemonClaim.claimed) {
265
- const current = daemonClaim.current;
266
- console.log(`[DAEMON] telepty daemon already running (pid ${current.pid}, port ${current.port}). Exiting.`);
267
- process.exit(0);
260
+ // Singleton claim guarded so a test require neither exits (when a daemon is running) nor
261
+ // overwrites a live daemon's on-disk state claim (when one is). Only the real daemon claims.
262
+ if (require.main === module) {
263
+ const daemonClaim = claimDaemonState({ host: HOST, port: Number(PORT), version: pkg.version });
264
+ if (!daemonClaim.claimed) {
265
+ const current = daemonClaim.current;
266
+ console.log(`[DAEMON] telepty daemon already running (pid ${current.pid}, port ${current.port}). Exiting.`);
267
+ process.exit(0);
268
+ }
268
269
  }
269
270
 
270
271
  const pendingReports = {}; // {targetSessionId: {source, injectedAt, injectId}}
271
272
  const AUTO_REPORT_IDLE_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_IDLE_SECONDS) || 10;
273
+ // #32: a legacy auto-report can fire ~0.0s after the inject (silence-timeout / ready-signal)
274
+ // even when the inject never reached the target TUI — indistinguishable from a real completion
275
+ // by the recipient. Below this elapsed floor the idle is NOT trusted as a processed-inject
276
+ // completion; the text-inject is relabeled so a stuck/hung target is never reported as DONE.
277
+ const AUTO_REPORT_MIN_REAL_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_MIN_REAL_SECONDS) || 1.0;
278
+
279
+ // #32: single provenance-tagged auto-report path (was 3 byte-identical builders at the
280
+ // onTransition-idle / silence-timeout / ready-signal sites). `trigger` distinguishes the
281
+ // originating path; sub-floor elapsed is relabeled TASK_IDLE_UNCONFIRMED instead of TASK_COMPLETE.
282
+ // Caller is responsible for the `!pendingReport.idleNotified` once-only guard.
283
+ // `deps` is a thin DI seam (defaults = module globals) so the elapsed→label decision is
284
+ // unit-testable with an injected clock and a captured deliver fn — behavior is byte-identical
285
+ // for the production callers, which pass no deps.
286
+ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps = {}) {
287
+ const _now = deps.now || Date.now;
288
+ const _broadcast = deps.broadcastSessionEvent || broadcastSessionEvent;
289
+ const _resolveAlias = deps.resolveSessionAlias || resolveSessionAlias;
290
+ const _sessions = deps.sessions || sessions;
291
+ const _deliver = deps.deliverInjectionToSession || deliverInjectionToSession;
292
+
293
+ pendingReport.idleNotified = true;
294
+ pendingReport.idleAt = new Date(_now()).toISOString();
295
+ const elapsedNum = (_now() - new Date(pendingReport.injectedAt).getTime()) / 1000;
296
+ const elapsed = elapsedNum.toFixed(1);
297
+
298
+ // Richer bus event (observability) — now also carries the trigger provenance.
299
+ _broadcast('TASK_IDLE_NO_REPORT', targetId, targetSession, {
300
+ extra: {
301
+ source: pendingReport.source,
302
+ inject_id: pendingReport.injectId,
303
+ elapsed_secs: Number(elapsed),
304
+ injected_at: pendingReport.injectedAt,
305
+ trigger
306
+ }
307
+ });
308
+ console.log(`[ENFORCE-REPORT] ${targetId} idle after ${elapsed}s (trigger=${trigger}) — awaiting REPORT from ${pendingReport.source}`);
309
+
310
+ const srcId = _resolveAlias(pendingReport.source) || pendingReport.source;
311
+ const srcSession = _sessions[srcId];
312
+ if (!srcSession) return;
313
+
314
+ const confirmed = elapsedNum >= AUTO_REPORT_MIN_REAL_SECONDS;
315
+ const injTag = pendingReport.injectId ? ` inject=${pendingReport.injectId}` : '';
316
+ const reportMsg = confirmed
317
+ ? `TASK_COMPLETE: ${targetId} is now idle after processing inject (${elapsed}s, via ${trigger}${injTag})`
318
+ : `TASK_IDLE_UNCONFIRMED: ${targetId} signaled idle ${elapsed}s after inject (via ${trigger}${injTag}) — inject may NOT have been processed; verify before treating as done`;
319
+ _deliver(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
320
+ console.log(`[AUTO-REPORT] ${targetId} → ${srcId}: ${confirmed ? 'TASK_COMPLETE' : 'TASK_IDLE_UNCONFIRMED'} after ${elapsed}s (trigger=${trigger})`);
321
+ }
272
322
 
273
323
  const sessions = {};
274
324
  const handoffs = {};
@@ -657,30 +707,104 @@ function markBootstrapReady(sessionId, session, reason) {
657
707
  return true;
658
708
  }
659
709
 
660
- function scheduleBootstrapPromptPoll(sessionId, session) {
710
+ // #31 (AC-31.4): a session stuck past the bootstrap timeout must surface an ACTIONABLE error
711
+ // and stop queuing forever. Emit an actionable bootstrap_ready_timeout (hint + dropped count)
712
+ // and FLUSH the queue: submit ops resolve 504, inject ops fail — instead of silently
713
+ // accumulating until the process is killed. The caller can re-inject if the target recovers.
714
+ function failBootstrapQueueOnTimeout(sessionId, session, detail = {}) {
715
+ const queued = Array.isArray(session.bootstrapQueue) ? session.bootstrapQueue.length : 0;
716
+ emitBootstrapEvent('bootstrap_ready_timeout', sessionId, session, {
717
+ ...detail,
718
+ actionable: true,
719
+ queued_dropped: queued,
720
+ hint: `Session '${sessionId}' did not become inject-ready within ${BOOTSTRAP_READY_TIMEOUT_MS}ms — the target CLI (e.g. codex MCP init) may be hung. Inspect the surface and re-spawn if needed; queued injects were flushed.`
721
+ });
722
+ if (queued === 0) return;
723
+ const drained = session.bootstrapQueue.splice(0, queued);
724
+ for (const op of drained) {
725
+ if (op.type === 'submit') {
726
+ resolveBootstrapSubmit(op, {
727
+ status: 504,
728
+ body: {
729
+ error: `bootstrap_ready_timeout — '${sessionId}' not ready within ${BOOTSTRAP_READY_TIMEOUT_MS}ms`,
730
+ reason: detail.reason || 'bootstrap_ready_timeout',
731
+ strategy: 'none',
732
+ attempts: 0,
733
+ gated: true,
734
+ bootstrap_queued: false,
735
+ bootstrap: buildBootstrapBlock(session)
736
+ }
737
+ });
738
+ }
739
+ emitBootstrapEvent('bootstrap_queue_failed', sessionId, session, {
740
+ op_id: op.op_id,
741
+ operation: op.type,
742
+ code: 'BOOTSTRAP_READY_TIMEOUT',
743
+ error: `target '${sessionId}' not ready within ${BOOTSTRAP_READY_TIMEOUT_MS}ms`
744
+ });
745
+ }
746
+ }
747
+
748
+ // #29: pure decision for the non-cmux owner-alive optimistic floor — returns true iff the
749
+ // armed timer should flip bootstrapReady (not already ready; owner PID valid + alive; owner WS
750
+ // open). `deps` injects the liveness predicates for unit-testing (defaults = module globals),
751
+ // mirroring submit-gate.js's opts DI seam. No side effects — pure predicate.
752
+ function shouldApplyOwnerAliveFloor(session, deps = {}) {
753
+ const _isBootstrapReady = deps.isBootstrapReady || isBootstrapReady;
754
+ const _isProcessRunning = deps.isProcessRunning || isProcessRunning;
755
+ const _isOpenWebSocket = deps.isOpenWebSocket || isOpenWebSocket;
756
+ if (_isBootstrapReady(session)) return false; // bridge_ready already won
757
+ const ownerPid = Number(session.ownerPid);
758
+ if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !_isProcessRunning(ownerPid)) return false;
759
+ if (!_isOpenWebSocket(session.ownerWs)) return false;
760
+ return true;
761
+ }
762
+
763
+ function scheduleBootstrapPromptPoll(sessionId, session, deps = {}) {
764
+ const _setTimeout = deps.setTimeout || setTimeout;
661
765
  if (!session || !isBootstrapGatedSession(session) || isBootstrapReady(session)) return;
662
- if (session.bootstrapPromptPoll || session.backend !== 'cmux' || !session.cmuxWorkspaceId) return;
663
766
  if (!isOpenWebSocket(session.ownerWs)) return;
664
767
 
665
- session.bootstrapPromptPoll = submitGate.awaitPromptSymbol(session, {
666
- timeoutMs: BOOTSTRAP_READY_TIMEOUT_MS
667
- }).then((result) => {
668
- session.bootstrapPromptPoll = null;
669
- if (result && result.ready && isOpenWebSocket(session.ownerWs)) {
670
- markBootstrapReady(sessionId, session, 'cmux_prompt_symbol');
671
- } else if (result && result.reason) {
672
- emitBootstrapEvent('bootstrap_ready_timeout', sessionId, session, {
673
- reason: result.reason,
674
- waited_ms: result.waited_ms || 0
768
+ // cmux: rendered-screen prompt poll (the cmux-only read-screen primitive). Unchanged,
769
+ // including the #31 actionable bootstrap-timeout on miss/error.
770
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
771
+ if (session.bootstrapPromptPoll) return;
772
+ session.bootstrapPromptPoll = submitGate.awaitPromptSymbol(session, {
773
+ timeoutMs: BOOTSTRAP_READY_TIMEOUT_MS
774
+ }).then((result) => {
775
+ session.bootstrapPromptPoll = null;
776
+ if (result && result.ready && isOpenWebSocket(session.ownerWs)) {
777
+ markBootstrapReady(sessionId, session, 'cmux_prompt_symbol');
778
+ } else if (result && result.reason) {
779
+ failBootstrapQueueOnTimeout(sessionId, session, {
780
+ reason: result.reason,
781
+ waited_ms: result.waited_ms || 0
782
+ });
783
+ }
784
+ }).catch((error) => {
785
+ session.bootstrapPromptPoll = null;
786
+ failBootstrapQueueOnTimeout(sessionId, session, {
787
+ reason: 'prompt_symbol_error',
788
+ error: error.message || String(error)
675
789
  });
676
- }
677
- }).catch((error) => {
678
- session.bootstrapPromptPoll = null;
679
- emitBootstrapEvent('bootstrap_ready_timeout', sessionId, session, {
680
- reason: 'prompt_symbol_error',
681
- error: error.message || String(error)
682
790
  });
683
- });
791
+ return;
792
+ }
793
+
794
+ // #29: non-cmux (warp/pty/kitty) has NO rendered-screen read primitive, so the cmux poll
795
+ // would early-return and bootstrapReady could stay false forever (inject queues forever on
796
+ // Warp). The fast path stays the bridge 'ready' frame; this arms an idempotent owner-alive
797
+ // optimistic FLOOR — byte-for-byte the shipped runStartupBootstrapRestore precedent
798
+ // (markBootstrapReady('startup_owner_alive') ~daemon.js:2997) — applied at the LIVE owner
799
+ // WS-connect path. markBootstrapReady is idempotent, so a late timer after bridge_ready is a
800
+ // harmless no-op. submit-gate.js read-screen guard stays cmux-only (untouched).
801
+ if (session.bootstrapOptimisticTimer) return;
802
+ session.bootstrapOptimisticTimer = _setTimeout(() => {
803
+ session.bootstrapOptimisticTimer = null;
804
+ if (!shouldApplyOwnerAliveFloor(session, deps)) return;
805
+ markBootstrapReady(sessionId, session, 'owner_alive');
806
+ console.log(`[BOOTSTRAP] Optimistic ready for ${sessionId} (ownerPid=${Number(session.ownerPid)}, backend=${session.backend || 'unknown'})`);
807
+ }, BOOTSTRAP_READY_TIMEOUT_MS);
684
808
  }
685
809
 
686
810
  async function waitForBootstrapSubmit(op, session, timeoutMs) {
@@ -1219,6 +1343,11 @@ async function teardownSessionById(id, options = {}) {
1219
1343
  try { session.ownerWs.close(1000, 'Session destroyed'); } catch {}
1220
1344
  }
1221
1345
 
1346
+ // Surface close is the orchestrator's job (Workspace Host adapter), per the 2026-05-30
1347
+ // verdict — this call is a NO-OP on the managed path. It actuates only for a standalone
1348
+ // telepty that opted in via AIGENTRY_TELEPTY_SELF_CLOSE_SURFACE=1 (gate lives in closeSurface).
1349
+ try { terminalBackend.closeSurface(session); } catch {}
1350
+
1222
1351
  delete sessions[id];
1223
1352
  sessionStateManager.unregister(id);
1224
1353
  try { mailbox.purge(id); } catch {}
@@ -2540,6 +2669,11 @@ app.delete('/api/sessions/:id', (req, res) => {
2540
2669
  } else if (session.ptyProcess) {
2541
2670
  session.ptyProcess.kill();
2542
2671
  }
2672
+ // Surface close is the orchestrator's job (Workspace Host adapter), per the 2026-05-30
2673
+ // verdict — NO-OP on the managed path. The orchestrator's session-cleanup.sh closes the
2674
+ // surface on this normal CLI-exit (CLEANUP_REQUEST→wh_close). Actuates only for a standalone
2675
+ // telepty with AIGENTRY_TELEPTY_SELF_CLOSE_SURFACE=1 (gate lives in closeSurface).
2676
+ try { terminalBackend.closeSurface(session); } catch {}
2543
2677
  delete sessions[id];
2544
2678
  sessionStateManager.unregister(id);
2545
2679
  try { mailbox.purge(id); } catch {}
@@ -2883,10 +3017,16 @@ app.patch('/api/threads/:id', (req, res) => {
2883
3017
  res.json({ success: true, thread_id: thread.id, status: thread.status });
2884
3018
  });
2885
3019
 
2886
- const server = app.listen(PORT, HOST, () => {
2887
- console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
2888
- runStartupBootstrapRestore();
2889
- });
3020
+ // Bind the port only under the require.main guard — a test can `require('./daemon.js')` to
3021
+ // reach the exported decision functions without starting the daemon. `server` stays undefined
3022
+ // when required, so the WS upgrade/error handlers below attach only when run as the daemon.
3023
+ let server;
3024
+ if (require.main === module) {
3025
+ server = app.listen(PORT, HOST, () => {
3026
+ console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
3027
+ runStartupBootstrapRestore();
3028
+ });
3029
+ }
2890
3030
 
2891
3031
  // #470 (0.4.5): when the daemon restarts under existing telepty allow workers,
2892
3032
  // persisted sessions are restored at daemon.js:1244 but bootstrapReady stays
@@ -2961,12 +3101,15 @@ const mailboxDelivery = new DeliveryEngine(mailbox, {
2961
3101
  }
2962
3102
  },
2963
3103
  });
2964
- // Startup sweep: break stale lock files before starting delivery
2965
- const staleBroken = mailbox.breakStaleLocks();
2966
- if (staleBroken > 0) {
2967
- console.log(`[MAILBOX] Startup sweep: broke ${staleBroken} stale lock(s)`);
3104
+ // Startup sweep: break stale lock files before starting delivery. Guarded so a test require
3105
+ // of this module neither breaks on-disk locks nor starts the delivery loop.
3106
+ if (require.main === module) {
3107
+ const staleBroken = mailbox.breakStaleLocks();
3108
+ if (staleBroken > 0) {
3109
+ console.log(`[MAILBOX] Startup sweep: broke ${staleBroken} stale lock(s)`);
3110
+ }
3111
+ mailboxDelivery.start();
2968
3112
  }
2969
- mailboxDelivery.start();
2970
3113
 
2971
3114
  const IDLE_THRESHOLD_SECONDS = 60;
2972
3115
  async function runIdleTtlSweep(nowMs = Date.now()) {
@@ -3000,13 +3143,14 @@ async function runIdleTtlSweep(nowMs = Date.now()) {
3000
3143
  }
3001
3144
  }
3002
3145
 
3003
- setInterval(() => {
3146
+ // Guarded: timers must not run (and keep the event loop alive) on a test require.
3147
+ if (require.main === module) setInterval(() => {
3004
3148
  runIdleTtlSweep().catch((err) => {
3005
3149
  console.error(`[REAPER] Idle TTL sweep failed: ${err.message}`);
3006
3150
  });
3007
3151
  }, IDLE_REAPER_POLL_MS);
3008
3152
 
3009
- setInterval(() => {
3153
+ if (require.main === module) setInterval(() => {
3010
3154
  const now = Date.now();
3011
3155
  for (const [id, session] of Object.entries(sessions)) {
3012
3156
  const idleSeconds = session.lastActivityAt ? Math.floor((now - new Date(session.lastActivityAt).getTime()) / 1000) : null;
@@ -3051,25 +3195,8 @@ setInterval(() => {
3051
3195
  // Skip if onTransition already fired the idle notification.
3052
3196
  const pendingRpt = pendingReports[id];
3053
3197
  if (pendingRpt && !pendingRpt.idleNotified && session.type !== 'wrapped' && idleSeconds !== null && idleSeconds >= AUTO_REPORT_IDLE_SECONDS) {
3054
- pendingRpt.idleNotified = true;
3055
- pendingRpt.idleAt = new Date().toISOString();
3056
- const elapsed = ((Date.now() - new Date(pendingRpt.injectedAt).getTime()) / 1000).toFixed(1);
3057
- // Fire new bus event + legacy text-inject
3058
- broadcastSessionEvent('TASK_IDLE_NO_REPORT', id, session, {
3059
- extra: {
3060
- source: pendingRpt.source,
3061
- inject_id: pendingRpt.injectId,
3062
- elapsed_secs: Number(elapsed),
3063
- injected_at: pendingRpt.injectedAt
3064
- }
3065
- });
3066
- const reportMsg = `TASK_COMPLETE: ${id} is now idle after processing inject (${elapsed}s)`;
3067
- const srcId = resolveSessionAlias(pendingRpt.source) || pendingRpt.source;
3068
- const srcSession = sessions[srcId];
3069
- if (srcSession) {
3070
- deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
3071
- console.log(`[AUTO-REPORT] ${id} → ${srcId}: idle after ${elapsed}s (threshold)`);
3072
- }
3198
+ // silence-timeout: session has been quiet past the threshold without a REPORT.
3199
+ fireAutoReport(id, session, pendingRpt, 'silence-timeout');
3073
3200
  }
3074
3201
  // Reset idle flag when activity resumes
3075
3202
  if (idleSeconds !== null && idleSeconds < IDLE_THRESHOLD_SECONDS) {
@@ -3099,6 +3226,49 @@ setInterval(() => {
3099
3226
  }
3100
3227
  }
3101
3228
 
3229
+ // #17: CONNECTED-zombie GC via cmux surface-liveness. Post-08cd796 a wrapped cmux bridge
3230
+ // SURVIVES its terminal app's death, so ownerWs stays OPEN and the 300s disconnect-GC
3231
+ // (below) never fires. If the workspace was EXPLICITLY closed while cmux itself is alive,
3232
+ // the session is a headless zombie → reclaim it after a grace window. INV-17: isSurfaceAlive
3233
+ // returns 'unknown' when cmux is unreachable (app-quit/restart vanishes ALL surfaces at
3234
+ // once), so this GCs NOTHING in that case — preserving the #486/#488 survival guarantee.
3235
+ if (session.type === 'wrapped' && session.backend === 'cmux' && session.cmuxWorkspaceId
3236
+ && isOpenWebSocket(session.ownerWs)) {
3237
+ const liveness = terminalBackend.isSurfaceAlive(session);
3238
+ const gcAction = decideSurfaceGc(liveness, session, now);
3239
+ if (gcAction === 'mark') {
3240
+ session.surfaceGoneAt = new Date().toISOString();
3241
+ console.log(`[SURFACE-GC] cmux workspace gone for ${id} (${session.cmuxWorkspaceId}) — ${SURFACE_ORPHAN_SECONDS}s grace started`);
3242
+ } else if (gcAction === 'reclaim') {
3243
+ const goneSeconds = Math.floor((now - new Date(session.surfaceGoneAt).getTime()) / 1000);
3244
+ console.log(`[SURFACE-GC] Reclaiming headless cmux zombie ${id} after ${goneSeconds}s surface-gone`);
3245
+ emitSessionLifecycleEvent('session_cleanup', id, session, {
3246
+ reason: 'SURFACE_GONE',
3247
+ surfaceGoneSeconds: goneSeconds
3248
+ });
3249
+ // Surface-ownership verdict (2026-05-30): telepty reclaims the zombie SESSION but does
3250
+ // NOT close the surface. Emit the orphan SIGNAL so the orchestrator's reconciler closes
3251
+ // the surface (wh_close). telepty signals; the orchestrator actuates.
3252
+ broadcastSessionEvent('surface_orphaned', id, session, {
3253
+ extra: {
3254
+ sid: id,
3255
+ backend: session.backend || null,
3256
+ cmuxWorkspaceId: session.cmuxWorkspaceId || null,
3257
+ surfaceGoneSeconds: goneSeconds,
3258
+ livenessVerdict: liveness
3259
+ }
3260
+ });
3261
+ teardownSessionById(id, { force: true, timeoutMs: 5000, reason: 'SURFACE_GONE', source: 'surface_gc' })
3262
+ .catch(err => console.error(`[SURFACE-GC] teardown failed for ${id}: ${err.message}`));
3263
+ continue; // being destroyed — skip remaining checks for this session this tick
3264
+ } else if (gcAction === 'recover') {
3265
+ // Recovery within the grace window (mirrors the aterm socket-recover above).
3266
+ console.log(`[SURFACE-GC] cmux workspace recovered for ${id} — clearing grace window`);
3267
+ session.surfaceGoneAt = null;
3268
+ }
3269
+ // 'skip' (incl. 'unknown' — INV-17 gate) → leave surfaceGoneAt unchanged, GC nothing.
3270
+ }
3271
+
3102
3272
  if (healthStatus === 'STALE' && !session._staleEmitted) {
3103
3273
  session._staleEmitted = true;
3104
3274
  emitSessionLifecycleEvent('session_stale', id, session, {
@@ -3125,7 +3295,7 @@ setInterval(() => {
3125
3295
  }
3126
3296
  }, HEALTH_POLL_MS);
3127
3297
 
3128
- server.on('error', async (error) => {
3298
+ if (server) server.on('error', async (error) => {
3129
3299
  clearDaemonState(process.pid);
3130
3300
 
3131
3301
  if (error && error.code === 'EADDRINUSE') {
@@ -3269,25 +3439,8 @@ wss.on('connection', (ws, req) => {
3269
3439
  // fired (pendingReports[sessionId].idleNotified === true).
3270
3440
  const pendingReport = pendingReports[sessionId];
3271
3441
  if (pendingReport && !pendingReport.idleNotified) {
3272
- pendingReport.idleNotified = true;
3273
- pendingReport.idleAt = new Date().toISOString();
3274
- const elapsed = ((Date.now() - new Date(pendingReport.injectedAt).getTime()) / 1000).toFixed(1);
3275
- // Fire new bus event + legacy text-inject
3276
- broadcastSessionEvent('TASK_IDLE_NO_REPORT', sessionId, activeSession, {
3277
- extra: {
3278
- source: pendingReport.source,
3279
- inject_id: pendingReport.injectId,
3280
- elapsed_secs: Number(elapsed),
3281
- injected_at: pendingReport.injectedAt
3282
- }
3283
- });
3284
- const reportMsg = `TASK_COMPLETE: ${sessionId} is now idle after processing inject (${elapsed}s)`;
3285
- const srcId = resolveSessionAlias(pendingReport.source) || pendingReport.source;
3286
- const srcSession = sessions[srcId];
3287
- if (srcSession) {
3288
- deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
3289
- console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (ready signal)`);
3290
- }
3442
+ // ready-signal: cli.js bridge emitted a 'ready' WS frame.
3443
+ fireAutoReport(sessionId, activeSession, pendingReport, 'ready-signal');
3291
3444
  }
3292
3445
  }
3293
3446
  } else {
@@ -3316,6 +3469,13 @@ wss.on('connection', (ws, req) => {
3316
3469
  activeSession.clients.delete(ws);
3317
3470
  if (activeSession.type === 'wrapped' && ws === activeSession.ownerWs) {
3318
3471
  activeSession.ownerWs = null;
3472
+ // #29: cancel any pending owner-alive optimistic timer — the owner is gone, so the
3473
+ // floor must not flip a disconnected session ready (hygiene; the timer also re-guards
3474
+ // on isOpenWebSocket, but clearing avoids a dangling handle).
3475
+ if (activeSession.bootstrapOptimisticTimer) {
3476
+ clearTimeout(activeSession.bootstrapOptimisticTimer);
3477
+ activeSession.bootstrapOptimisticTimer = null;
3478
+ }
3319
3479
  markSessionDisconnected(activeSession);
3320
3480
  console.log(`[WS] Wrap owner disconnected from session ${sessionId} (Total: ${activeSession.clients.size})`);
3321
3481
  emitSessionLifecycleEvent('session_disconnect', sessionId, activeSession, {
@@ -3376,7 +3536,7 @@ busWss.on('connection', (ws, req) => {
3376
3536
  });
3377
3537
  });
3378
3538
 
3379
- server.on('upgrade', (req, socket, head) => {
3539
+ if (server) server.on('upgrade', (req, socket, head) => {
3380
3540
  const url = new URL(req.url, 'http://' + req.headers.host);
3381
3541
  const token = url.searchParams.get('token');
3382
3542
 
@@ -3408,8 +3568,23 @@ function shutdown(code) {
3408
3568
  process.exit(code);
3409
3569
  }
3410
3570
 
3411
- process.on('SIGINT', () => shutdown(0));
3412
- process.on('SIGTERM', () => shutdown(0));
3413
- process.on('exit', () => {
3414
- clearDaemonState(process.pid);
3415
- });
3571
+ // Daemon-lifecycle signal/exit handlers — only when run as the daemon, so a test require does
3572
+ // not register them (and does not clear on-disk daemon-state at the test process's exit).
3573
+ if (require.main === module) {
3574
+ process.on('SIGINT', () => shutdown(0));
3575
+ process.on('SIGTERM', () => shutdown(0));
3576
+ process.on('exit', () => {
3577
+ clearDaemonState(process.pid);
3578
+ });
3579
+ }
3580
+
3581
+ // Minimal test surface (no logic change): expose the pure lifecycle decisions + DI-seamed
3582
+ // helpers so the daemon ACs are unit-testable without starting the daemon. Behavior for the
3583
+ // production call sites is unchanged. NOT a public API — internal/test use only.
3584
+ module.exports = {
3585
+ fireAutoReport, // #32: provenance-tagged auto-report (deps DI: now/deliver/...)
3586
+ failBootstrapQueueOnTimeout, // #31: actionable bootstrap-timeout queue flush
3587
+ shouldApplyOwnerAliveFloor, // #29: owner-alive optimistic-floor decision (deps DI: isProcessRunning/...)
3588
+ scheduleBootstrapPromptPoll, // #29: arms the floor timer (deps DI: setTimeout/...)
3589
+ decideSurfaceGc, // #17: surface-liveness verdict→action (incl. INV-17 unknown→skip)
3590
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty
3
- description: Overview of telepty — PTY multiplexer for AI session orchestration. Use this when user asks "what is telepty" or needs a getting-started guide. 키워드: 텔레프티, 텔레프티 개요, 시작하기, telepty 소개, 사용법, 가이드
3
+ description: 'Overview of telepty — PTY multiplexer for AI session orchestration. Use this when user asks "what is telepty" or needs a getting-started guide. 키워드: 텔레프티, 텔레프티 개요, 시작하기, telepty 소개, 사용법, 가이드'
4
4
  ---
5
5
 
6
6
  # telepty — Overview
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-allow
3
- description: Create telepty sessions by wrapping CLI processes. Covers the allow/enable/wrap command for session creation and PTY management. 키워드: 세션 생성, 세션 래핑, CLI 래핑, allow, 세션 만들기, PTY
3
+ description: 'Create telepty sessions by wrapping CLI processes. Covers the allow/enable/wrap command for session creation and PTY management. 키워드: 세션 생성, 세션 래핑, CLI 래핑, allow, 세션 만들기, PTY'
4
4
  ---
5
5
 
6
6
  # telepty-allow — Create and Manage Sessions
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-attach
3
- description: Attach interactively to a telepty session to view output and send input in real-time. 키워드: 세션 접속, 세션 연결, 세션 들어가기, 어태치, attach, 실시간 보기
3
+ description: 'Attach interactively to a telepty session to view output and send input in real-time. 키워드: 세션 접속, 세션 연결, 세션 들어가기, 어태치, attach, 실시간 보기'
4
4
  ---
5
5
 
6
6
  # telepty-attach — Interactive Session Attachment
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-broadcast
3
- description: Send messages to multiple telepty sessions at once. Covers broadcast (all sessions) and multicast (selected targets). 키워드: 전체 공지, 모든 세션에, 일괄 전송, 브로드캐스트, 멀티캐스트, 다중 주입
3
+ description: 'Send messages to multiple telepty sessions at once. Covers broadcast (all sessions) and multicast (selected targets). 키워드: 전체 공지, 모든 세션에, 일괄 전송, 브로드캐스트, 멀티캐스트, 다중 주입'
4
4
  ---
5
5
 
6
6
  # telepty-broadcast — Multi-Target Messaging
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-daemon
3
- description: Manage the telepty daemon — start, stop, repair, update, and TUI dashboard. Use when daemon is broken or needs maintenance. 키워드: 데몬 시작, 데몬 재시작, 데몬 종료, TUI, 대시보드, 데몬 상태, daemon
3
+ description: 'Manage the telepty daemon — start, stop, repair, update, and TUI dashboard. Use when daemon is broken or needs maintenance. 키워드: 데몬 시작, 데몬 재시작, 데몬 종료, TUI, 대시보드, 데몬 상태, daemon'
4
4
  ---
5
5
 
6
6
  # telepty-daemon — Daemon Management
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-inject
3
- description: Send messages, commands, and keystrokes to telepty sessions. Covers inject, enter, send-key, and reply commands. 키워드: 세션에 메시지, 메시지 보내기, 전달, 주입, inject, 응답, 답장, 키 입력
3
+ description: 'Send messages, commands, and keystrokes to telepty sessions. Covers inject, enter, send-key, and reply commands. 키워드: 세션에 메시지, 메시지 보내기, 전달, 주입, inject, 응답, 답장, 키 입력'
4
4
  ---
5
5
 
6
6
  # telepty-inject — Send Messages to Sessions
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-list
3
- description: Discover telepty sessions, check status and health. Covers list, session info, and status commands. 키워드: 세션 목록, 활성 세션, 세션 조회, 세션 상태, 세션 확인, 리스트
3
+ description: 'Discover telepty sessions, check status and health. Covers list, session info, and status commands. 키워드: 세션 목록, 활성 세션, 세션 조회, 세션 상태, 세션 확인, 리스트'
4
4
  ---
5
5
 
6
6
  # telepty-list — Discover Sessions and Check Status
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-listen
3
- description: Monitor telepty events and read session screen output. Covers listen (event bus) and read-screen commands. 키워드: 이벤트 모니터, 화면 확인, 화면 읽기, 이벤트 스트림, listen, read-screen, 모니터링
3
+ description: 'Monitor telepty events and read session screen output. Covers listen (event bus) and read-screen commands. 키워드: 이벤트 모니터, 화면 확인, 화면 읽기, 이벤트 스트림, listen, read-screen, 모니터링'
4
4
  ---
5
5
 
6
6
  # telepty-listen — Event Monitoring and Screen Reading
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-rename
3
- description: Rename, delete, and clean up telepty sessions. Session lifecycle management. 키워드: 세션 이름 변경, 세션 삭제, 세션 정리, 세션 청소, rename, 라이프사이클
3
+ description: 'Rename, delete, and clean up telepty sessions. Session lifecycle management. 키워드: 세션 이름 변경, 세션 삭제, 세션 정리, 세션 청소, rename, 라이프사이클'
4
4
  ---
5
5
 
6
6
  # telepty-rename — Session Lifecycle Management
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: telepty-session
3
- description: Multi-session orchestration — start multiple sessions at once and arrange terminal layouts. Covers session start and layout commands. 키워드: 멀티 세션 시작, 다중 세션, 세션 레이아웃, 세션 일괄 시작, 멀티 시작, layout
3
+ description: 'Multi-session orchestration — start multiple sessions at once and arrange terminal layouts. Covers session start and layout commands. 키워드: 멀티 세션 시작, 다중 세션, 세션 레이아웃, 세션 일괄 시작, 멀티 시작, layout'
4
4
  ---
5
5
 
6
6
  # telepty-session — Multi-Session Orchestration
@@ -1,6 +1,15 @@
1
1
  'use strict';
2
2
 
3
- const { execSync } = require('child_process');
3
+ const { execSync, execFileSync } = require('child_process');
4
+
5
+ // #17/#30/#31: validate a cmux id before it flows into a cmux invocation. cmux ids are
6
+ // UUIDs, typed short-refs (workspace:N / surface:N / pane:N / window:N / tab:N), or numeric
7
+ // indexes. New surface-lifecycle methods shell out via execFileSync (arg arrays, no shell),
8
+ // and this allowlist additionally rejects anything malformed.
9
+ const CMUX_REF_RE = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|(?:workspace|surface|pane|window|tab):\d+|\d+)$/;
10
+ function isCmuxRef(id) {
11
+ return typeof id === 'string' && CMUX_REF_RE.test(id);
12
+ }
4
13
 
5
14
  // Detect terminal environment at daemon level
6
15
  function detectTerminal() {
@@ -126,6 +135,63 @@ function clearCache() {
126
135
  lastCacheRefresh = 0;
127
136
  }
128
137
 
138
+ // #17/#30: liveness of a session's cmux workspace surface (forced, cache-bypassing).
139
+ // 'unknown' — non-cmux backend, missing/invalid id, OR cmux itself unreachable. The last
140
+ // case is the INV-17 gate: a cmux app-quit/restart makes ALL surfaces vanish at
141
+ // once, so an unreachable cmux means INDETERMINATE → caller must PRESERVE (GC
142
+ // nothing), preserving the #486/#488 survival guarantee.
143
+ // 'gone' — cmux reachable but this session's workspace UUID is absent from the live list
144
+ // (an explicit single-workspace close while the bridge survived).
145
+ // 'alive' — cmux reachable and the workspace is present.
146
+ function isSurfaceAlive(session) {
147
+ if (!session || session.backend !== 'cmux') return 'unknown';
148
+ const wid = session.cmuxWorkspaceId;
149
+ if (!isCmuxRef(wid)) return 'unknown';
150
+ // INV-17 gate: cmux unreachable → INDETERMINATE.
151
+ try {
152
+ execFileSync('cmux', ['ping'], { timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
153
+ } catch {
154
+ return 'unknown';
155
+ }
156
+ // Enumerate live workspace UUIDs. A failure here (pinged OK but list errored) is still
157
+ // treated as INDETERMINATE rather than 'gone', so a transient cmux hiccup never GCs.
158
+ let listing;
159
+ try {
160
+ listing = execFileSync('cmux', ['--id-format', 'uuids', 'list-workspaces'], {
161
+ timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
162
+ });
163
+ } catch {
164
+ return 'unknown';
165
+ }
166
+ const needle = String(wid).toLowerCase();
167
+ const present = listing.split('\n').some(line => line.toLowerCase().includes(needle));
168
+ return present ? 'alive' : 'gone';
169
+ }
170
+
171
+ // Terminal-surface CLOSE is owned by the orchestrator's Workspace Host adapter
172
+ // (workspace-host.sh `wh_close`), per the 2026-05-30 surface-ownership verdict — telepty
173
+ // probes liveness and emits `surface_orphaned`, it does not actuate surface close on the
174
+ // managed path. This function is a STANDALONE-ONLY fallback (orchestrator-absent): it stays a
175
+ // no-op unless AIGENTRY_TELEPTY_SELF_CLOSE_SURFACE=1, so a single-installed telepty can opt in
176
+ // to closing its own orphan tab. Default off → no managed-path double-close.
177
+ function closeSurface(session) {
178
+ if (process.env.AIGENTRY_TELEPTY_SELF_CLOSE_SURFACE !== '1') return true; // managed default: no-op
179
+ if (!session || session.backend !== 'cmux') return true; // kitty/headless: no-op
180
+ const wid = session.cmuxWorkspaceId;
181
+ if (!isCmuxRef(wid)) return true; // nothing addressable to close
182
+ try {
183
+ execFileSync('cmux', ['close-workspace', '--workspace', String(wid)], {
184
+ timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
185
+ });
186
+ console.log(`[BACKEND] cmux close-workspace ${wid} (self-close opt-in)`);
187
+ return true;
188
+ } catch (err) {
189
+ // Already-gone / transient: harmless no-op, never blocks the destroy.
190
+ console.log(`[BACKEND] cmux close-workspace ${wid} no-op (${err.message})`);
191
+ return true;
192
+ }
193
+ }
194
+
129
195
  module.exports = {
130
196
  detectTerminal,
131
197
  findSurface,
@@ -133,5 +199,7 @@ module.exports = {
133
199
  cmuxSendEnter,
134
200
  refreshSurfaceCache,
135
201
  invalidateCache,
136
- clearCache
202
+ clearCache,
203
+ isSurfaceAlive,
204
+ closeSurface
137
205
  };