@dmsdc-ai/aigentry-telepty 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,62 @@ All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.5] - 2026-05-26
8
+
9
+ ### Fixed — Stale-daemon, restart-recovery, force-bypass, codex matcher (tasks #469 #470 #471 #472)
10
+
11
+ - **#469 — npm postinstall hook restarts a stale daemon (`scripts/postinstall.js`).**
12
+ `npm install -g @dmsdc-ai/aigentry-telepty@X` previously overwrote files but
13
+ never signalled the running `telepty-daemon`, so the daemon kept executing
14
+ the previously-loaded code (observed: PID 3222 ran 22 days through 4
15
+ upgrades). The new postinstall script reads `~/.telepty/daemon-state.json`,
16
+ compares the running daemon's reported version to the just-installed
17
+ `package.json` version, and on mismatch invokes the existing
18
+ `cleanupDaemonProcesses()` primitive plus a detached respawn. Skips on
19
+ `TELEPTY_SKIP_POSTINSTALL=1` and on non-global installs
20
+ (`npm_config_global!=='true'`).
21
+ - **#470 — daemon restart re-bootstraps existing sessions
22
+ (`daemon.js` `runStartupBootstrapRestore()`).** After a daemon restart,
23
+ persisted-and-restored sessions remained `ready:false` indefinitely because
24
+ the bootstrap prompt-symbol probe only fired on owner-WebSocket reconnect.
25
+ On startup, each restored gated session whose `ownerPid` is still alive is
26
+ now actively probed (cmux path) or optimistically marked ready (non-cmux
27
+ path), with the chosen reason recorded for log attribution. Sessions whose
28
+ owner process is dead remain unready, matching the prior unready semantics.
29
+ - **#471 — `force: true` bypasses the bootstrap gate (`daemon.js:1969`).**
30
+ The per-request `force` escape hatch (`cli.js --submit-force`,
31
+ `TELEPTY_SUBMIT_FORCE_DEFAULT=1` from 0.4.4) was parsed correctly but the
32
+ bootstrap gate enqueued it and returned 504 long before the force-bypass
33
+ block at L1998 could run. Surgical 1-line condition edit: gate fires only
34
+ when `!force`. The force-bypass code path is now exercisable as documented.
35
+ - **#472 — codex prompt-symbol matcher normalized across environments
36
+ (`src/prompt-symbol-registry.js`).** On real cmux captures the codex `›`
37
+ glyph tail-renders on the same row as the model-status footer and DECRQM /
38
+ cursor-position-query fragments (`>4;0m>7u`, `0 q`) leak into the screen
39
+ buffer, so the prior strict line-leading scan permanently missed and the
40
+ session stuck at `ready:false`. New tolerant detector: (1) modal-UI
41
+ anti-pattern guard for resume picker, first-run directory-trust prompt and
42
+ generic press-enter-to-continue modals (treated as NOT ready); (2)
43
+ multi-signal match on `"OpenAI Codex (v"` plus `/gpt-[0-9.]+\s+\w+\s+fast/`
44
+ anywhere on the screen; (3) legacy strict line-leading scan preserved as a
45
+ back-compat fallback. `awaitPromptSymbol` now emits a single
46
+ `[bootstrap] <cli> ready via: <reason>` log line on stabilize, paired with
47
+ the #470 optimistic-ready logging for unified debuggability.
48
+
49
+ ### Notes — 0.4.5
50
+
51
+ - **Tests** — `npm test` passes 416 / 416 (was 411 / 411 in 0.4.4; +5 new
52
+ cases in `test/release-0.4.5-bugfixes.test.js` covering #469/#470/#471/#472
53
+ including env-resistance regression guard on the existing noforce test).
54
+ - **Snyk Code SAST** — all newly authored or modified JS files
55
+ (`scripts/postinstall.js`, `src/prompt-symbol-registry.js`,
56
+ `src/submit-gate.js`, new `daemon.js` line ranges, and
57
+ `test/release-0.4.5-bugfixes.test.js`) report 0 findings. The 55
58
+ pre-existing repo-wide findings in unchanged code are tracked separately as
59
+ task #474 (security cleanup track) and are not part of this release.
60
+ - **Out of scope, tracked separately** — task #473 (session-ID reuse → stale
61
+ command metadata) is queued for a 0.4.6 dispatch and is not addressed here.
62
+
7
63
  ## [0.4.4] - 2026-05-25
