@dmsdc-ai/aigentry-telepty 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,68 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.6.3] - 2026-06-13
6
+
7
+ ### ⚠️ BREAKING — daemon binds 127.0.0.1 by default (#50)
8
+
9
+ - **The daemon (and broker host) now binds `127.0.0.1` instead of `0.0.0.0`.** A fresh install no
10
+ longer exposes the inject/control API to the local network. **Cross-machine setups where a peer
11
+ dials this daemon directly over LAN will stop working after the daemon restarts** — opt back in
12
+ explicitly on the daemon host with `TELEPTY_BIND=0.0.0.0` (the legacy `HOST` env override is
13
+ still honored; `TELEPTY_BIND` wins when both are set). The startup banner now prints the bind
14
+ address and a one-line exposure hint. SSH-tunnel peers (`telepty connect`) and the #42 broker
15
+ node mode (outbound-only) are unaffected.
16
+
17
+ ### Added — `telepty uninstall` + npm preuninstall hook (#49)
18
+
19
+ - **`telepty uninstall [--purge] [--dry-run]`**: stops running daemons (full discovery chain),
20
+ unloads **and removes** the launchd plists (`com.aigentry.telepty`, `com.aigentry.telepty-broker`)
21
+ on macOS, and reports the 3 state directories (`~/.telepty`, `~/.aigentry`,
22
+ `~/.config/aigentry-telepty`). **User data is kept by default** — the paths are printed; deletion
23
+ requires the explicit `--purge`. `--dry-run` reports without touching anything.
24
+ - **npm `preuninstall` hook**: daemon stop + plist unload only, quietly; it can never fail (a broken
25
+ hook would break `npm rm` itself). Note: npm 7+ no longer executes uninstall lifecycle scripts —
26
+ the reliable path is running `telepty uninstall` before `npm rm -g`.
27
+
28
+ ### Fixed — blocked daemon restarts: actionable diagnostic, no per-command noise (#15)
29
+
30
+ - When the running daemon cannot be stopped (no `daemon-state.json`, owned by a parent app such as
31
+ an aterm bundle, EPERM), the CLI used to retry the restart **3 times with backoff and repeat the
32
+ full mismatch + failure banner on every command**, even though sessions kept working. Now: the
33
+ discovery chain (state file → process-title scan → port-owner via `lsof`/`Get-NetTCPConnection`)
34
+ is checked once — if the port owner survives cleanup, the restart **fails fast** with one
35
+ actionable diagnostic naming the parent process (`Daemon (PID X) is owned by parent Y (pid Z) —
36
+ restart that app … or run: kill X && telepty daemon`), discovered via new
37
+ `findParentProcessInfo` (PPID lookup). An identical blocked state (same versions + blocking pid)
38
+ warns **once** and is then silent (`~/.telepty/restart-failure.json` marker) until the state
39
+ changes or a restart succeeds.
40
+
41
+ ### Fixed — `--help` is now always safe on payload subcommands (#51)
42
+
43
+ - `telepty broadcast --help` used to **broadcast the literal string `--help` to every active
44
+ session**, and `telepty allow --help` spawned a junk `<dir>---help` session. A bare `-h`/`--help`
45
+ before an explicit `--` separator now always prints the subcommand usage with zero network or
46
+ fan-out side effects (broadcast/multicast/inject/allow + aliases). Sending the literal text
47
+ requires the explicit separator: `telepty broadcast -- --help`. Defense-in-depth: broadcast and
48
+ multicast refuse a payload that is exactly a help flag unless `--` was used.
49
+
50
+ ## [0.6.2] - 2026-06-10
51
+
52
+ ### Fixed — TASK_IDLE_UNCONFIRMED false positives (#48)
53
+
54
+ - **`TASK_IDLE_UNCONFIRMED` fired ~0–0.5s after nearly every inject** even when the inject was
55
+ processed, destroying the signal's value. Two proven causes: (a) the bridge re-sends `ready` on
56
+ every TUI prompt-glyph redraw after an inject, and "working" evidence was only recorded on a
57
+ transition *into* working — so an inject landing on an already-working session left zero evidence
58
+ and the notifier fired on the first weak snapshot; (b) codex's spinner-less TUI (5s silence +
59
+ `›` prompt glyph) flips the real-idle classifier mid-work.
60
+ - **Fix: settle-and-recheck.** A would-be `TASK_IDLE_UNCONFIRMED` is held for
61
+ `TELEPTY_IDLE_UNCONFIRMED_SETTLE_SECONDS` (default 5) and re-checked against the **live** session
62
+ state: working/thinking → suppressed; output advanced while idle-classified → bounded re-settle
63
+ (`TELEPTY_IDLE_UNCONFIRMED_SETTLE_MAX_REARMS`, default 3); still idle and stalled → notify, so the
64
+ genuine "inject not consumed" signal is preserved. The report label is pinned at arm time, so the
65
+ settle window can never promote a stale idle snapshot to `TASK_COMPLETE` (the never-false-complete
66
+ invariant is kept). Message format is unchanged.
6
67
 
7
68
  ## [0.6.1] - 2026-06-09
8
69
 
package/cli.js CHANGED
@@ -11,7 +11,15 @@ const prompts = require('prompts');
11
11
  const updateNotifier = require('update-notifier');
12
12
  const pkg = require('./package.json');
13
13
  const { getConfig } = require('./auth');
14
- const { cleanupDaemonProcesses, readDaemonState, findPortOwnerPid } = require('./daemon-control');
14
+ const {
15
+ cleanupDaemonProcesses,
16
+ clearRestartFailureMarker,
17
+ findParentProcessInfo,
18
+ findPortOwnerPid,
19
+ readDaemonState,
20
+ readRestartFailureMarker,
21
+ writeRestartFailureMarker
22
+ } = require('./daemon-control');
15
23
  const { attachInteractiveTerminal, getTerminalSize, restoreTerminalModes } = require('./interactive-terminal');
16
24
  const { getRuntimeInfo } = require('./runtime-info');
17
25
  const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./session-routing');
@@ -434,13 +442,31 @@ async function waitForDaemonHealth(maxMs = 5000) {
434
442
  return null;
435
443
  }
436
444
 
