@dmsdc-ai/aigentry-telepty 0.4.5 → 0.5.1
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 +73 -0
- package/cli.js +100 -15
- package/daemon.js +281 -104
- package/package.json +1 -1
- package/skills/telepty/SKILL.md +1 -1
- package/skills/telepty-allow/SKILL.md +1 -1
- package/skills/telepty-attach/SKILL.md +1 -1
- package/skills/telepty-broadcast/SKILL.md +1 -1
- package/skills/telepty-daemon/SKILL.md +1 -1
- package/skills/telepty-inject/SKILL.md +1 -1
- package/skills/telepty-list/SKILL.md +1 -1
- package/skills/telepty-listen/SKILL.md +1 -1
- package/skills/telepty-rename/SKILL.md +1 -1
- package/skills/telepty-session/SKILL.md +1 -1
- package/terminal-backend.js +70 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,79 @@ All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.1] - 2026-05-30
|
|
8
|
+
|
|
9
|
+
### Fixed — daemon never started (CRITICAL, regresses 0.5.0)
|
|
10
|
+
|
|
11
|
+
- **The daemon failed to start for all users on 0.5.0.** `daemon.js` guarded
|
|
12
|
+
`app.listen()` behind `require.main === module` (added in 0.5.0 for test
|
|
13
|
+
isolation so `require('./daemon.js')` is side-effect-free). But the production
|
|
14
|
+
CLI launches the daemon via `require('./daemon.js')` (`telepty daemon`, and the
|
|
15
|
+
auto-start spawns `node cli.js daemon`), so `require.main` is always `cli.js` —
|
|
16
|
+
`app.listen` never ran, the process exited 0 right after `[PERSIST] Restored
|
|
17
|
+
session … (awaiting reconnect)`, and every CLI reported `Daemon restart failed
|
|
18
|
+
after 3 attempts` / `fetch failed`. **Fix:** `cli.js` sets
|
|
19
|
+
`AIGENTRY_TELEPTY_DAEMON_MAIN=1` before requiring `daemon.js`; the guard is now
|
|
20
|
+
`require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1'`.
|
|
21
|
+
Tests that `require()` daemon.js without the env stay side-effect-free. (telepty#15)
|
|
22
|
+
- Follow-up (tracked): a daemon-launch integration smoke test — assert the HTTP
|
|
23
|
+
endpoint responds when the daemon is launched via the real CLI path. The unit-test
|
|
24
|
+
guard masked this regression; an integration test would have caught it.
|
|
25
|
+
|
|
26
|
+
## [0.5.0] - 2026-05-30
|
|
27
|
+
|
|
28
|
+
### Changed — Surface-ownership boundary (ADR 2026-05-30)
|
|
29
|
+
|
|
30
|
+
- **Orchestrator Workspace Host now owns surface close/focus; telepty no longer
|
|
31
|
+
actuates them.** Daemon focus actuation and `focusSurface` are removed
|
|
32
|
+
(focus moves to the orchestrator `wh_focus` path). `closeSurface` is gated
|
|
33
|
+
behind `AIGENTRY_TELEPTY_SELF_CLOSE_SURFACE` — a no-op on the managed path,
|
|
34
|
+
opt-in for standalone use. The `isSurfaceAlive` cmux liveness probe,
|
|
35
|
+
`decideSurfaceGc`, and session-side zombie reclaim are retained; telepty now
|
|
36
|
+
emits a `surface_orphaned` bus event for the orchestrator reconciler to
|
|
37
|
+
actuate the close. INV-17 / #486 preserved (probe unknown → skip gate intact).
|
|
38
|
+
|
|
39
|
+
### Fixed — Lifecycle / bootstrap / skill-loading (tasks #35 #20 #32 #17 #29 #31 #19)
|
|
40
|
+
|
|
41
|
+
- **#35 / #20 — Codex skill loading.** Single-quote the `description` YAML
|
|
42
|
+
scalars in the bundled `SKILL.md` files so a Korean `키워드:` colon is no
|
|
43
|
+
longer parsed as a nested mapping; Codex now loads all bundled skills.
|
|
44
|
+
- **#32 — auto-report consolidation.** Three byte-identical auto-report
|
|
45
|
+
builders are consolidated into one provenance-tagged `fireAutoReport`;
|
|
46
|
+
sub-1s elapsed is relabelled `TASK_IDLE_UNCONFIRMED` so a stuck target is
|
|
47
|
+
never reported as `TASK_COMPLETE`.
|
|
48
|
+
- **#17 — surface-liveness GC.** New cmux surface-liveness probe
|
|
49
|
+
(`isSurfaceAlive`, INV-17 unknown-on-unreachable gate) plus `decideSurfaceGc`
|
|
50
|
+
and session-side SURFACE-GC reclaim of the headless zombie; the cli.js bridge
|
|
51
|
+
terminates (no reconnect) on a 1000 `Session destroyed` close.
|
|
52
|
+
- **#29 — Warp bootstrap.** Non-cmux (Warp) owner-alive optimistic bootstrap
|
|
53
|
+
floor (mirrors `runStartupBootstrapRestore`) plus `TERM_PROGRAM=WarpTerminal`
|
|
54
|
+
backend classification — fixes inject-queues-forever on Warp.
|
|
55
|
+
- **#31 — bootstrap timeout.** Actionable bootstrap-timeout
|
|
56
|
+
(`failBootstrapQueueOnTimeout` flushes the queue instead of hanging).
|
|
57
|
+
- **#19 — Windows codex PATH.** Verified already-fixed by `874d14a`
|
|
58
|
+
(no code change).
|
|
59
|
+
|
|
60
|
+
### Security — Snyk cli.js posture (task #26)
|
|
61
|
+
|
|
62
|
+
- **Fixed — 3 path-traversal findings** (`fs.readFileSync`/`fs.readdirSync` on the
|
|
63
|
+
`--config=` / `--dir=` (`telepty session start`) and `--context` (`telepty
|
|
64
|
+
deliberate`) CLI path arguments). A new `sanitizePathArg()` rejects empty input,
|
|
65
|
+
null-byte injection, and `..` traversal segments, then normalizes via
|
|
66
|
+
`path.resolve()`; applied at each `fs.*` call site. Snyk path-traversal count is
|
|
67
|
+
now **0**.
|
|
68
|
+
- **Hardened — self-update default** (`runUpdateInstall`): the default
|
|
69
|
+
`npm install -g` now runs via `execFileSync` with a fixed argument array (no
|
|
70
|
+
shell), removing the default-path command-injection surface.
|
|
71
|
+
- **By-design waivers (operator-trusted, no privilege boundary; pre-existing
|
|
72
|
+
baseline, not introduced by this work):** two `IndirectCommandInjection`
|
|
73
|
+
findings remain and are accepted by design — (1) `pty.spawn` of the
|
|
74
|
+
operator/user-chosen CLI, which *is* the `telepty allow` feature; and (2) the
|
|
75
|
+
explicit `TELEPTY_UPDATE_COMMAND` self-update override, an operator-set env var
|
|
76
|
+
(setting it already implies shell control, so no boundary is crossed). Both are
|
|
77
|
+
annotated in code so they are not mistaken for an oversight. **Net: 0
|
|
78
|
+
newly-introduced and 0 non-by-design findings.**
|
|
79
|
+
|
|
7
80
|
## [0.4.5] - 2026-05-26
|
|
8
81
|
|
|
9
82
|
### 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
|
-
|
|
497
|
-
|
|
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 = {}) {
|
|
@@ -928,6 +966,10 @@ async function main() {
|
|
|
928
966
|
|
|
929
967
|
if (cmd === 'daemon') {
|
|
930
968
|
console.log('Starting telepty daemon...');
|
|
969
|
+
// daemon.js binds the port only when launched as the daemon. The CLI reaches
|
|
970
|
+
// it via require() (not as require.main), so signal intent explicitly — tests
|
|
971
|
+
// that `require('./daemon.js')` without this env stay side-effect-free. (#15 / 0.5.0 daemon-never-listened regression)
|
|
972
|
+
process.env.AIGENTRY_TELEPTY_DAEMON_MAIN = '1';
|
|
931
973
|
require('./daemon.js');
|
|
932
974
|
return;
|
|
933
975
|
}
|
|
@@ -1082,7 +1124,9 @@ async function main() {
|
|
|
1082
1124
|
return files.length > 0;
|
|
1083
1125
|
} catch { return false; }
|
|
1084
1126
|
}
|
|
1085
|
-
|
|
1127
|
+
// #29: classify Warp first (TERM_PROGRAM=WarpTerminal) for honest telemetry + a named
|
|
1128
|
+
// branch (Warp readiness uses the #29 owner-alive floor, not a cmux read-screen poll).
|
|
1129
|
+
const detectedBackend = classifyBackend(process.env, findKittySocketCli);
|
|
1086
1130
|
|
|
1087
1131
|
// Register session with daemon
|
|
1088
1132
|
const terminalProgram = detectTerminalProgram(process.env);
|
|
@@ -1182,6 +1226,10 @@ async function main() {
|
|
|
1182
1226
|
// Windows: walk %PATHEXT% so bare names (`claude`, `codex`, `gemini`)
|
|
1183
1227
|
// resolve to their npm-global `.cmd`/`.ps1` shims. POSIX: no-op. (#25)
|
|
1184
1228
|
const resolvedCommand = resolveWindowsExecutable(command, process.env);
|
|
1229
|
+
// #26 (Snyk waiver, accepted-by-design): spawning the operator/user-chosen CLI IS the
|
|
1230
|
+
// `telepty allow` feature — `command` comes from the local CLI invocation, not an
|
|
1231
|
+
// untrusted boundary, so this is not an exploitable injection. Pre-existing baseline
|
|
1232
|
+
// finding; not fixable without removing `telepty allow`.
|
|
1185
1233
|
child = pty.spawn(resolvedCommand, cmdArgs, {
|
|
1186
1234
|
name: 'xterm-256color',
|
|
1187
1235
|
cols: process.stdout.columns || 80,
|
|
@@ -1401,8 +1449,19 @@ async function main() {
|
|
|
1401
1449
|
}
|
|
1402
1450
|
});
|
|
1403
1451
|
|
|
1404
|
-
daemonWs.on('close', () => {
|
|
1452
|
+
daemonWs.on('close', (code, reason) => {
|
|
1405
1453
|
wsReady = false;
|
|
1454
|
+
// #17 (OQ-2): the daemon explicitly destroyed this session (manual kill or the
|
|
1455
|
+
// surface-gone GC) → close code 1000 'Session destroyed'. Terminate the bridge
|
|
1456
|
+
// instead of reconnecting; otherwise the orphan bridge re-registers and defeats the
|
|
1457
|
+
// GC. Daemon restarts / network drops use other codes (e.g. 1006) and still reconnect,
|
|
1458
|
+
// preserving the #487/#488 survive-and-reattach guarantee.
|
|
1459
|
+
if (isDaemonDestroyClose(code, reason)) {
|
|
1460
|
+
if (closeAllowSession()) {
|
|
1461
|
+
exitAllowSession(0);
|
|
1462
|
+
}
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1406
1465
|
scheduleReconnect();
|
|
1407
1466
|
});
|
|
1408
1467
|
|
|
@@ -1464,8 +1523,11 @@ async function main() {
|
|
|
1464
1523
|
return true;
|
|
1465
1524
|
}
|
|
1466
1525
|
|
|
1467
|
-
function exitAllowSession(code) {
|
|
1468
|
-
|
|
1526
|
+
function exitAllowSession(code, exitCtx) {
|
|
1527
|
+
if (exitCtx) {
|
|
1528
|
+
logSessionDeath(exitCtx.exitCode, exitCtx.signal, exitCtx.duration);
|
|
1529
|
+
}
|
|
1530
|
+
process.exit(code);
|
|
1469
1531
|
}
|
|
1470
1532
|
|
|
1471
1533
|
// Intercept terminal title escape sequences and prefix with session ID
|
|
@@ -1562,14 +1624,25 @@ async function main() {
|
|
|
1562
1624
|
}
|
|
1563
1625
|
attachChildExitHandler();
|
|
1564
1626
|
|
|
1565
|
-
|
|
1627
|
+
process.on('SIGHUP', () => {
|
|
1628
|
+
// Explicit no-op: decouples telepty-allow lifecycle from parent terminal app.
|
|
1629
|
+
// Node default for SIGHUP is process.exit; this handler overrides that default.
|
|
1630
|
+
// See ADR 2026-05-27-cmux-telepty-session-boundary §4.
|
|
1631
|
+
});
|
|
1632
|
+
process.stdout.on('error', () => {});
|
|
1633
|
+
|
|
1634
|
+
for (const signalName of ['SIGTERM', 'SIGQUIT']) {
|
|
1566
1635
|
const handler = () => {
|
|
1567
1636
|
closeAllowSession();
|
|
1568
1637
|
try {
|
|
1569
1638
|
child.kill(signalName);
|
|
1570
1639
|
} catch {}
|
|
1571
1640
|
const signalCode = osConstants.signals[signalName] || 1;
|
|
1572
|
-
exitAllowSession(128 + signalCode
|
|
1641
|
+
exitAllowSession(128 + signalCode, {
|
|
1642
|
+
exitCode: null,
|
|
1643
|
+
signal: signalName,
|
|
1644
|
+
duration: Date.now() - sessionStartTime
|
|
1645
|
+
});
|
|
1573
1646
|
};
|
|
1574
1647
|
allowSignalHandlers.set(signalName, handler);
|
|
1575
1648
|
process.on(signalName, handler);
|
|
@@ -2525,11 +2598,12 @@ async function main() {
|
|
|
2525
2598
|
// Discover project folders (subdirectories with .git)
|
|
2526
2599
|
let projects;
|
|
2527
2600
|
if (configPath) {
|
|
2528
|
-
projects = JSON.parse(fs.readFileSync(configPath, 'utf8')).projects;
|
|
2601
|
+
projects = JSON.parse(fs.readFileSync(sanitizePathArg(configPath, 'config'), 'utf8')).projects;
|
|
2529
2602
|
} else {
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
.
|
|
2603
|
+
const resolvedDir = sanitizePathArg(projectsDir, 'dir');
|
|
2604
|
+
projects = fs.readdirSync(sanitizePathArg(projectsDir, 'dir'), { withFileTypes: true })
|
|
2605
|
+
.filter(d => d.isDirectory() && fs.existsSync(path.join(resolvedDir, d.name, '.git')))
|
|
2606
|
+
.map(d => ({ name: d.name, cwd: path.join(resolvedDir, d.name) }));
|
|
2533
2607
|
}
|
|
2534
2608
|
|
|
2535
2609
|
if (projects.length === 0) {
|
|
@@ -2836,7 +2910,7 @@ async function main() {
|
|
|
2836
2910
|
let contextContent = null;
|
|
2837
2911
|
if (contextPath) {
|
|
2838
2912
|
try {
|
|
2839
|
-
contextContent = fs.readFileSync(contextPath, 'utf-8');
|
|
2913
|
+
contextContent = fs.readFileSync(sanitizePathArg(contextPath, 'context'), 'utf-8');
|
|
2840
2914
|
} catch (err) {
|
|
2841
2915
|
console.error(`Failed to read context file: ${err.message}`);
|
|
2842
2916
|
process.exit(1);
|
|
@@ -3448,4 +3522,15 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
3448
3522
|
`);
|
|
3449
3523
|
}
|
|
3450
3524
|
|
|
3451
|
-
|
|
3525
|
+
// Guard the entry point so a test can `require('./cli.js')` to reach the exported pure helpers
|
|
3526
|
+
// without dispatching the argv command. Behavior when run as the CLI is unchanged.
|
|
3527
|
+
if (require.main === module) {
|
|
3528
|
+
main();
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
// Minimal test surface (no logic change) — pure decisions exposed for unit-testing.
|
|
3532
|
+
module.exports = {
|
|
3533
|
+
classifyBackend, // #29: TERM_PROGRAM/CMUX/kitty → backend string
|
|
3534
|
+
isDaemonDestroyClose, // #17 OQ-2: 1000 'Session destroyed' → terminate-not-reconnect
|
|
3535
|
+
sanitizePathArg, // #26: path-arg validation/normalization
|
|
3536
|
+
};
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
session.bootstrapPromptPoll
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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,18 @@ 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
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
3020
|
+
// Bind the port when launched as the daemon. A test can `require('./daemon.js')` to reach the
|
|
3021
|
+
// exported decision functions WITHOUT starting the daemon — it just must not set the env below.
|
|
3022
|
+
// The production CLI reaches daemon.js via require() (cli.js `cmd==='daemon'`), so require.main is
|
|
3023
|
+
// cli.js, never this module — hence the explicit AIGENTRY_TELEPTY_DAEMON_MAIN signal. Guarding on
|
|
3024
|
+
// require.main ALONE (0.5.0 regression) meant app.listen never ran in production → daemon exited 0.
|
|
3025
|
+
let server;
|
|
3026
|
+
if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1') {
|
|
3027
|
+
server = app.listen(PORT, HOST, () => {
|
|
3028
|
+
console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
|
|
3029
|
+
runStartupBootstrapRestore();
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
2890
3032
|
|
|
2891
3033
|
// #470 (0.4.5): when the daemon restarts under existing telepty allow workers,
|
|
2892
3034
|
// persisted sessions are restored at daemon.js:1244 but bootstrapReady stays
|
|
@@ -2961,12 +3103,15 @@ const mailboxDelivery = new DeliveryEngine(mailbox, {
|
|
|
2961
3103
|
}
|
|
2962
3104
|
},
|
|
2963
3105
|
});
|
|
2964
|
-
// Startup sweep: break stale lock files before starting delivery
|
|
2965
|
-
|
|
2966
|
-
if (
|
|
2967
|
-
|
|
3106
|
+
// Startup sweep: break stale lock files before starting delivery. Guarded so a test require
|
|
3107
|
+
// of this module neither breaks on-disk locks nor starts the delivery loop.
|
|
3108
|
+
if (require.main === module) {
|
|
3109
|
+
const staleBroken = mailbox.breakStaleLocks();
|
|
3110
|
+
if (staleBroken > 0) {
|
|
3111
|
+
console.log(`[MAILBOX] Startup sweep: broke ${staleBroken} stale lock(s)`);
|
|
3112
|
+
}
|
|
3113
|
+
mailboxDelivery.start();
|
|
2968
3114
|
}
|
|
2969
|
-
mailboxDelivery.start();
|
|
2970
3115
|
|
|
2971
3116
|
const IDLE_THRESHOLD_SECONDS = 60;
|
|
2972
3117
|
async function runIdleTtlSweep(nowMs = Date.now()) {
|
|
@@ -3000,13 +3145,14 @@ async function runIdleTtlSweep(nowMs = Date.now()) {
|
|
|
3000
3145
|
}
|
|
3001
3146
|
}
|
|
3002
3147
|
|
|
3003
|
-
|
|
3148
|
+
// Guarded: timers must not run (and keep the event loop alive) on a test require.
|
|
3149
|
+
if (require.main === module) setInterval(() => {
|
|
3004
3150
|
runIdleTtlSweep().catch((err) => {
|
|
3005
3151
|
console.error(`[REAPER] Idle TTL sweep failed: ${err.message}`);
|
|
3006
3152
|
});
|
|
3007
3153
|
}, IDLE_REAPER_POLL_MS);
|
|
3008
3154
|
|
|
3009
|
-
setInterval(() => {
|
|
3155
|
+
if (require.main === module) setInterval(() => {
|
|
3010
3156
|
const now = Date.now();
|
|
3011
3157
|
for (const [id, session] of Object.entries(sessions)) {
|
|
3012
3158
|
const idleSeconds = session.lastActivityAt ? Math.floor((now - new Date(session.lastActivityAt).getTime()) / 1000) : null;
|
|
@@ -3051,25 +3197,8 @@ setInterval(() => {
|
|
|
3051
3197
|
// Skip if onTransition already fired the idle notification.
|
|
3052
3198
|
const pendingRpt = pendingReports[id];
|
|
3053
3199
|
if (pendingRpt && !pendingRpt.idleNotified && session.type !== 'wrapped' && idleSeconds !== null && idleSeconds >= AUTO_REPORT_IDLE_SECONDS) {
|
|
3054
|
-
|
|
3055
|
-
|
|
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
|
-
}
|
|
3200
|
+
// silence-timeout: session has been quiet past the threshold without a REPORT.
|
|
3201
|
+
fireAutoReport(id, session, pendingRpt, 'silence-timeout');
|
|
3073
3202
|
}
|
|
3074
3203
|
// Reset idle flag when activity resumes
|
|
3075
3204
|
if (idleSeconds !== null && idleSeconds < IDLE_THRESHOLD_SECONDS) {
|
|
@@ -3099,6 +3228,49 @@ setInterval(() => {
|
|
|
3099
3228
|
}
|
|
3100
3229
|
}
|
|
3101
3230
|
|
|
3231
|
+
// #17: CONNECTED-zombie GC via cmux surface-liveness. Post-08cd796 a wrapped cmux bridge
|
|
3232
|
+
// SURVIVES its terminal app's death, so ownerWs stays OPEN and the 300s disconnect-GC
|
|
3233
|
+
// (below) never fires. If the workspace was EXPLICITLY closed while cmux itself is alive,
|
|
3234
|
+
// the session is a headless zombie → reclaim it after a grace window. INV-17: isSurfaceAlive
|
|
3235
|
+
// returns 'unknown' when cmux is unreachable (app-quit/restart vanishes ALL surfaces at
|
|
3236
|
+
// once), so this GCs NOTHING in that case — preserving the #486/#488 survival guarantee.
|
|
3237
|
+
if (session.type === 'wrapped' && session.backend === 'cmux' && session.cmuxWorkspaceId
|
|
3238
|
+
&& isOpenWebSocket(session.ownerWs)) {
|
|
3239
|
+
const liveness = terminalBackend.isSurfaceAlive(session);
|
|
3240
|
+
const gcAction = decideSurfaceGc(liveness, session, now);
|
|
3241
|
+
if (gcAction === 'mark') {
|
|
3242
|
+
session.surfaceGoneAt = new Date().toISOString();
|
|
3243
|
+
console.log(`[SURFACE-GC] cmux workspace gone for ${id} (${session.cmuxWorkspaceId}) — ${SURFACE_ORPHAN_SECONDS}s grace started`);
|
|
3244
|
+
} else if (gcAction === 'reclaim') {
|
|
3245
|
+
const goneSeconds = Math.floor((now - new Date(session.surfaceGoneAt).getTime()) / 1000);
|
|
3246
|
+
console.log(`[SURFACE-GC] Reclaiming headless cmux zombie ${id} after ${goneSeconds}s surface-gone`);
|
|
3247
|
+
emitSessionLifecycleEvent('session_cleanup', id, session, {
|
|
3248
|
+
reason: 'SURFACE_GONE',
|
|
3249
|
+
surfaceGoneSeconds: goneSeconds
|
|
3250
|
+
});
|
|
3251
|
+
// Surface-ownership verdict (2026-05-30): telepty reclaims the zombie SESSION but does
|
|
3252
|
+
// NOT close the surface. Emit the orphan SIGNAL so the orchestrator's reconciler closes
|
|
3253
|
+
// the surface (wh_close). telepty signals; the orchestrator actuates.
|
|
3254
|
+
broadcastSessionEvent('surface_orphaned', id, session, {
|
|
3255
|
+
extra: {
|
|
3256
|
+
sid: id,
|
|
3257
|
+
backend: session.backend || null,
|
|
3258
|
+
cmuxWorkspaceId: session.cmuxWorkspaceId || null,
|
|
3259
|
+
surfaceGoneSeconds: goneSeconds,
|
|
3260
|
+
livenessVerdict: liveness
|
|
3261
|
+
}
|
|
3262
|
+
});
|
|
3263
|
+
teardownSessionById(id, { force: true, timeoutMs: 5000, reason: 'SURFACE_GONE', source: 'surface_gc' })
|
|
3264
|
+
.catch(err => console.error(`[SURFACE-GC] teardown failed for ${id}: ${err.message}`));
|
|
3265
|
+
continue; // being destroyed — skip remaining checks for this session this tick
|
|
3266
|
+
} else if (gcAction === 'recover') {
|
|
3267
|
+
// Recovery within the grace window (mirrors the aterm socket-recover above).
|
|
3268
|
+
console.log(`[SURFACE-GC] cmux workspace recovered for ${id} — clearing grace window`);
|
|
3269
|
+
session.surfaceGoneAt = null;
|
|
3270
|
+
}
|
|
3271
|
+
// 'skip' (incl. 'unknown' — INV-17 gate) → leave surfaceGoneAt unchanged, GC nothing.
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3102
3274
|
if (healthStatus === 'STALE' && !session._staleEmitted) {
|
|
3103
3275
|
session._staleEmitted = true;
|
|
3104
3276
|
emitSessionLifecycleEvent('session_stale', id, session, {
|
|
@@ -3125,7 +3297,7 @@ setInterval(() => {
|
|
|
3125
3297
|
}
|
|
3126
3298
|
}, HEALTH_POLL_MS);
|
|
3127
3299
|
|
|
3128
|
-
server.on('error', async (error) => {
|
|
3300
|
+
if (server) server.on('error', async (error) => {
|
|
3129
3301
|
clearDaemonState(process.pid);
|
|
3130
3302
|
|
|
3131
3303
|
if (error && error.code === 'EADDRINUSE') {
|
|
@@ -3269,25 +3441,8 @@ wss.on('connection', (ws, req) => {
|
|
|
3269
3441
|
// fired (pendingReports[sessionId].idleNotified === true).
|
|
3270
3442
|
const pendingReport = pendingReports[sessionId];
|
|
3271
3443
|
if (pendingReport && !pendingReport.idleNotified) {
|
|
3272
|
-
|
|
3273
|
-
|
|
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
|
-
}
|
|
3444
|
+
// ready-signal: cli.js bridge emitted a 'ready' WS frame.
|
|
3445
|
+
fireAutoReport(sessionId, activeSession, pendingReport, 'ready-signal');
|
|
3291
3446
|
}
|
|
3292
3447
|
}
|
|
3293
3448
|
} else {
|
|
@@ -3316,6 +3471,13 @@ wss.on('connection', (ws, req) => {
|
|
|
3316
3471
|
activeSession.clients.delete(ws);
|
|
3317
3472
|
if (activeSession.type === 'wrapped' && ws === activeSession.ownerWs) {
|
|
3318
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
|
+
}
|
|
3319
3481
|
markSessionDisconnected(activeSession);
|
|
3320
3482
|
console.log(`[WS] Wrap owner disconnected from session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
3321
3483
|
emitSessionLifecycleEvent('session_disconnect', sessionId, activeSession, {
|
|
@@ -3376,7 +3538,7 @@ busWss.on('connection', (ws, req) => {
|
|
|
3376
3538
|
});
|
|
3377
3539
|
});
|
|
3378
3540
|
|
|
3379
|
-
server.on('upgrade', (req, socket, head) => {
|
|
3541
|
+
if (server) server.on('upgrade', (req, socket, head) => {
|
|
3380
3542
|
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
3381
3543
|
const token = url.searchParams.get('token');
|
|
3382
3544
|
|
|
@@ -3408,8 +3570,23 @@ function shutdown(code) {
|
|
|
3408
3570
|
process.exit(code);
|
|
3409
3571
|
}
|
|
3410
3572
|
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3573
|
+
// Daemon-lifecycle signal/exit handlers — only when run as the daemon, so a test require does
|
|
3574
|
+
// not register them (and does not clear on-disk daemon-state at the test process's exit).
|
|
3575
|
+
if (require.main === module) {
|
|
3576
|
+
process.on('SIGINT', () => shutdown(0));
|
|
3577
|
+
process.on('SIGTERM', () => shutdown(0));
|
|
3578
|
+
process.on('exit', () => {
|
|
3579
|
+
clearDaemonState(process.pid);
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
// Minimal test surface (no logic change): expose the pure lifecycle decisions + DI-seamed
|
|
3584
|
+
// helpers so the daemon ACs are unit-testable without starting the daemon. Behavior for the
|
|
3585
|
+
// production call sites is unchanged. NOT a public API — internal/test use only.
|
|
3586
|
+
module.exports = {
|
|
3587
|
+
fireAutoReport, // #32: provenance-tagged auto-report (deps DI: now/deliver/...)
|
|
3588
|
+
failBootstrapQueueOnTimeout, // #31: actionable bootstrap-timeout queue flush
|
|
3589
|
+
shouldApplyOwnerAliveFloor, // #29: owner-alive optimistic-floor decision (deps DI: isProcessRunning/...)
|
|
3590
|
+
scheduleBootstrapPromptPoll, // #29: arms the floor timer (deps DI: setTimeout/...)
|
|
3591
|
+
decideSurfaceGc, // #17: surface-liveness verdict→action (incl. INV-17 unknown→skip)
|
|
3592
|
+
};
|
package/package.json
CHANGED
package/skills/telepty/SKILL.md
CHANGED
|
@@ -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
|
package/terminal-backend.js
CHANGED
|
@@ -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
|
};
|