@dmsdc-ai/aigentry-telepty 0.4.4 → 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 +110 -0
- package/cli.js +96 -15
- package/daemon.js +319 -105
- package/package.json +6 -4
- package/scripts/postinstall.js +94 -0
- 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/src/prompt-symbol-registry.js +34 -4
- package/src/submit-gate.js +7 -1
- package/terminal-backend.js +70 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,116 @@ 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
|
+
|
|
61
|
+
## [0.4.5] - 2026-05-26
|
|
62
|
+
|
|
63
|
+
### Fixed — Stale-daemon, restart-recovery, force-bypass, codex matcher (tasks #469 #470 #471 #472)
|
|
64
|
+
|
|
65
|
+
- **#469 — npm postinstall hook restarts a stale daemon (`scripts/postinstall.js`).**
|
|
66
|
+
`npm install -g @dmsdc-ai/aigentry-telepty@X` previously overwrote files but
|
|
67
|
+
never signalled the running `telepty-daemon`, so the daemon kept executing
|
|
68
|
+
the previously-loaded code (observed: PID 3222 ran 22 days through 4
|
|
69
|
+
upgrades). The new postinstall script reads `~/.telepty/daemon-state.json`,
|
|
70
|
+
compares the running daemon's reported version to the just-installed
|
|
71
|
+
`package.json` version, and on mismatch invokes the existing
|
|
72
|
+
`cleanupDaemonProcesses()` primitive plus a detached respawn. Skips on
|
|
73
|
+
`TELEPTY_SKIP_POSTINSTALL=1` and on non-global installs
|
|
74
|
+
(`npm_config_global!=='true'`).
|
|
75
|
+
- **#470 — daemon restart re-bootstraps existing sessions
|
|
76
|
+
(`daemon.js` `runStartupBootstrapRestore()`).** After a daemon restart,
|
|
77
|
+
persisted-and-restored sessions remained `ready:false` indefinitely because
|
|
78
|
+
the bootstrap prompt-symbol probe only fired on owner-WebSocket reconnect.
|
|
79
|
+
On startup, each restored gated session whose `ownerPid` is still alive is
|
|
80
|
+
now actively probed (cmux path) or optimistically marked ready (non-cmux
|
|
81
|
+
path), with the chosen reason recorded for log attribution. Sessions whose
|
|
82
|
+
owner process is dead remain unready, matching the prior unready semantics.
|
|
83
|
+
- **#471 — `force: true` bypasses the bootstrap gate (`daemon.js:1969`).**
|
|
84
|
+
The per-request `force` escape hatch (`cli.js --submit-force`,
|
|
85
|
+
`TELEPTY_SUBMIT_FORCE_DEFAULT=1` from 0.4.4) was parsed correctly but the
|
|
86
|
+
bootstrap gate enqueued it and returned 504 long before the force-bypass
|
|
87
|
+
block at L1998 could run. Surgical 1-line condition edit: gate fires only
|
|
88
|
+
when `!force`. The force-bypass code path is now exercisable as documented.
|
|
89
|
+
- **#472 — codex prompt-symbol matcher normalized across environments
|
|
90
|
+
(`src/prompt-symbol-registry.js`).** On real cmux captures the codex `›`
|
|
91
|
+
glyph tail-renders on the same row as the model-status footer and DECRQM /
|
|
92
|
+
cursor-position-query fragments (`>4;0m>7u`, `0 q`) leak into the screen
|
|
93
|
+
buffer, so the prior strict line-leading scan permanently missed and the
|
|
94
|
+
session stuck at `ready:false`. New tolerant detector: (1) modal-UI
|
|
95
|
+
anti-pattern guard for resume picker, first-run directory-trust prompt and
|
|
96
|
+
generic press-enter-to-continue modals (treated as NOT ready); (2)
|
|
97
|
+
multi-signal match on `"OpenAI Codex (v"` plus `/gpt-[0-9.]+\s+\w+\s+fast/`
|
|
98
|
+
anywhere on the screen; (3) legacy strict line-leading scan preserved as a
|
|
99
|
+
back-compat fallback. `awaitPromptSymbol` now emits a single
|
|
100
|
+
`[bootstrap] <cli> ready via: <reason>` log line on stabilize, paired with
|
|
101
|
+
the #470 optimistic-ready logging for unified debuggability.
|
|
102
|
+
|
|
103
|
+
### Notes — 0.4.5
|
|
104
|
+
|
|
105
|
+
- **Tests** — `npm test` passes 416 / 416 (was 411 / 411 in 0.4.4; +5 new
|
|
106
|
+
cases in `test/release-0.4.5-bugfixes.test.js` covering #469/#470/#471/#472
|
|
107
|
+
including env-resistance regression guard on the existing noforce test).
|
|
108
|
+
- **Snyk Code SAST** — all newly authored or modified JS files
|
|
109
|
+
(`scripts/postinstall.js`, `src/prompt-symbol-registry.js`,
|
|
110
|
+
`src/submit-gate.js`, new `daemon.js` line ranges, and
|
|
111
|
+
`test/release-0.4.5-bugfixes.test.js`) report 0 findings. The 55
|
|
112
|
+
pre-existing repo-wide findings in unchanged code are tracked separately as
|
|
113
|
+
task #474 (security cleanup track) and are not part of this release.
|
|
114
|
+
- **Out of scope, tracked separately** — task #473 (session-ID reuse → stale
|
|
115
|
+
command metadata) is queued for a 0.4.6 dispatch and is not addressed here.
|
|
116
|
+
|
|
7
117
|
## [0.4.4] - 2026-05-25
|
|
8
118
|
|
|
9
119
|
### Added — TELEPTY_SUBMIT_FORCE_DEFAULT env var (task #453)
|
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 = {}) {
|
|
@@ -1082,7 +1120,9 @@ async function main() {
|
|
|
1082
1120
|
return files.length > 0;
|
|
1083
1121
|
} catch { return false; }
|
|
1084
1122
|
}
|
|
1085
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2531
|
-
|
|
2532
|
-
.
|
|
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
|
-
|
|
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
|
@@ -6,7 +6,7 @@ const crypto = require('crypto');
|
|
|
6
6
|
const { WebSocketServer } = require('ws');
|
|
7
7
|
const { getConfig } = require('./auth');
|
|
8
8
|
const pkg = require('./package.json');
|
|
9
|
-
const { claimDaemonState, clearDaemonState } = require('./daemon-control');
|
|
9
|
+
const { claimDaemonState, clearDaemonState, isProcessRunning } = require('./daemon-control');
|
|
10
10
|
const { checkEntitlement } = require('./entitlement');
|
|
11
11
|
const terminalBackend = require('./terminal-backend');
|
|
12
12
|
const { FileMailbox } = require('./src/mailbox/index');
|
|
@@ -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 {}
|
|
@@ -1966,7 +2095,10 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
|
|
|
1966
2095
|
|
|
1967
2096
|
console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}${gateOff ? ' [gate=off]' : ''}`);
|
|
1968
2097
|
|
|
1969
|
-
|
|
2098
|
+
// #471 (0.4.5): force=true must bypass the bootstrap gate. Without `!force`
|
|
2099
|
+
// here the per-request escape hatch (cli.js --submit-force) is enqueued and
|
|
2100
|
+
// 504s before the force-bypass block below ever runs.
|
|
2101
|
+
if (!force && isBootstrapGatedSession(session) && (!isBootstrapReady(session) || hasBootstrapBacklog(session) || session.bootstrapDraining)) {
|
|
1970
2102
|
const op = enqueueBootstrapOperation(id, session, {
|
|
1971
2103
|
type: 'submit',
|
|
1972
2104
|
body: { ...(req.body || {}) }
|
|
@@ -2537,6 +2669,11 @@ app.delete('/api/sessions/:id', (req, res) => {
|
|
|
2537
2669
|
} else if (session.ptyProcess) {
|
|
2538
2670
|
session.ptyProcess.kill();
|
|
2539
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 {}
|
|
2540
2677
|
delete sessions[id];
|
|
2541
2678
|
sessionStateManager.unregister(id);
|
|
2542
2679
|
try { mailbox.purge(id); } catch {}
|
|
@@ -2880,9 +3017,51 @@ app.patch('/api/threads/:id', (req, res) => {
|
|
|
2880
3017
|
res.json({ success: true, thread_id: thread.id, status: thread.status });
|
|
2881
3018
|
});
|
|
2882
3019
|
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
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
|
+
}
|
|
3030
|
+
|
|
3031
|
+
// #470 (0.4.5): when the daemon restarts under existing telepty allow workers,
|
|
3032
|
+
// persisted sessions are restored at daemon.js:1244 but bootstrapReady stays
|
|
3033
|
+
// false until the owner WS reconnects — leaving every survivor session stuck
|
|
3034
|
+
// at ready:false indefinitely. Re-probe on startup: for cmux sessions whose
|
|
3035
|
+
// owner PID is still alive, run the WS-independent prompt-symbol probe; for
|
|
3036
|
+
// non-cmux survivors, optimistically mark ready (the underlying CLI is alive
|
|
3037
|
+
// and no probe primitive is available).
|
|
3038
|
+
function runStartupBootstrapRestore() {
|
|
3039
|
+
for (const [id, session] of Object.entries(sessions)) {
|
|
3040
|
+
if (!isBootstrapGatedSession(session) || isBootstrapReady(session)) continue;
|
|
3041
|
+
const ownerPid = Number(session.ownerPid);
|
|
3042
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !isProcessRunning(ownerPid)) {
|
|
3043
|
+
continue;
|
|
3044
|
+
}
|
|
3045
|
+
if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
|
|
3046
|
+
submitGate.awaitPromptSymbol(session, { timeoutMs: 5000 })
|
|
3047
|
+
.then((result) => {
|
|
3048
|
+
if (result && result.ready) {
|
|
3049
|
+
markBootstrapReady(id, session, 'startup_restore');
|
|
3050
|
+
} else {
|
|
3051
|
+
markBootstrapReady(id, session, 'startup_owner_alive');
|
|
3052
|
+
console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, probe=${result?.reason || 'timeout'})`);
|
|
3053
|
+
}
|
|
3054
|
+
})
|
|
3055
|
+
.catch(() => {
|
|
3056
|
+
markBootstrapReady(id, session, 'startup_owner_alive');
|
|
3057
|
+
console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, probe=error)`);
|
|
3058
|
+
});
|
|
3059
|
+
} else {
|
|
3060
|
+
markBootstrapReady(id, session, 'startup_owner_alive');
|
|
3061
|
+
console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, backend=${session.backend || 'unknown'})`);
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
2886
3065
|
|
|
2887
3066
|
// --- Mailbox system initialization ---
|
|
2888
3067
|
const mailbox = new FileMailbox();
|
|
@@ -2922,12 +3101,15 @@ const mailboxDelivery = new DeliveryEngine(mailbox, {
|
|
|
2922
3101
|
}
|
|
2923
3102
|
},
|
|
2924
3103
|
});
|
|
2925
|
-
// Startup sweep: break stale lock files before starting delivery
|
|
2926
|
-
|
|
2927
|
-
if (
|
|
2928
|
-
|
|
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();
|
|
2929
3112
|
}
|
|
2930
|
-
mailboxDelivery.start();
|
|
2931
3113
|
|
|
2932
3114
|
const IDLE_THRESHOLD_SECONDS = 60;
|
|
2933
3115
|
async function runIdleTtlSweep(nowMs = Date.now()) {
|
|
@@ -2961,13 +3143,14 @@ async function runIdleTtlSweep(nowMs = Date.now()) {
|
|
|
2961
3143
|
}
|
|
2962
3144
|
}
|
|
2963
3145
|
|
|
2964
|
-
|
|
3146
|
+
// Guarded: timers must not run (and keep the event loop alive) on a test require.
|
|
3147
|
+
if (require.main === module) setInterval(() => {
|
|
2965
3148
|
runIdleTtlSweep().catch((err) => {
|
|
2966
3149
|
console.error(`[REAPER] Idle TTL sweep failed: ${err.message}`);
|
|
2967
3150
|
});
|
|
2968
3151
|
}, IDLE_REAPER_POLL_MS);
|
|
2969
3152
|
|
|
2970
|
-
setInterval(() => {
|
|
3153
|
+
if (require.main === module) setInterval(() => {
|
|
2971
3154
|
const now = Date.now();
|
|
2972
3155
|
for (const [id, session] of Object.entries(sessions)) {
|
|
2973
3156
|
const idleSeconds = session.lastActivityAt ? Math.floor((now - new Date(session.lastActivityAt).getTime()) / 1000) : null;
|
|
@@ -3012,25 +3195,8 @@ setInterval(() => {
|
|
|
3012
3195
|
// Skip if onTransition already fired the idle notification.
|
|
3013
3196
|
const pendingRpt = pendingReports[id];
|
|
3014
3197
|
if (pendingRpt && !pendingRpt.idleNotified && session.type !== 'wrapped' && idleSeconds !== null && idleSeconds >= AUTO_REPORT_IDLE_SECONDS) {
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
const elapsed = ((Date.now() - new Date(pendingRpt.injectedAt).getTime()) / 1000).toFixed(1);
|
|
3018
|
-
// Fire new bus event + legacy text-inject
|
|
3019
|
-
broadcastSessionEvent('TASK_IDLE_NO_REPORT', id, session, {
|
|
3020
|
-
extra: {
|
|
3021
|
-
source: pendingRpt.source,
|
|
3022
|
-
inject_id: pendingRpt.injectId,
|
|
3023
|
-
elapsed_secs: Number(elapsed),
|
|
3024
|
-
injected_at: pendingRpt.injectedAt
|
|
3025
|
-
}
|
|
3026
|
-
});
|
|
3027
|
-
const reportMsg = `TASK_COMPLETE: ${id} is now idle after processing inject (${elapsed}s)`;
|
|
3028
|
-
const srcId = resolveSessionAlias(pendingRpt.source) || pendingRpt.source;
|
|
3029
|
-
const srcSession = sessions[srcId];
|
|
3030
|
-
if (srcSession) {
|
|
3031
|
-
deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
|
|
3032
|
-
console.log(`[AUTO-REPORT] ${id} → ${srcId}: idle after ${elapsed}s (threshold)`);
|
|
3033
|
-
}
|
|
3198
|
+
// silence-timeout: session has been quiet past the threshold without a REPORT.
|
|
3199
|
+
fireAutoReport(id, session, pendingRpt, 'silence-timeout');
|
|
3034
3200
|
}
|
|
3035
3201
|
// Reset idle flag when activity resumes
|
|
3036
3202
|
if (idleSeconds !== null && idleSeconds < IDLE_THRESHOLD_SECONDS) {
|
|
@@ -3060,6 +3226,49 @@ setInterval(() => {
|
|
|
3060
3226
|
}
|
|
3061
3227
|
}
|
|
3062
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
|
+
|
|
3063
3272
|
if (healthStatus === 'STALE' && !session._staleEmitted) {
|
|
3064
3273
|
session._staleEmitted = true;
|
|
3065
3274
|
emitSessionLifecycleEvent('session_stale', id, session, {
|
|
@@ -3086,7 +3295,7 @@ setInterval(() => {
|
|
|
3086
3295
|
}
|
|
3087
3296
|
}, HEALTH_POLL_MS);
|
|
3088
3297
|
|
|
3089
|
-
server.on('error', async (error) => {
|
|
3298
|
+
if (server) server.on('error', async (error) => {
|
|
3090
3299
|
clearDaemonState(process.pid);
|
|
3091
3300
|
|
|
3092
3301
|
if (error && error.code === 'EADDRINUSE') {
|
|
@@ -3230,25 +3439,8 @@ wss.on('connection', (ws, req) => {
|
|
|
3230
3439
|
// fired (pendingReports[sessionId].idleNotified === true).
|
|
3231
3440
|
const pendingReport = pendingReports[sessionId];
|
|
3232
3441
|
if (pendingReport && !pendingReport.idleNotified) {
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
const elapsed = ((Date.now() - new Date(pendingReport.injectedAt).getTime()) / 1000).toFixed(1);
|
|
3236
|
-
// Fire new bus event + legacy text-inject
|
|
3237
|
-
broadcastSessionEvent('TASK_IDLE_NO_REPORT', sessionId, activeSession, {
|
|
3238
|
-
extra: {
|
|
3239
|
-
source: pendingReport.source,
|
|
3240
|
-
inject_id: pendingReport.injectId,
|
|
3241
|
-
elapsed_secs: Number(elapsed),
|
|
3242
|
-
injected_at: pendingReport.injectedAt
|
|
3243
|
-
}
|
|
3244
|
-
});
|
|
3245
|
-
const reportMsg = `TASK_COMPLETE: ${sessionId} is now idle after processing inject (${elapsed}s)`;
|
|
3246
|
-
const srcId = resolveSessionAlias(pendingReport.source) || pendingReport.source;
|
|
3247
|
-
const srcSession = sessions[srcId];
|
|
3248
|
-
if (srcSession) {
|
|
3249
|
-
deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
|
|
3250
|
-
console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (ready signal)`);
|
|
3251
|
-
}
|
|
3442
|
+
// ready-signal: cli.js bridge emitted a 'ready' WS frame.
|
|
3443
|
+
fireAutoReport(sessionId, activeSession, pendingReport, 'ready-signal');
|
|
3252
3444
|
}
|
|
3253
3445
|
}
|
|
3254
3446
|
} else {
|
|
@@ -3277,6 +3469,13 @@ wss.on('connection', (ws, req) => {
|
|
|
3277
3469
|
activeSession.clients.delete(ws);
|
|
3278
3470
|
if (activeSession.type === 'wrapped' && ws === activeSession.ownerWs) {
|
|
3279
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
|
+
}
|
|
3280
3479
|
markSessionDisconnected(activeSession);
|
|
3281
3480
|
console.log(`[WS] Wrap owner disconnected from session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
3282
3481
|
emitSessionLifecycleEvent('session_disconnect', sessionId, activeSession, {
|
|
@@ -3337,7 +3536,7 @@ busWss.on('connection', (ws, req) => {
|
|
|
3337
3536
|
});
|
|
3338
3537
|
});
|
|
3339
3538
|
|
|
3340
|
-
server.on('upgrade', (req, socket, head) => {
|
|
3539
|
+
if (server) server.on('upgrade', (req, socket, head) => {
|
|
3341
3540
|
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
3342
3541
|
const token = url.searchParams.get('token');
|
|
3343
3542
|
|
|
@@ -3369,8 +3568,23 @@ function shutdown(code) {
|
|
|
3369
3568
|
process.exit(code);
|
|
3370
3569
|
}
|
|
3371
3570
|
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aigentry-telepty": "install.js",
|
|
@@ -28,14 +28,16 @@
|
|
|
28
28
|
"install.sh",
|
|
29
29
|
"install.ps1",
|
|
30
30
|
"mcp-server/",
|
|
31
|
+
"scripts/postinstall.js",
|
|
31
32
|
"src/",
|
|
32
33
|
"skills/",
|
|
33
34
|
"CHANGELOG.md"
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|
|
36
|
-
"
|
|
37
|
-
"test
|
|
38
|
-
"test:
|
|
37
|
+
"postinstall": "node scripts/postinstall.js",
|
|
38
|
+
"test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
39
|
+
"test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js",
|
|
40
|
+
"test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
39
41
|
"regen-fixtures": "node scripts/regen-snippet-fixtures.js"
|
|
40
42
|
},
|
|
41
43
|
"keywords": [
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// #469 (0.4.5): npm postinstall hook — restart a stale telepty-daemon after
|
|
5
|
+
// `npm install -g`. Without this, the running daemon keeps executing the
|
|
6
|
+
// previously-loaded code (verified: a daemon ran 22 days through 4 npm
|
|
7
|
+
// upgrades), so user-facing upgrades quietly no-op until they manually kill
|
|
8
|
+
// the daemon. Wires the existing daemon-shutdown primitive into npm's
|
|
9
|
+
// lifecycle; does not add new shutdown logic.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { spawn, execSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const pkg = require('../package.json');
|
|
17
|
+
|
|
18
|
+
function shouldSkip() {
|
|
19
|
+
if (process.env.TELEPTY_SKIP_POSTINSTALL === '1') {
|
|
20
|
+
return 'TELEPTY_SKIP_POSTINSTALL=1';
|
|
21
|
+
}
|
|
22
|
+
// Only act on global installs. Local `npm install` (CI, dev) must not
|
|
23
|
+
// restart a user's daemon.
|
|
24
|
+
if (process.env.npm_config_global !== 'true') {
|
|
25
|
+
return 'non-global install';
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readDaemonState() {
|
|
31
|
+
const statePath = path.join(os.homedir(), '.telepty', 'daemon-state.json');
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveTeleptyBin() {
|
|
40
|
+
try {
|
|
41
|
+
const cmd = os.platform() === 'win32' ? 'where telepty' : 'which telepty';
|
|
42
|
+
return execSync(cmd, { encoding: 'utf8' }).split('\n')[0].trim() || 'telepty';
|
|
43
|
+
} catch {
|
|
44
|
+
return 'telepty';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
(function main() {
|
|
49
|
+
const skip = shouldSkip();
|
|
50
|
+
if (skip) {
|
|
51
|
+
console.log(`[telepty postinstall] Skipped (${skip}).`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const state = readDaemonState();
|
|
56
|
+
if (!state || !Number.isInteger(state.pid) || state.pid <= 0) {
|
|
57
|
+
console.log('[telepty postinstall] No running daemon detected — nothing to restart.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (state.version === pkg.version) {
|
|
62
|
+
console.log(`[telepty postinstall] Running daemon already at ${pkg.version} (pid ${state.pid}). No restart needed.`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(`[telepty postinstall] Detected stale daemon ${state.version || 'unknown'} (pid ${state.pid}); upgrading in-place to ${pkg.version}.`);
|
|
67
|
+
|
|
68
|
+
let stopped = 0;
|
|
69
|
+
try {
|
|
70
|
+
// Lazy require so a malformed install of daemon-control.js doesn't abort
|
|
71
|
+
// postinstall before the skip-check runs.
|
|
72
|
+
const { cleanupDaemonProcesses } = require('../daemon-control');
|
|
73
|
+
const result = cleanupDaemonProcesses();
|
|
74
|
+
stopped = result.stopped.length;
|
|
75
|
+
if (result.failed.length > 0) {
|
|
76
|
+
console.warn(`[telepty postinstall] Could not stop ${result.failed.length} daemon process(es).`);
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.warn(`[telepty postinstall] cleanupDaemonProcesses failed: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// launchd/systemd KeepAlive will respawn the daemon automatically on
|
|
83
|
+
// macOS/root-Linux. For other platforms (Windows, non-root Linux) or when
|
|
84
|
+
// the user disabled the service, spawn a fresh detached daemon so upgrades
|
|
85
|
+
// never silently leave the user without one.
|
|
86
|
+
try {
|
|
87
|
+
const bin = resolveTeleptyBin();
|
|
88
|
+
const child = spawn(bin, ['daemon'], { detached: true, stdio: 'ignore' });
|
|
89
|
+
child.unref();
|
|
90
|
+
console.log(`[telepty postinstall] Stopped ${stopped} stale daemon(s); spawned fresh ${pkg.version} daemon.`);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn(`[telepty postinstall] Daemon respawn failed: ${err.message} (launchd/systemd may restart it automatically).`);
|
|
93
|
+
}
|
|
94
|
+
})();
|
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
|
|
@@ -33,19 +33,49 @@ const ENTRIES = {
|
|
|
33
33
|
return { found: false };
|
|
34
34
|
},
|
|
35
35
|
},
|
|
36
|
-
// codex
|
|
37
|
-
//
|
|
36
|
+
// #472 (0.4.5): codex previously matched on a strict line-leading "^ › "
|
|
37
|
+
// shape; on real cmux captures the '›' tail-renders on the same row as the
|
|
38
|
+
// model-status footer and DECRQM/cursor-pos fragments leak in, so that
|
|
39
|
+
// strict matcher misses. Multi-signal tolerant matcher: picker anti-pattern
|
|
40
|
+
// first (resume-picker UI must NOT be considered ready), then a tolerant
|
|
41
|
+
// (a + b) signal pair, then the legacy strict scan as a back-compat
|
|
42
|
+
// fallback. Reason field surfaces which signal fired for log-attribution.
|
|
38
43
|
codex: {
|
|
39
44
|
symbol: '›',
|
|
40
45
|
byteSeq: Buffer.from([0xE2, 0x80, 0xBA]),
|
|
41
46
|
detect(screen) {
|
|
42
|
-
const
|
|
47
|
+
const text = String(screen == null ? '' : screen);
|
|
48
|
+
|
|
49
|
+
// Step 1: modal-UI anti-pattern. Resume picker, first-run directory
|
|
50
|
+
// trust prompt, and generic "Press enter to continue" modals are all
|
|
51
|
+
// pre-prompt UIs where Enter would not submit a user message. Treat
|
|
52
|
+
// any of them as NOT ready.
|
|
53
|
+
if (
|
|
54
|
+
/Resume a previous session/.test(text) ||
|
|
55
|
+
/^Filter:/m.test(text) ||
|
|
56
|
+
/Do you trust the contents/i.test(text) ||
|
|
57
|
+
/Press enter to continue/i.test(text)
|
|
58
|
+
) {
|
|
59
|
+
return { found: false, reason: 'codex_modal_ui' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 2: multi-signal tolerant. The codex boot box contains
|
|
63
|
+
// "OpenAI Codex (v<version>)" and the status row contains
|
|
64
|
+
// "gpt-<ver> <profile> fast". Both present anywhere on the captured
|
|
65
|
+
// screen → ready, regardless of where the literal '›' rendered.
|
|
66
|
+
if (/OpenAI Codex \(v/.test(text) && /gpt-[0-9.]+\s+\w+\s+fast/.test(text)) {
|
|
67
|
+
return { found: true, reason: 'codex_multi_signal' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Step 3: legacy strict line-leading scan — preserved for back-compat
|
|
71
|
+
// on clean cmux captures where the original matcher already worked.
|
|
72
|
+
const lines = text.split('\n');
|
|
43
73
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
44
74
|
const line = lines[i];
|
|
45
75
|
if (!/^ › /.test(line)) continue;
|
|
46
76
|
const footer = (lines[i + 1] || '') + '\n' + (lines[i + 2] || '');
|
|
47
77
|
if (/gpt-\d/.test(footer)) {
|
|
48
|
-
return { found: true, line_index: i, col: 2 };
|
|
78
|
+
return { found: true, line_index: i, col: 2, reason: 'codex_strict_line' };
|
|
49
79
|
}
|
|
50
80
|
}
|
|
51
81
|
return { found: false };
|
package/src/submit-gate.js
CHANGED
|
@@ -227,7 +227,13 @@ async function awaitPromptSymbol(session, opts = {}) {
|
|
|
227
227
|
if (lastSeenAt === null) {
|
|
228
228
|
lastSeenAt = now();
|
|
229
229
|
} else if (now() - lastSeenAt >= stabilityMs) {
|
|
230
|
-
|
|
230
|
+
// #472 (0.4.5): tag the success reason for debuggability — pairs
|
|
231
|
+
// with daemon.js startup-restore optimistic-ready logging so we
|
|
232
|
+
// can attribute every bootstrap_ready flip to a concrete signal.
|
|
233
|
+
if (match.reason && typeof console !== 'undefined' && console.log) {
|
|
234
|
+
console.log(`[bootstrap] ${session.command} ready via: ${match.reason}`);
|
|
235
|
+
}
|
|
236
|
+
return { ready: true, last_seen_at: lastSeenAt, waited_ms: now() - start, reason: match.reason };
|
|
231
237
|
}
|
|
232
238
|
} else {
|
|
233
239
|
// symbol disappeared — reset the stability streak
|
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
|
};
|