445
+ // telepty#15: actionable diagnostic for a daemon the CLI cannot stop (foreign
446
+ // parent app owns it, EPERM, parent respawns it). Pure formatter, exposed for
447
+ // unit-testing — `parent` is findParentProcessInfo's { ppid, command } or null.
448
+ function formatDaemonStopDiagnostic({ pid, parent }) {
449
+ if (parent && parent.command) {
450
+ return `Daemon (PID ${pid}) is owned by parent ${parent.command} (pid ${parent.ppid}) — restart that app to update its bundled daemon, or run: kill ${pid} && telepty daemon`;
451
+ }
452
+ return `Daemon (PID ${pid}) could not be stopped — run: kill ${pid} && telepty daemon`;
453
+ }
454
+
437
455
  async function restartDaemonGraceful(options = {}) {
438
456
  const maxAttempts = options.maxAttempts || 3;
439
457
  const requiredCapabilities = options.requiredCapabilities || [];
458
+ // Injectable seams (default to the real implementations) so the blocked-restart
459
+ // path is unit-testable without touching a real daemon or process table (#15;
460
+ // same pattern as ensureDaemonRunning #567).
461
+ const cleanup = options._cleanupDaemonProcesses || cleanupDaemonProcesses;
462
+ const startDaemon = options._startDetachedDaemon || startDetachedDaemon;
463
+ const waitHealth = options._waitForDaemonHealth || waitForDaemonHealth;
464
+ const portOwner = options._findPortOwnerPid || findPortOwnerPid;
465
+ const parentInfo = options._findParentProcessInfo || findParentProcessInfo;
440
466
 
441
467
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
442
468
  // (a) Kill existing daemon processes
443
- const results = cleanupDaemonProcesses();
469
+ const results = cleanup();
444
470
 
445
471
  // (b) Wait up to 3s for old processes to fully exit
446
472
  if (results.stopped.length > 0) {
@@ -453,11 +479,22 @@ async function restartDaemonGraceful(options = {}) {
453
479
  }
454
480
  }
455
481
 
482
+ // telepty#15 fail-fast: when the port is still owned by a process cleanup did
483
+ // not stop (state file absent and unkillable, EPERM, foreign parent), starting
484
+ // a new daemon can never bind — the old "3 attempts with backoff" was pure
485
+ // noise. Stop retrying and emit one actionable diagnostic instead.
486
+ const survivingOwner = portOwner(Number(PORT));
487
+ if (Number.isInteger(survivingOwner) && survivingOwner > 0 && survivingOwner !== process.pid) {
488
+ const diagnostic = formatDaemonStopDiagnostic({ pid: survivingOwner, parent: parentInfo(survivingOwner) });
489
+ console.error(`\x1b[31m❌ Daemon restart blocked: ${diagnostic}\x1b[0m`);
490
+ return { success: false, meta: null, attempt, blockedPid: survivingOwner, diagnostic };
491
+ }
492
+
456
493
  // (c) Start new daemon
457
- startDetachedDaemon();
494
+ startDaemon();
458
495
 
459
496
  // (d) Wait for new daemon to respond with correct version
460
- const meta = await waitForDaemonHealth(5000);
497
+ const meta = await waitHealth(5000);
461
498
  if (meta && meta.version === pkg.version) {
462
499
  const hasCapabilities = requiredCapabilities.every(c => (meta.capabilities || []).includes(c));
463
500
  if (hasCapabilities || requiredCapabilities.length === 0) {
@@ -711,6 +748,10 @@ async function ensureDaemonRunning(options = {}) {
711
748
  const getMeta = options._getDaemonMeta || getDaemonMeta;
712
749
  const fetchAuth = options._fetchWithAuth || fetchWithAuth;
713
750
  const doRestart = options._restartDaemonGraceful || restartDaemonGraceful;
751
+ const portOwner = options._findPortOwnerPid || findPortOwnerPid;
752
+ const readFailureMarker = options._readRestartFailureMarker || readRestartFailureMarker;
753
+ const writeFailureMarker = options._writeRestartFailureMarker || writeRestartFailureMarker;
754
+ const clearFailureMarker = options._clearRestartFailureMarker || clearRestartFailureMarker;
714
755
  const probe = options._probe || {};
715
756
  const attempts = probe.attempts || 3;
716
757
  const backoffMs = probe.backoffMs == null ? 200 : probe.backoffMs;
@@ -749,6 +790,21 @@ async function ensureDaemonRunning(options = {}) {
749
790
  return; // healthy + correct version + all capabilities → leave the daemon alone (#567)
750
791
  }
751
792
 
793
+ // telepty#15: a restart blocked by a daemon the CLI cannot stop (foreign parent
794
+ // app, EPERM) used to re-warn and re-fail on EVERY command. After warning once,
795
+ // an identical blocked state (same versions + same blocking pid) stays silent —
796
+ // sessions keep working through the old daemon — until the signature changes
797
+ // (daemon upgraded/killed, parent restarted) or a restart succeeds.
798
+ let signature = null;
799
+ if (decision.action === 'restart') {
800
+ const ownerPid = portOwner(Number(PORT));
801
+ signature = `${decision.reason}:${meta && meta.version ? meta.version : 'none'}->${pkg.version}:pid${ownerPid || 0}`;
802
+ const marker = readFailureMarker();
803
+ if (marker && marker.signature === signature) {
804
+ return; // already warned for exactly this blocked state — stay quiet
805
+ }
806
+ }
807
+
752
808
  // stderr (not stdout): banner must not contaminate `telepty list --json` (task #400, telepty#15)
753
809
  if (decision.action === 'restart' && decision.reason.startsWith('version-')) {
754
810
  process.stderr.write(`\x1b[33m⚙️ Daemon version mismatch (running v${meta.version}, installed v${pkg.version}). Restarting...\x1b[0m\n`);
@@ -759,7 +815,16 @@ async function ensureDaemonRunning(options = {}) {
759
815
  } else {
760
816
  process.stderr.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
761
817
  }
762
- await doRestart({ requiredCapabilities });
818
+ const result = await doRestart({ requiredCapabilities });
819
+ if (signature && result && result.success === false && result.blockedPid) {
820
+ writeFailureMarker({
821
+ signature: `${decision.reason}:${meta && meta.version ? meta.version : 'none'}->${pkg.version}:pid${result.blockedPid}`,
822
+ diagnostic: result.diagnostic || null,
823
+ warnedAt: new Date().toISOString()
824
+ });
825
+ } else if (result && result.success) {
826
+ clearFailureMarker();
827
+ }
763
828
  }
764
829
 
765
830
  async function manageInteractiveAttach(sessionId, targetHost) {
@@ -1013,9 +1078,70 @@ async function manageInteractive() {
1013
1078
  }
1014
1079
  }
1015
1080
 
1081
+ // telepty#51: trailing-payload subcommands (broadcast/multicast/inject/allow) collect
1082
+ // free-form text, which swallowed `--help`/`-h` as DATA — `telepty broadcast --help`
1083
+ // fanned the literal string out to every active session, and `telepty allow --help`
1084
+ // spawned a junk `<dir>---help` session. One shared interceptor (DRY) runs BEFORE each
1085
+ // payload parser: a bare `-h`/`--help` appearing before an explicit `--` separator
1086
+ // prints the subcommand usage and stops — zero network / fan-out side effects.
1087
+ // `telepty <subcommand> -- --help` remains the deliberate way to send the literal text.
1088
+ const TRAILING_PAYLOAD_HELP = {
1089
+ allow: [
1090
+ 'Usage: telepty allow [--id <session_id>] [--idle-ttl <duration|off>] [--auto-restart] <command> [args...]',
1091
+ '',
1092
+ 'Wrap a CLI so other sessions can inject into it. Aliases: enable, wrap.',
1093
+ 'Use `--` before the command to pass hyphenated arguments literally:',
1094
+ ' telepty allow -- claude --help'
1095
+ ],
1096
+ inject: [
1097
+ 'Usage: telepty inject [--ref [file]] [--from <id>] [--reply-to <id>] [--submit] [--submit-force] [--submit-retry N] <session_id> "<prompt text>"',
1098
+ '',
1099
+ 'Inject prompt text into a session. Use `--` before the prompt to send a payload',
1100
+ 'that starts with a hyphen: telepty inject my-session -- --help'
1101
+ ],
1102
+ multicast: [
1103
+ 'Usage: telepty multicast <id1,id2,...> "<prompt text>"',
1104
+ '',
1105
+ 'Inject prompt text into multiple sessions. Use `--` before the prompt to send',
1106
+ 'a payload that starts with a hyphen: telepty multicast id1,id2 -- --help'
1107
+ ],
1108
+ broadcast: [
1109
+ 'Usage: telepty broadcast [--ref [file]] "<prompt text>"',
1110
+ '',
1111
+ 'Inject prompt text into ALL active sessions. Use `--` before the prompt to send',
1112
+ 'a payload that starts with a hyphen: telepty broadcast -- --help'
1113
+ ]
1114
+ };
1115
+
1116
+ // True when a bare `-h`/`--help` token appears before the first `--` separator
1117
+ // (everything after `--` is literal payload by universal CLI convention).
1118
+ function helpRequested(argv) {
1119
+ for (const arg of argv) {
1120
+ if (arg === '--') return false;
1121
+ if (arg === '--help' || arg === '-h') return true;
1122
+ }
1123
+ return false;
1124
+ }
1125
+
1126
+ function interceptSubcommandHelp(cmd, argv) {
1127
+ const canonical = (cmd === 'enable' || cmd === 'wrap') ? 'allow' : cmd;
1128
+ const lines = TRAILING_PAYLOAD_HELP[canonical];
1129
+ if (!lines || !helpRequested(argv)) return false;
1130
+ console.log(lines.join('\n'));
1131
+ return true;
1132
+ }
1133
+
1134
+ // telepty#51 defense-in-depth: even if help interception regresses, broadcast/multicast
1135
+ // must never fan out a bare help flag to every session. Literal sends remain possible
1136
+ // via the explicit `--` separator (which sets hadSeparator at the call site).
1137
+ function isHelpLikePayload(payload) {
1138
+ const text = String(payload || '').trim();
1139
+ return text === '--help' || text === '-h';
1140
+ }
1141
+
1016
1142
  async function main() {
1017
1143
  const cmd = args[0];
1018
-
1144
+
1019
1145
  if (!cmd) {
1020
1146
  return manageInteractive();
1021
1147
  }
@@ -1047,6 +1173,53 @@ async function main() {
1047
1173
  return;
1048
1174
  }
1049
1175
 
1176
+ if (cmd === 'uninstall') {
1177
+ // telepty#49: stop the daemon, unload+remove the launchd plist (macOS), and
1178
+ // report the state dirs. User data is KEPT by default — deleted only with
1179
+ // --purge. --dry-run prints what would happen without touching anything.
1180
+ const purge = args.includes('--purge');
1181
+ const dryRun = args.includes('--dry-run');
1182
+ const { runUninstall } = require('./src/uninstall');
1183
+ const result = runUninstall({ purge, dryRun });
1184
+ const would = dryRun ? 'Would ' : '';
1185
+
1186
+ if (dryRun) {
1187
+ console.log('🔎 Dry run — nothing was stopped, unloaded, or deleted.');
1188
+ console.log(`${would}stop any running telepty daemons (state file → process scan → port owner).`);
1189
+ } else {
1190
+ console.log(`Stopped ${result.stopped.length} telepty daemon(s).`);
1191
+ if (result.failed.length > 0) {
1192
+ console.log(`⚠️ Failed to stop ${result.failed.length} daemon(s) — run "telepty cleanup-daemons" or kill them manually.`);
1193
+ }
1194
+ }
1195
+
1196
+ for (const plist of result.plists) {
1197
+ if (!plist.existed) continue;
1198
+ if (dryRun) {
1199
+ console.log(`${would}unload and remove launchd service: ${plist.path}`);
1200
+ } else {
1201
+ console.log(`Launchd service ${plist.removed ? 'unloaded and removed' : (plist.unloaded ? 'unloaded (file not removed)' : 'removal attempted')}: ${plist.path}`);
1202
+ }
1203
+ }
1204
+
1205
+ const existingDirs = result.stateDirs.filter((d) => d.exists);
1206
+ if (purge) {
1207
+ for (const dir of existingDirs) {
1208
+ console.log(dryRun ? `${would}delete state directory: ${dir.path}` : `${dir.purged ? 'Deleted' : '⚠️ Failed to delete'} state directory: ${dir.path}`);
1209
+ }
1210
+ } else if (existingDirs.length > 0) {
1211
+ console.log('State directories kept (user data — delete with `telepty uninstall --purge`):');
1212
+ for (const dir of existingDirs) {
1213
+ console.log(` - ${dir.path}`);
1214
+ }
1215
+ }
1216
+
1217
+ if (!dryRun) {
1218
+ console.log('\nNow remove the package: npm rm -g @dmsdc-ai/aigentry-telepty');
1219
+ }
1220
+ return;
1221
+ }
1222
+
1050
1223
  if (cmd === 'cleanup-daemons') {
1051
1224
  const results = cleanupDaemonProcesses();
1052
1225
  console.log(`Stopped ${results.stopped.length} telepty daemon(s).`);
@@ -1279,6 +1452,7 @@ async function main() {
1279
1452
  }
1280
1453
 
1281
1454
  if (cmd === 'allow' || cmd === 'enable' || cmd === 'wrap') {
1455
+ if (interceptSubcommandHelp(cmd, args.slice(1))) return; // telepty#51: never wrap "--help" as a command
1282
1456
  // Parse arguments: telepty allow [--id <session_id>] <command> [args...]
1283
1457
  // Also supports legacy: telepty allow [--id <session_id>] -- <command> [args...]
1284
1458
  const allowArgs = args.slice(1);
@@ -2046,6 +2220,11 @@ async function main() {
2046
2220
  }
2047
2221
 
2048
2222
  if (cmd === 'inject') {
2223
+ if (interceptSubcommandHelp(cmd, args.slice(1))) return; // telepty#51: help must never become the injected prompt
2224
+ // telepty#51: an explicit `--` separator marks the rest as literal payload
2225
+ // (e.g. `telepty inject my-session -- --help` sends the literal text).
2226
+ const injectSepIndex = args.indexOf('--');
2227
+ if (injectSepIndex !== -1) args.splice(injectSepIndex, 1);
2049
2228
  const { useRef, refFilePath } = parseRefOption(args);
2050
2229
 
2051
2230
  if (args.includes('--no-enter')) {
@@ -2520,8 +2699,19 @@ async function main() {
2520
2699
  }
2521
2700
 
2522
2701
  if (cmd === 'multicast') {
2702
+ if (interceptSubcommandHelp(cmd, args.slice(1))) return; // telepty#51: help must never fan out as data
2703
+ const multicastSepIndex = args.indexOf('--');
2704
+ const multicastHadSeparator = multicastSepIndex !== -1;
2705
+ if (multicastHadSeparator) args.splice(multicastSepIndex, 1);
2523
2706
  const sessionIdsRaw = args[1]; const prompt = args.slice(2).join(' ');
2524
2707
  if (!sessionIdsRaw || !prompt) { console.error('❌ Usage: telepty multicast <id1,id2,...> "<prompt text>"'); process.exit(1); }
2708
+ // telepty#51 defense-in-depth: a payload that is exactly a help flag is almost
2709
+ // certainly a swallowed `--help`, never a real prompt. Refuse unless the caller
2710
+ // opted into the literal send with an explicit `--`.
2711
+ if (!multicastHadSeparator && isHelpLikePayload(prompt)) {
2712
+ console.error('❌ Refusing to multicast a bare help flag. Use `telepty multicast --help` for usage, or `telepty multicast <ids> -- --help` to send the literal text.');
2713
+ process.exit(1);
2714
+ }
2525
2715
  const sessionRefs = sessionIdsRaw.split(',').map(s => s.trim()).filter(s => s);
2526
2716
  try {
2527
2717
  const discovered = await discoverSessions({ silent: true });
@@ -2561,10 +2751,21 @@ async function main() {
2561
2751
  }
2562
2752
 
2563
2753
  if (cmd === 'broadcast') {
2754
+ if (interceptSubcommandHelp(cmd, args.slice(1))) return; // telepty#51: help must never fan out to every session
2755
+ const broadcastSepIndex = args.indexOf('--');
2756
+ const broadcastHadSeparator = broadcastSepIndex !== -1;
2757
+ if (broadcastHadSeparator) args.splice(broadcastSepIndex, 1);
2564
2758
  const { useRef, refFilePath } = parseRefOption(args);
2565
2759
 
2566
2760
  const prompt = args.slice(1).join(' ');
2567
2761
  if (!prompt && !refFilePath) { console.error('❌ Usage: telepty broadcast [--ref [file]] "<prompt text>"'); process.exit(1); }
2762
+ // telepty#51 defense-in-depth: a payload that is exactly a help flag is almost
2763
+ // certainly a swallowed `--help`, never a real prompt. Refuse unless the caller
2764
+ // opted into the literal send with an explicit `--`.
2765
+ if (!broadcastHadSeparator && isHelpLikePayload(prompt)) {
2766
+ console.error('❌ Refusing to broadcast a bare help flag to every session. Use `telepty broadcast --help` for usage, or `telepty broadcast -- --help` to send the literal text.');
2767
+ process.exit(1);
2768
+ }
2568
2769
  try {
2569
2770
  const discovered = await discoverSessions({ silent: true });
2570
2771
  const aggregate = { successful: [], failed: [] };
@@ -3787,6 +3988,7 @@ Discuss the following topic from your project's perspective. Engage with other s
3787
3988
 
3788
3989
  \x1b[1mOther:\x1b[0m
3789
3990
  telepty update Update to latest version
3991
+ telepty uninstall [--purge] [--dry-run] Stop daemon + unload service; keep user data unless --purge
3790
3992
  telepty layout [grid|tall|stack] Arrange terminal windows
3791
3993
  telepty status-report --phase <p> [options] Emit structured status event
3792
3994
 
@@ -3821,4 +4023,8 @@ module.exports = {
3821
4023
  sanitizePathArg, // #26: path-arg validation/normalization
3822
4024
  decideDaemonAction, // #567: pure restart-decision policy (meta-primary; no I/O)
3823
4025
  ensureDaemonRunning, // #567: orchestrator (injectable probes for unit-testing)
4026
+ helpRequested, // telepty#51: bare -h/--help before `--` → show help, not payload
4027
+ isHelpLikePayload, // telepty#51: defense-in-depth payload guard for broadcast/multicast
4028
+ formatDaemonStopDiagnostic, // telepty#15: actionable can't-stop-daemon diagnostic (pure)
4029
+ restartDaemonGraceful, // telepty#15: injectable seams for the blocked-restart fail-fast path
3824
4030
  };
package/daemon-control.js CHANGED
@@ -275,6 +275,85 @@ function probeTeleptyOnPort(port, opts) {
275
275
  });
276
276
  }
277
277
 
278
+ // telepty#15: identify who launched a daemon we cannot stop (e.g. an older
279
+ // daemon bundled inside a parent app such as aterm), so the CLI can print an
280
+ // actionable diagnostic instead of retrying blindly. Returns
281
+ // { ppid, command } for `pid`'s parent, or null when it cannot be resolved.
282
+ function findParentProcessInfo(pid, opts) {
283
+ if (!Number.isInteger(pid) || pid <= 0) return null;
284
+ const o = opts || {};
285
+ const platform = o.platform || process.platform;
286
+ const exec = o.execSync || execSync;
287
+
288
+ try {
289
+ if (platform === 'win32') {
290
+ const script =
291
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; ` +
292
+ `if ($p) { $pp = Get-CimInstance Win32_Process -Filter "ProcessId=$($p.ParentProcessId)"; ` +
293
+ `"$($p.ParentProcessId)|$(if ($pp) { $pp.Name } else { '' })" }`;
294
+ const output = String(exec(`powershell.exe -NoProfile -Command "${script}"`, {
295
+ encoding: 'utf8',
296
+ stdio: ['ignore', 'pipe', 'ignore']
297
+ })).trim();
298
+ if (!output) return null;
299
+ const [ppidRaw, name] = output.split('|');
300
+ const ppid = Number(ppidRaw);
301
+ if (!Number.isInteger(ppid) || ppid <= 0) return null;
302
+ return { ppid, command: (name || '').trim() || null };
303
+ }
304
+
305
+ const ppidRaw = String(exec(`ps -p ${pid} -o ppid=`, {
306
+ encoding: 'utf8',
307
+ stdio: ['ignore', 'pipe', 'ignore']
308
+ })).trim();
309
+ const ppid = Number(ppidRaw);
310
+ if (!Number.isInteger(ppid) || ppid <= 0) return null;
311
+ const command = String(exec(`ps -p ${ppid} -o comm=`, {
312
+ encoding: 'utf8',
313
+ stdio: ['ignore', 'pipe', 'ignore']
314
+ })).trim() || null;
315
+ return { ppid, command };
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
321
+ // telepty#15: once-per-mismatch warning marker. A daemon the CLI cannot stop
322
+ // (foreign-owned port, EPERM, parent respawns it) used to produce the full
323
+ // mismatch + 3-retries + failure banner on EVERY command. The CLI records the
324
+ // blocked state's signature here after warning once; identical signatures are
325
+ // then silent until the blocking pid / version pair changes or a restart
326
+ // succeeds. `opts.file` is an injectable path so tests never touch ~/.telepty.
327
+ const RESTART_FAILURE_FILE = path.join(TELEPTY_DIR, 'restart-failure.json');
328
+
329
+ function readRestartFailureMarker(opts) {
330
+ const file = (opts && opts.file) || RESTART_FAILURE_FILE;
331
+ try {
332
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+
338
+ function writeRestartFailureMarker(marker, opts) {
339
+ const file = (opts && opts.file) || RESTART_FAILURE_FILE;
340
+ try {
341
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
342
+ fs.writeFileSync(file, JSON.stringify(marker, null, 2), { mode: 0o600 });
343
+ } catch {
344
+ // Best-effort: losing the marker only means a repeated warning, never a failure.
345
+ }
346
+ }
347
+
348
+ function clearRestartFailureMarker(opts) {
349
+ const file = (opts && opts.file) || RESTART_FAILURE_FILE;
350
+ try {
351
+ fs.rmSync(file, { force: true });
352
+ } catch {
353
+ // Best-effort (see writeRestartFailureMarker).
354
+ }
355
+ }
356
+
278
357
  // Confirm via local process scan that `pid`'s command line looks like a
279
358
  // telepty daemon (fallback when HTTP probe fails — daemon may be stuck).
280
359
  function pidMatchesTeleptyCmdline(pid) {
@@ -343,11 +422,15 @@ module.exports = {
343
422
  claimDaemonState,
344
423
  cleanupDaemonProcesses,
345
424
  clearDaemonState,
425
+ clearRestartFailureMarker,
426
+ findParentProcessInfo,
346
427
  findPortOwnerPid,
347
428
  isLikelyTeleptyDaemon,
348
429
  isProcessRunning,
349
430
  listDaemonProcesses,
350
431
  pidMatchesTeleptyCmdline,
351
432
  probeTeleptyOnPort,
352
- readDaemonState
433
+ readDaemonState,
434
+ readRestartFailureMarker,
435
+ writeRestartFailureMarker
353
436
  };
package/daemon.js CHANGED
@@ -18,6 +18,7 @@ const { UnixSocketNotifier } = require('./src/mailbox/notifier');
18
18
  const { SessionStateManager, STATE_DISPLAY, stripAnsi: stripAnsiState } = require('./session-state');
19
19
  const { classifyReportPrompt, buildAutoSummary } = require('./src/report-enforcement');
20
20
  const submitGate = require('./src/submit-gate');
21
+ const { sampleChildCpuSeconds } = require('./src/child-cpu'); // #52: quiet-thinking CPU recheck
21
22
  const readyRegistry = require('./src/prompt-symbol-registry');
22
23
  const lifecycle = require('./src/lifecycle');
23
24
  const { SURFACE_ORPHAN_SECONDS, SURFACE_MISMATCH_SECONDS, decideSurfaceGc, applySurfaceMismatchProbe } = lifecycle;
@@ -265,7 +266,25 @@ const PORT = process.env.PORT || 3848;
265
266
  // Reported by /api/meta so callers (e.g. the test harness) can read it back.
266
267
  let boundPort = Number(PORT);
267
268
 
268
- const HOST = process.env.HOST || '0.0.0.0';
269
+ // telepty#50: bind loopback by default — a fresh install must not expose the
270
+ // inject/control API to the local network. Network exposure is an explicit
271
+ // opt-in: TELEPTY_BIND=0.0.0.0 (preferred) or the legacy HOST override.
272
+ // BREAKING: cross-machine peers that dialed this daemon directly over LAN
273
+ // need the opt-in on the daemon host after a restart (see CHANGELOG).
274
+ function resolveBindHost(env) {
275
+ return env.TELEPTY_BIND || env.HOST || '127.0.0.1';
276
+ }
277
+
278
+ // One-line bind-address banner so operators can see (and fix) their exposure
279
+ // posture at startup without reading docs.
280
+ function formatBindHint(host) {
281
+ if (host === '127.0.0.1' || host === 'localhost' || host === '::1') {
282
+ return ` bind: ${host} (loopback only) — LAN peers cannot connect; opt in with TELEPTY_BIND=0.0.0.0`;
283
+ }
284
+ return ` bind: ${host} — reachable from the network (TELEPTY_BIND/HOST opt-in)`;
285
+ }
286
+
287
+ const HOST = resolveBindHost(process.env);
269
288
  process.title = 'telepty-daemon';
270
289
 
271
290
  // Singleton claim — guarded so a test require neither exits (when a daemon is running) nor
@@ -286,6 +305,21 @@ const AUTO_REPORT_IDLE_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_IDLE_SEC
286
305
  // by the recipient. Below this elapsed floor the idle is NOT trusted as a processed-inject
287
306
  // completion; the text-inject is relabeled so a stuck/hung target is never reported as DONE.
288
307
  const AUTO_REPORT_MIN_REAL_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_MIN_REAL_SECONDS) || 1.0;
308
+ // #48: a momentary idle/ready snapshot right after an inject (the bridge re-sends 'ready' on a
309
+ // TUI prompt-glyph redraw; codex's silence+glyph flips real-idle mid-work) is almost always a
310
+ // transition-gap false positive — the session is, or moments later is, working. Before emitting
311
+ // TASK_IDLE_UNCONFIRMED, hold for this settle window and recheck the LIVE session state.
312
+ const IDLE_UNCONFIRMED_SETTLE_SECONDS = Number(process.env.TELEPTY_IDLE_UNCONFIRMED_SETTLE_SECONDS) || 5;
313
+ // Output advanced during the settle window while still idle-classified (sparse TUI redraw) →
314
+ // re-settle, bounded so periodic idle redraws cannot starve the genuinely-unconsumed signal.
315
+ const IDLE_UNCONFIRMED_SETTLE_MAX_REARMS = Math.max(0, Number(process.env.TELEPTY_IDLE_UNCONFIRMED_SETTLE_MAX_REARMS) || 3);
316
+ // #52: codex quiet-thinking (no output, no spinner) outlasts the settle chain — the recheck
317
+ // consults the same screen classifier that produced the false idle. Auxiliary heuristic: a
318
+ // wrapped child whose CPU time advanced ≥ this delta across the settle window is working
319
+ // (quiet thinking) — re-settle instead of notifying, on its own (larger) bound so a long
320
+ // no-output stretch is survivable while a pathological always-busy child still signals.
321
+ const IDLE_UNCONFIRMED_CPU_DELTA_SECONDS = Number(process.env.TELEPTY_IDLE_UNCONFIRMED_CPU_DELTA_SECONDS) || 0.1;
322
+ const IDLE_UNCONFIRMED_CPU_MAX_REARMS = Math.max(0, Number(process.env.TELEPTY_IDLE_UNCONFIRMED_CPU_MAX_REARMS) || 24);
289
323
 
290
324
  function pendingReportHasSubmitEvidence(pendingReport) {
291
325
  return !!(pendingReport && (
@@ -295,6 +329,31 @@ function pendingReportHasSubmitEvidence(pendingReport) {
295
329
  ));
296
330
  }
297
331
 
332
+ // #52: the TASK_IDLE_UNCONFIRMED semantic is "inject may NOT have been processed" — gate it
333
+ // on CONSUMPTION evidence the daemon already owns instead of screen idleness. Evidence:
334
+ // - a screen-VERIFIED submit confirmation (body consumed from the composer, or a state
335
+ // transition observed after the CR) — 'force'/ambiguous accepts are NOT verification;
336
+ // - the injected body echoed in PTY frames appended after the inject (composer/transcript
337
+ // redraw), matched conservatively (submit-gate observeInjectEcho).
338
+ // A definitively failed submit (accepted:false — body observed stuck in the composer /
339
+ // no-land) is positive NON-consumption and can never be overridden by echo, so the
340
+ // never-false-complete invariant of #48 holds: a genuinely unconsumed inject still signals.
341
+ function observeConsumptionEvidence(pendingReport, session) {
342
+ const confirm = pendingReport.submitConfirm;
343
+ if (confirm && confirm.accepted === false) {
344
+ return { observed: false, reason: 'submit_failed' };
345
+ }
346
+ if (confirm && confirm.accepted === true && !confirm.ambiguous
347
+ && (confirm.reason === 'body_consumed' || /^state_(working|thinking)$/.test(String(confirm.reason)))) {
348
+ return { observed: true, reason: `submit_${confirm.reason}` };
349
+ }
350
+ const echo = submitGate.observeInjectEcho(session, pendingReport.injectedBodyPreview, {
351
+ sinceBytes: Number.isFinite(pendingReport.ringBytesAtInject) ? pendingReport.ringBytesAtInject : null,
352
+ stripAnsi: stripAnsiState,
353
+ });
354
+ return { observed: echo.observed === true, reason: echo.reason };
355
+ }
356
+
298
357
  function getPendingReport(sessionId, registry = pendingReports) {
299
358
  if (typeof sessionId !== 'string') return null;
300
359
  if (sessionId === '__proto__' || sessionId === 'prototype' || sessionId === 'constructor') return null;
@@ -358,6 +417,14 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
358
417
  const _sessions = deps.sessions || sessions;
359
418
  const _pendingReports = deps.pendingReports || pendingReports;
360
419
  const _deliver = deps.deliverInjectionToSession || deliverInjectionToSession;
420
+ // #48: live auto-state lookup for the settle recheck (DI for unit tests).
421
+ const _getAutoState = deps.getAutoState || ((sid) => {
422
+ const st = sessionStateManager.getState(sid);
423
+ return st && st.state ? st.state : null;
424
+ });
425
+ // #52: wrapped-child CPU sampler for the quiet-thinking recheck (DI for unit tests).
426
+ const _sampleChildCpu = deps.sampleChildCpu || ((sess) =>
427
+ sampleChildCpuSeconds(sess ? (sess.ptyPid || (sess.ptyProcess && sess.ptyProcess.pid) || null) : null));
361
428
 
362
429
  const elapsedNum = (_now() - new Date(pendingReport.injectedAt).getTime()) / 1000;
363
430
  const elapsed = elapsedNum.toFixed(1);
@@ -390,25 +457,6 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
390
457
  }
391
458
  }
392
459
 
393
- pendingReport.idleNotified = true;
394
- pendingReport.idleAt = new Date(_now()).toISOString();
395
-
396
- // Richer bus event (observability) — now also carries the trigger provenance.
397
- _broadcast('TASK_IDLE_NO_REPORT', targetId, targetSession, {
398
- extra: {
399
- source: pendingReport.source,
400
- inject_id: pendingReport.injectId,
401
- elapsed_secs: Number(elapsed),
402
- injected_at: pendingReport.injectedAt,
403
- trigger
404
- }
405
- });
406
- console.log(`[ENFORCE-REPORT] ${targetId} idle after ${elapsed}s (trigger=${trigger}) — awaiting REPORT from ${pendingReport.source}`);
407
-
408
- const srcId = _resolveAlias(pendingReport.source) || pendingReport.source;
409
- const srcSession = _sessions[srcId];
410
- if (!srcSession) return;
411
-
412
460
  // #537 / Bug B: a never-started worker (transient submit failure → claude startup
413
461
  // busy→idle settle at ~4.5s) must NOT be reported TASK_COMPLETE. When a submit was
414
462
  // expected, the elapsed floor and startup-polluted sawWorkingAfterInject are NOT trusted
@@ -428,13 +476,110 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
428
476
  const idleEvidenceUnreliable = trigger === 'real-idle'
429
477
  && pendingReport.submitExpected
430
478
  && deps.idleEvidenceReliable === false;
431
- const confirmed = trigger === 'ready-signal' && pendingReport.submitExpected
479
+ // #48: a settled recheck re-enters ONLY to emit the UNCONFIRMED label — pinned at arm time,
480
+ // so elapsed growing past the floor during the settle window can never promote a stale idle
481
+ // snapshot to TASK_COMPLETE (never a false complete).
482
+ const confirmed = pendingReport.unconfirmedSettleDone
432
483
  ? false
433
- : idleEvidenceUnreliable
484
+ : trigger === 'ready-signal' && pendingReport.submitExpected
434
485
  ? false
435
- : pendingReport.submitExpected
436
- ? strongSubmitConfirmed
437
- : (elapsedNum >= AUTO_REPORT_MIN_REAL_SECONDS || hasSubmitEvidence);
486
+ : idleEvidenceUnreliable
487
+ ? false
488
+ : pendingReport.submitExpected
489
+ ? strongSubmitConfirmed
490
+ : (elapsedNum >= AUTO_REPORT_MIN_REAL_SECONDS || hasSubmitEvidence);
491
+
492
+ // #48: settle-and-recheck before any UNCONFIRMED notification. The first weak idle/ready
493
+ // snapshot right after an inject is almost always a transition gap — the bridge re-sends
494
+ // 'ready' on a TUI prompt-glyph redraw (with no state transition, no evidence flag is ever
495
+ // set even though the session IS working), and codex's silence+glyph heuristic flips
496
+ // real-idle mid-work. Hold the notification for a settle window and recheck the LIVE
497
+ // session: notify only when it is still not working AND its output has not advanced.
498
+ // Suppression does NOT consume the once-only idleNotified guard, so a later genuine
499
+ // busy→idle transition re-enters this path (and an evidence-backed one reports COMPLETE).
500
+ if (!confirmed && !pendingReport.unconfirmedSettleDone) {
501
+ if (pendingReport.unconfirmedSettleTimer) return; // settle window already open
502
+ const settleMs = Math.max(50, Math.round(IDLE_UNCONFIRMED_SETTLE_SECONDS * 1000));
503
+ const armSettle = () => {
504
+ const liveAtArm = _sessions[targetId] || targetSession;
505
+ const activityAtArm = liveAtArm ? liveAtArm.lastActivityAt : null;
506
+ const cpuAtArm = _sampleChildCpu(liveAtArm); // #52: null when unobservable
507
+ pendingReport.unconfirmedSettleTimer = _setTimeout(() => {
508
+ pendingReport.unconfirmedSettleTimer = null;
509
+ const currentPending = getPendingReport(targetId, _pendingReports);
510
+ // REPORT arrived / entry replaced / another path already notified — stand down.
511
+ if (currentPending !== pendingReport || currentPending.idleNotified) return;
512
+ const liveSession = _sessions[targetId] || targetSession;
513
+ const autoState = _getAutoState(targetId);
514
+ if (autoState === 'working' || autoState === 'thinking') {
515
+ console.log(`[AUTO-REPORT] ${targetId} idle-unconfirmed suppressed after settle — session is ${autoState} (trigger=${trigger})`);
516
+ return;
517
+ }
518
+ const activityNow = liveSession ? liveSession.lastActivityAt : null;
519
+ if (activityNow !== activityAtArm
520
+ && (pendingReport.unconfirmedSettleRearms || 0) < IDLE_UNCONFIRMED_SETTLE_MAX_REARMS) {
521
+ pendingReport.unconfirmedSettleRearms = (pendingReport.unconfirmedSettleRearms || 0) + 1;
522
+ console.log(`[AUTO-REPORT] ${targetId} output advanced during settle — re-settling (${pendingReport.unconfirmedSettleRearms}/${IDLE_UNCONFIRMED_SETTLE_MAX_REARMS})`);
523
+ armSettle();
524
+ return;
525
+ }
526
+ // #52: screen idle + output stalled, but the wrapped child's CPU time advanced
527
+ // across the settle window → quiet thinking (codex no-spinner blind spot). Treat
528
+ // as working: re-settle on its own bound instead of notifying.
529
+ const cpuNow = _sampleChildCpu(liveSession);
530
+ if (cpuAtArm != null && cpuNow != null
531
+ && (cpuNow - cpuAtArm) >= IDLE_UNCONFIRMED_CPU_DELTA_SECONDS
532
+ && (pendingReport.unconfirmedCpuRearms || 0) < IDLE_UNCONFIRMED_CPU_MAX_REARMS) {
533
+ pendingReport.unconfirmedCpuRearms = (pendingReport.unconfirmedCpuRearms || 0) + 1;
534
+ console.log(`[AUTO-REPORT] ${targetId} child CPU advanced ${(cpuNow - cpuAtArm).toFixed(2)}s during settle — quiet-thinking; re-settling (${pendingReport.unconfirmedCpuRearms}/${IDLE_UNCONFIRMED_CPU_MAX_REARMS})`);
535
+ armSettle();
536
+ return;
537
+ }
538
+ pendingReport.unconfirmedSettleDone = true;
539
+ fireAutoReport(targetId, liveSession || targetSession, currentPending, trigger, deps);
540
+ }, settleMs);
541
+ };
542
+ armSettle();
543
+ console.log(`[AUTO-REPORT] ${targetId} idle unconfirmed at ${elapsed}s (trigger=${trigger}) — settling ${IDLE_UNCONFIRMED_SETTLE_SECONDS}s before notify`);
544
+ return;
545
+ }
546
+
547
+ // #52: before emitting the unconfirmed-DELIVERY warning, check for inject-consumption
548
+ // evidence (screen-verified submit / post-inject echo). Idle-looking + consumed is at
549
+ // most a TASK_IDLE fact — not "inject may NOT have been processed". Suppression does not
550
+ // consume the once-only idleNotified guard, so a later evidence-backed genuine busy→idle
551
+ // transition can still report TASK_COMPLETE, and the pending entry stays armed until the
552
+ // worker's content REPORT arrives. Confirmed completions (confirmed === true) are
553
+ // untouched — this gate only ever silences a would-be false warning, never a signal that
554
+ // a genuinely unconsumed inject produced (no echo + no verified submit ⇒ falls through).
555
+ if (!confirmed) {
556
+ const _observeConsumption = deps.observeConsumptionEvidence || observeConsumptionEvidence;
557
+ const consumption = _observeConsumption(pendingReport, _sessions[targetId] || targetSession);
558
+ if (consumption.observed) {
559
+ console.log(`[AUTO-REPORT] ${targetId} idle-unconfirmed suppressed — inject consumption observed (${consumption.reason}, trigger=${trigger})`);
560
+ return;
561
+ }
562
+ }
563
+
564
+ pendingReport.idleNotified = true;
565
+ pendingReport.idleAt = new Date(_now()).toISOString();
566
+
567
+ // Richer bus event (observability) — now also carries the trigger provenance.
568
+ _broadcast('TASK_IDLE_NO_REPORT', targetId, targetSession, {
569
+ extra: {
570
+ source: pendingReport.source,
571
+ inject_id: pendingReport.injectId,
572
+ elapsed_secs: Number(elapsed),
573
+ injected_at: pendingReport.injectedAt,
574
+ trigger
575
+ }
576
+ });
577
+ console.log(`[ENFORCE-REPORT] ${targetId} idle after ${elapsed}s (trigger=${trigger}) — awaiting REPORT from ${pendingReport.source}`);
578
+
579
+ const srcId = _resolveAlias(pendingReport.source) || pendingReport.source;
580
+ const srcSession = _sessions[srcId];
581
+ if (!srcSession) return;
582
+
438
583
  const injTag = pendingReport.injectId ? ` inject=${pendingReport.injectId}` : '';
439
584
  const reportMsg = confirmed
440
585
  ? `TASK_COMPLETE: ${targetId} is now idle after processing inject (${elapsed}s, via ${trigger}${injTag})`
@@ -1552,6 +1697,9 @@ async function deliverInjectionToSession(id, session, prompt, options = {}) {
1552
1697
 
1553
1698
  function appendToOutputRing(session, data) {
1554
1699
  if (!session.outputRing) session.outputRing = [];
1700
+ // #52: monotonic byte counter — the inject-time watermark that scopes echo-evidence
1701
+ // matching to frames appended AFTER the inject (survives ring trimming below).
1702
+ session.outputRingTotalBytes = (session.outputRingTotalBytes || 0) + data.length;
1555
1703
  session.outputRing.push(data);
1556
1704
  // Keep total data under ~200KB limit by trimming old entries
1557
1705
  let totalLen = session.outputRing.reduce((sum, d) => sum + d.length, 0);
@@ -2943,6 +3091,8 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
2943
3091
  submitExpected: !!no_enter,
2944
3092
  noEnter: !!no_enter,
2945
3093
  injectedBodyPreview: prompt.slice(0, 500),
3094
+ // #52: echo-evidence watermark — only frames appended after this inject count.
3095
+ ringBytesAtInject: session.outputRingTotalBytes || 0,
2946
3096
  awaitingReport: true,
2947
3097
  idleNotified: false
2948
3098
  };
@@ -3701,6 +3851,7 @@ if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1')
3701
3851
  const address = server.address();
3702
3852
  boundPort = (address && address.port) || Number(PORT);
3703
3853
  console.log(`🔐 aigentry-telepty broker listening on https://${HOST}:${boundPort} (/broker/*)`);
3854
+ console.log(formatBindHint(HOST)); // telepty#50
3704
3855
  runStartupBootstrapRestore();
3705
3856
  });
3706
3857
  } else {
@@ -3708,6 +3859,7 @@ if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1')
3708
3859
  const address = server.address();
3709
3860
  boundPort = (address && address.port) || Number(PORT);
3710
3861
  console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${boundPort}`);
3862
+ console.log(formatBindHint(HOST)); // telepty#50
3711
3863
  runStartupBootstrapRestore();
3712
3864
  // #42 node-mode (§2F-ii): start the broker-client if broker config is present.
3713
3865
  // Absent ⇒ no-op (default-OFF). Started after listen so sessions/delivery are live.
@@ -4090,4 +4242,6 @@ module.exports = {
4090
4242
  loadNodeBrokerConfig, // node-mode: resolve broker.json / env config (or null)
4091
4243
  startNodeBrokerClient, // node-mode: start createBrokerClient (default-OFF; in-process deliver)
4092
4244
  deliverInjectionToSession, // §4.3: the in-process delivery wired into the broker-client
4245
+ resolveBindHost, // telepty#50: pure bind-address policy (loopback default, env opt-in)
4246
+ formatBindHint, // telepty#50: startup bind/exposure banner line
4093
4247
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -29,15 +29,17 @@
29
29
  "install.ps1",
30
30
  "mcp-server/",
31
31
  "scripts/postinstall.js",
32
+ "scripts/preuninstall.js",
32
33
  "src/",
33
34
  "skills/",
34
35
  "CHANGELOG.md"
35
36
  ],
36
37
  "scripts": {
37
38
  "postinstall": "node scripts/postinstall.js",
38
- "test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-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/install-service-generation.test.js test/install-broker-service.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.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 test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
- "test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-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/install-service-generation.test.js test/install-broker-service.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.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 test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
40
- "test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-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/install-service-generation.test.js test/install-broker-service.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.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 test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
+ "preuninstall": "node scripts/preuninstall.js",
40
+ "test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-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/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.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 test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
41
+ "test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-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/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.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 test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
42
+ "test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-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/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.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 test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
41
43
  "typecheck": "tsc --noEmit",
42
44
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
43
45
  },
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // telepty#49 — npm preuninstall hook: stop the running daemon and unload the
5
+ // launchd plist QUIETLY before the package files disappear, so `npm rm -g`
6
+ // does not leave a live daemon answering on 3848 or a plist that launchd keeps
7
+ // trying to resurrect. State directories and the plist file itself are left to
8
+ // the explicit `telepty uninstall [--purge]` path.
9
+ //
10
+ // NOTE: npm 7+ no longer executes uninstall lifecycle scripts — this hook
11
+ // covers npm 6 and package managers that still honor it. The documented,
12
+ // reliable cleanup path is `telepty uninstall` (run it BEFORE `npm rm -g`).
13
+ //
14
+ // HARD RULE: this script must never fail — a broken preuninstall would break
15
+ // `npm rm` itself, which is strictly worse than leaving a daemon running.
16
+
17
+ try {
18
+ const { runPreuninstall } = require('../src/uninstall');
19
+ runPreuninstall();
20
+ } catch {
21
+ // Swallow everything — see HARD RULE above.
22
+ }
23
+ process.exit(0);
@@ -0,0 +1,54 @@
1
+ // src/child-cpu.js — wrapped-child CPU-time sampling for the #52 idle-unconfirmed recheck.
2
+ //
3
+ // codex quiet-thinking is TTY-silent but process-active: at settle-recheck time the
4
+ // daemon samples the wrapped child's cumulative CPU time and treats an advancing value
5
+ // as working evidence (screen idle && CPU advancing → re-settle instead of notifying).
6
+ //
7
+ // Constitution §1/§17: no new dependency — `ps -o time=` on POSIX (macOS/Linux), and a
8
+ // clean null fallback on win32 / missing pid / exec failure so callers keep the prior
9
+ // (#48) behavior whenever CPU is unobservable.
10
+ //
11
+ // Pure parse + DI exec/platform seams for unit tests.
12
+
13
+ 'use strict';
14
+
15
+ // Parse a ps(1) TIME value into seconds. Accepted shapes:
16
+ // macOS: MM:SS.cc (e.g. "0:01.23")
17
+ // Linux: [DD-]HH:MM:SS (e.g. "00:00:05", "1-02:03:04")
18
+ // Returns null for anything unparseable.
19
+ function parsePsTimeSeconds(raw) {
20
+ const s = String(raw == null ? '' : raw).trim();
21
+ if (!s) return null;
22
+ const dayMatch = s.match(/^(\d+)-(.+)$/);
23
+ const days = dayMatch ? Number(dayMatch[1]) : 0;
24
+ const rest = dayMatch ? dayMatch[2] : s;
25
+ const parts = rest.split(':');
26
+ if (parts.length < 2 || parts.length > 3) return null;
27
+ if (parts.some((p) => !/^\d+(\.\d+)?$/.test(p))) return null;
28
+ const nums = parts.map(Number);
29
+ const seconds = parts.length === 3
30
+ ? nums[0] * 3600 + nums[1] * 60 + nums[2]
31
+ : nums[0] * 60 + nums[1];
32
+ return days * 86400 + seconds;
33
+ }
34
+
35
+ // Sample the cumulative CPU time (seconds) of `pid`, or null when unobservable
36
+ // (no/invalid pid, win32, ps failure). Bounded: 500ms exec timeout.
37
+ function sampleChildCpuSeconds(pid, opts = {}) {
38
+ const numericPid = Number(pid);
39
+ if (!Number.isInteger(numericPid) || numericPid <= 0) return null;
40
+ const platform = opts.platform || process.platform;
41
+ if (platform === 'win32') return null;
42
+ const execFileSync = opts.execFileSync || require('child_process').execFileSync;
43
+ try {
44
+ const out = execFileSync('ps', ['-o', 'time=', '-p', String(numericPid)], {
45
+ timeout: 500,
46
+ stdio: ['ignore', 'pipe', 'ignore'],
47
+ });
48
+ return parsePsTimeSeconds(String(out));
49
+ } catch (_err) {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ module.exports = { parsePsTimeSeconds, sampleChildCpuSeconds };
@@ -379,6 +379,65 @@ async function awaitInputSettled(session, bodyText, opts = {}) {
379
379
  }
380
380
  }
381
381
 
382
+ // #52: observe whether the injected body was ECHOED by the target TUI in frames
383
+ // appended AFTER the inject (composer/transcript redraw) — consumption evidence that
384
+ // gates the TASK_IDLE_UNCONFIRMED warning. Conservative by design (never-false-complete):
385
+ // - the normalized body must be ≥ minChars (short/common strings claim nothing);
386
+ // - matching samples fixed-length windows of the body (step minChars/2) so a echo
387
+ // wrapped across bordered composer lines is still observed, while requiring
388
+ // `needed` distinct window hits for long bodies;
389
+ // - only frames past the `sinceBytes` watermark count, and a window that ALSO appears
390
+ // in the pre-inject portion of the ring is discarded — a redraw of an identical
391
+ // earlier message is NOT fresh echo.
392
+ // Pure: outputRing-only, DI stripAnsi — no I/O, no daemon coupling.
393
+ function observeInjectEcho(session, bodyText, opts = {}) {
394
+ const minChars = Number.isFinite(opts.minChars) ? opts.minChars : 24;
395
+ const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
396
+ const sinceBytes = Number.isFinite(opts.sinceBytes) ? opts.sinceBytes : null;
397
+
398
+ const body = normalize(bodyText);
399
+ if (body.length < minChars) {
400
+ return { observed: false, reason: body.length === 0 ? 'empty_body' : 'body_too_short' };
401
+ }
402
+ if (!session || !Array.isArray(session.outputRing) || session.outputRing.length === 0) {
403
+ return { observed: false, reason: 'no_ring' };
404
+ }
405
+
406
+ // Split the ring at the inject watermark: only post-inject frames may witness echo,
407
+ // and pre-inject frames veto windows that already existed on screen before the inject.
408
+ let preRaw = '';
409
+ let postRaw = '';
410
+ if (sinceBytes !== null && Number.isFinite(session.outputRingTotalBytes)) {
411
+ const appended = Math.max(0, session.outputRingTotalBytes - sinceBytes);
412
+ if (appended === 0) return { observed: false, reason: 'no_frames_since_inject' };
413
+ const all = session.outputRing.join('');
414
+ const splitAt = Math.max(0, all.length - appended);
415
+ preRaw = all.slice(0, splitAt);
416
+ postRaw = all.slice(splitAt);
417
+ } else {
418
+ // No watermark (legacy entry) — treat the whole ring as post-inject, with no veto.
419
+ postRaw = session.outputRing.join('');
420
+ }
421
+ const post = normalize(stripAnsi(postRaw));
422
+ const pre = normalize(stripAnsi(preRaw));
423
+
424
+ const step = Math.max(1, Math.floor(minChars / 2));
425
+ const windows = [];
426
+ for (let i = 0; i + minChars <= body.length; i += step) {
427
+ windows.push(body.slice(i, i + minChars));
428
+ }
429
+
430
+ const needed = body.length >= minChars * 2 ? 2 : 1;
431
+ let hits = 0;
432
+ for (const w of windows) {
433
+ if (post.indexOf(w) !== -1 && pre.indexOf(w) === -1) {
434
+ hits++;
435
+ if (hits >= needed) return { observed: true, reason: 'echo', windows_matched: hits };
436
+ }
437
+ }
438
+ return { observed: false, reason: 'no_echo', windows_matched: hits };
439
+ }
440
+
382
441
  function isAcceptedSubmitState(state, submittedAtMs) {
383
442
  if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
384
443
  if (!Number.isFinite(submittedAtMs)) {
@@ -553,6 +612,7 @@ module.exports = {
553
612
  verifyBodyConsumed,
554
613
  confirmSubmitAccepted,
555
614
  observeBodyVisibility,
615
+ observeInjectEcho,
556
616
  awaitPromptSymbol,
557
617
  defaultReadScreen,
558
618
  isReady,
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // telepty#49 — uninstall hygiene. `npm rm -g` used to leave a live daemon (port
4
+ // 3848 kept answering), a loaded launchd plist (launchd may resurrect a binary
5
+ // that no longer exists), and 3 undocumented state directories behind.
6
+ //
7
+ // Two entry points share this module:
8
+ // - `telepty uninstall [--purge] [--dry-run]` (cli.js): full pass — stop
9
+ // daemons, unload+remove the launchd plists (macOS), and report the state
10
+ // dirs (kept by default; deleted only with --purge).
11
+ // - scripts/preuninstall.js (npm lifecycle): quiet partial pass — daemon stop
12
+ // + plist unload ONLY, never throws, so `npm rm` can never be broken by it.
13
+ //
14
+ // Every platform interaction (process cleanup, launchctl, fs) is injectable so
15
+ // the logic is unit-testable without touching a live daemon or launchd.
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const { execFileSync } = require('child_process');
21
+
22
+ // Daemon + broker service labels generated by install.js.
23
+ const LAUNCHD_PLIST_NAMES = ['com.aigentry.telepty.plist', 'com.aigentry.telepty-broker.plist'];
24
+
25
+ function stateDirPaths(homedir) {
26
+ return [
27
+ path.join(homedir, '.telepty'),
28
+ path.join(homedir, '.aigentry'),
29
+ path.join(homedir, '.config', 'aigentry-telepty')
30
+ ];
31
+ }
32
+
33
+ function launchdPlistPaths(homedir) {
34
+ return LAUNCHD_PLIST_NAMES.map((name) => path.join(homedir, 'Library', 'LaunchAgents', name));
35
+ }
36
+
37
+ // What an uninstall on this platform would touch. Pure (no I/O).
38
+ function planUninstall(opts = {}) {
39
+ const platform = opts.platform || process.platform;
40
+ const homedir = opts.homedir || os.homedir();
41
+ return {
42
+ platform,
43
+ // launchd is macOS-only; on linux/windows the generated service (systemd
44
+ // unit / scheduled task) is reported as a manual step instead of guessed at.
45
+ plists: platform === 'darwin' ? launchdPlistPaths(homedir) : [],
46
+ stateDirs: stateDirPaths(homedir)
47
+ };
48
+ }
49
+
50
+ function runUninstall(opts = {}) {
51
+ const purge = Boolean(opts.purge);
52
+ const dryRun = Boolean(opts.dryRun);
53
+ const removePlists = opts.removePlists !== false;
54
+ const execFile = opts.execFileSync || execFileSync;
55
+ const fsx = opts.fs || fs;
56
+ // Late-require keeps module load free of side effects and the seam injectable.
57
+ const cleanup = opts.cleanupDaemonProcesses || require('../daemon-control').cleanupDaemonProcesses;
58
+ const plan = planUninstall(opts);
59
+
60
+ const result = {
61
+ dryRun,
62
+ purge,
63
+ platform: plan.platform,
64
+ stopped: [],
65
+ failed: [],
66
+ plists: [],
67
+ stateDirs: []
68
+ };
69
+
70
+ // (1) Stop running daemons via the full discovery chain (state file →
71
+ // process-title scan → port owner; daemon-control, telepty#15/#44).
72
+ if (!dryRun) {
73
+ try {
74
+ const stopResult = cleanup();
75
+ result.stopped = stopResult.stopped || [];
76
+ result.failed = stopResult.failed || [];
77
+ } catch {
78
+ // Best-effort: a failed scan must not block the rest of the uninstall.
79
+ }
80
+ }
81
+
82
+ // (2) launchd service (macOS): unload so launchd stops resurrecting the
83
+ // daemon, then remove the plist file (skipped for the preuninstall hook).
84
+ for (const plistPath of plan.plists) {
85
+ const entry = { path: plistPath, existed: false, unloaded: false, removed: false };
86
+ try {
87
+ entry.existed = fsx.existsSync(plistPath);
88
+ } catch {
89
+ entry.existed = false;
90
+ }
91
+ if (entry.existed && !dryRun) {
92
+ try {
93
+ execFile('launchctl', ['unload', plistPath], { stdio: ['ignore', 'ignore', 'ignore'] });
94
+ entry.unloaded = true;
95
+ } catch {
96
+ entry.unloaded = false; // already unloaded / not loaded — fine either way
97
+ }
98
+ if (removePlists) {
99
+ try {
100
+ fsx.rmSync(plistPath, { force: true });
101
+ entry.removed = true;
102
+ } catch {
103
+ entry.removed = false;
104
+ }
105
+ }
106
+ }
107
+ result.plists.push(entry);
108
+ }
109
+
110
+ // (3) State directories: user data stays by default — callers print the
111
+ // paths so the leftover is documented, not silent. Deleted only with --purge.
112
+ for (const dirPath of plan.stateDirs) {
113
+ const entry = { path: dirPath, exists: false, purged: false };
114
+ try {
115
+ entry.exists = fsx.existsSync(dirPath);
116
+ } catch {
117
+ entry.exists = false;
118
+ }
119
+ if (purge && entry.exists && !dryRun) {
120
+ try {
121
+ fsx.rmSync(dirPath, { recursive: true, force: true });
122
+ entry.purged = true;
123
+ } catch {
124
+ entry.purged = false;
125
+ }
126
+ }
127
+ result.stateDirs.push(entry);
128
+ }
129
+
130
+ return result;
131
+ }
132
+
133
+ // npm preuninstall lifecycle pass: daemon stop + plist unload only, quietly.
134
+ // Never throws — breaking `npm rm` is strictly worse than leaving a daemon.
135
+ function runPreuninstall(opts = {}) {
136
+ try {
137
+ return runUninstall({ ...opts, purge: false, dryRun: false, removePlists: false });
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ module.exports = {
144
+ LAUNCHD_PLIST_NAMES,
145
+ planUninstall,
146
+ runPreuninstall,
147
+ runUninstall
148
+ };