8
64
 
9
65
  ### Added — TELEPTY_SUBMIT_FORCE_DEFAULT env var (task #453)
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');
@@ -1966,7 +1966,10 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
1966
1966
 
1967
1967
  console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}${gateOff ? ' [gate=off]' : ''}`);
1968
1968
 
1969
- if (isBootstrapGatedSession(session) && (!isBootstrapReady(session) || hasBootstrapBacklog(session) || session.bootstrapDraining)) {
1969
+ // #471 (0.4.5): force=true must bypass the bootstrap gate. Without `!force`
1970
+ // here the per-request escape hatch (cli.js --submit-force) is enqueued and
1971
+ // 504s before the force-bypass block below ever runs.
1972
+ if (!force && isBootstrapGatedSession(session) && (!isBootstrapReady(session) || hasBootstrapBacklog(session) || session.bootstrapDraining)) {
1970
1973
  const op = enqueueBootstrapOperation(id, session, {
1971
1974
  type: 'submit',
1972
1975
  body: { ...(req.body || {}) }
@@ -2882,8 +2885,44 @@ app.patch('/api/threads/:id', (req, res) => {
2882
2885
 
2883
2886
  const server = app.listen(PORT, HOST, () => {
2884
2887
  console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
2888
+ runStartupBootstrapRestore();
2885
2889
  });
2886
2890
 
2891
+ // #470 (0.4.5): when the daemon restarts under existing telepty allow workers,
2892
+ // persisted sessions are restored at daemon.js:1244 but bootstrapReady stays
2893
+ // false until the owner WS reconnects — leaving every survivor session stuck
2894
+ // at ready:false indefinitely. Re-probe on startup: for cmux sessions whose
2895
+ // owner PID is still alive, run the WS-independent prompt-symbol probe; for
2896
+ // non-cmux survivors, optimistically mark ready (the underlying CLI is alive
2897
+ // and no probe primitive is available).
2898
+ function runStartupBootstrapRestore() {
2899
+ for (const [id, session] of Object.entries(sessions)) {
2900
+ if (!isBootstrapGatedSession(session) || isBootstrapReady(session)) continue;
2901
+ const ownerPid = Number(session.ownerPid);
2902
+ if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !isProcessRunning(ownerPid)) {
2903
+ continue;
2904
+ }
2905
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId) {
2906
+ submitGate.awaitPromptSymbol(session, { timeoutMs: 5000 })
2907
+ .then((result) => {
2908
+ if (result && result.ready) {
2909
+ markBootstrapReady(id, session, 'startup_restore');
2910
+ } else {
2911
+ markBootstrapReady(id, session, 'startup_owner_alive');
2912
+ console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, probe=${result?.reason || 'timeout'})`);
2913
+ }
2914
+ })
2915
+ .catch(() => {
2916
+ markBootstrapReady(id, session, 'startup_owner_alive');
2917
+ console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, probe=error)`);
2918
+ });
2919
+ } else {
2920
+ markBootstrapReady(id, session, 'startup_owner_alive');
2921
+ console.log(`[BOOTSTRAP] Optimistic ready for ${id} (ownerPid=${ownerPid}, backend=${session.backend || 'unknown'})`);
2922
+ }
2923
+ }
2924
+ }
2925
+
2887
2926
  // --- Mailbox system initialization ---
2888
2927
  const mailbox = new FileMailbox();
2889
2928
  const mailboxNotifier = new UnixSocketNotifier({ coalesceMs: 25 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
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
- "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 && git diff --exit-code tests/snippet-protocol/v1/",
37
- "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",
38
- "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 && git diff --exit-code tests/snippet-protocol/v1/",
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
+ })();
@@ -33,19 +33,49 @@ const ENTRIES = {
33
33
  return { found: false };
34
34
  },
35
35
  },
36
- // codex renders idle as " › <placeholder>" (column 2). Status footer
37
- // ("gpt-5.5 …" or "gpt-5 …") sits 1–2 lines below.
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 lines = String(screen == null ? '' : screen).split('\n');
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 };
@@ -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
- return { ready: true, last_seen_at: lastSeenAt, waited_ms: now() - start };
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