@dmsdc-ai/aigentry-telepty 0.6.2 → 0.6.4

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,64 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.6.4] - 2026-06-13
6
+
7
+ ### Added — inject consumption-evidence: consumed | queued | unknown (#53)
8
+
9
+ - **`telepty inject` now distinguishes "delivered" from "consumed".** After the CR
10
+ (`pty_cr`), the daemon captures an output-ring watermark and `classifyInjectConsumption()`
11
+ / `verifyBodyConsumed()` (reusing the #52 echo-watermark technique) classify the result:
12
+ **consumed** (composer cleared + new turn rendered), **queued** (injected text persists
13
+ in a busy TUI composer), or **unknown** (conservative). The `/submit` response and CLI
14
+ output now carry this status; a `queued` result on a busy orchestrator TUI prints a
15
+ pull-fallback hint. Closes the "`Submitted` reads as success but the busy recipient never
16
+ consumed it" gap (observed 3+ times in a single orchestration wave). Backward-compatible
17
+ (accepted/retryable semantics and exit codes unchanged; response is a superset).
18
+
19
+ ## [0.6.3] - 2026-06-13
20
+
21
+ ### ⚠️ BREAKING — daemon binds 127.0.0.1 by default (#50)
22
+
23
+ - **The daemon (and broker host) now binds `127.0.0.1` instead of `0.0.0.0`.** A fresh install no
24
+ longer exposes the inject/control API to the local network. **Cross-machine setups where a peer
25
+ dials this daemon directly over LAN will stop working after the daemon restarts** — opt back in
26
+ explicitly on the daemon host with `TELEPTY_BIND=0.0.0.0` (the legacy `HOST` env override is
27
+ still honored; `TELEPTY_BIND` wins when both are set). The startup banner now prints the bind
28
+ address and a one-line exposure hint. SSH-tunnel peers (`telepty connect`) and the #42 broker
29
+ node mode (outbound-only) are unaffected.
30
+
31
+ ### Added — `telepty uninstall` + npm preuninstall hook (#49)
32
+
33
+ - **`telepty uninstall [--purge] [--dry-run]`**: stops running daemons (full discovery chain),
34
+ unloads **and removes** the launchd plists (`com.aigentry.telepty`, `com.aigentry.telepty-broker`)
35
+ on macOS, and reports the 3 state directories (`~/.telepty`, `~/.aigentry`,
36
+ `~/.config/aigentry-telepty`). **User data is kept by default** — the paths are printed; deletion
37
+ requires the explicit `--purge`. `--dry-run` reports without touching anything.
38
+ - **npm `preuninstall` hook**: daemon stop + plist unload only, quietly; it can never fail (a broken
39
+ hook would break `npm rm` itself). Note: npm 7+ no longer executes uninstall lifecycle scripts —
40
+ the reliable path is running `telepty uninstall` before `npm rm -g`.
41
+
42
+ ### Fixed — blocked daemon restarts: actionable diagnostic, no per-command noise (#15)
43
+
44
+ - When the running daemon cannot be stopped (no `daemon-state.json`, owned by a parent app such as
45
+ an aterm bundle, EPERM), the CLI used to retry the restart **3 times with backoff and repeat the
46
+ full mismatch + failure banner on every command**, even though sessions kept working. Now: the
47
+ discovery chain (state file → process-title scan → port-owner via `lsof`/`Get-NetTCPConnection`)
48
+ is checked once — if the port owner survives cleanup, the restart **fails fast** with one
49
+ actionable diagnostic naming the parent process (`Daemon (PID X) is owned by parent Y (pid Z) —
50
+ restart that app … or run: kill X && telepty daemon`), discovered via new
51
+ `findParentProcessInfo` (PPID lookup). An identical blocked state (same versions + blocking pid)
52
+ warns **once** and is then silent (`~/.telepty/restart-failure.json` marker) until the state
53
+ changes or a restart succeeds.
54
+
55
+ ### Fixed — `--help` is now always safe on payload subcommands (#51)
56
+
57
+ - `telepty broadcast --help` used to **broadcast the literal string `--help` to every active
58
+ session**, and `telepty allow --help` spawned a junk `<dir>---help` session. A bare `-h`/`--help`
59
+ before an explicit `--` separator now always prints the subcommand usage with zero network or
60
+ fan-out side effects (broadcast/multicast/inject/allow + aliases). Sending the literal text
61
+ requires the explicit separator: `telepty broadcast -- --help`. Defense-in-depth: broadcast and
62
+ multicast refuse a payload that is exactly a help flag unless `--` was used.
6
63
 
7
64
  ## [0.6.2] - 2026-06-10
8
65
 
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')) {
@@ -2244,7 +2423,21 @@ async function main() {
2244
2423
  : '';
2245
2424
  const attemptsNote = submitData.attempts > 1 ? ` (${submitData.attempts} attempts)` : '';
2246
2425
  const forcedNote = submitData.forced ? ' [forced]' : '';
2247
- console.log(`✅ Submitted via ${submitData.strategy}${attemptsNote}${gateNote}${lateNote}${forcedNote}.`);
2426
+ const tail = `${attemptsNote}${gateNote}${lateNote}${forcedNote}`;
2427
+ // #53: distinguish CONSUMED-as-a-turn from QUEUED-in-a-busy-composer. A bare
2428
+ // "Submitted via pty_cr" only proves bytes reached the PTY; a busy recipient TUI
2429
+ // parks the text without firing a turn, so report that instead of a false success.
2430
+ const consumption = submitData.consumption
2431
+ || (submitData.verify && submitData.verify.consumption) || null;
2432
+ if (consumption === 'queued') {
2433
+ console.log(`⚠️ Submitted via ${submitData.strategy}${tail}, but recipient is BUSY — text QUEUED, NOT consumed as a new turn. It will be processed after the current turn ends; if a reply is expected, fall back to pulling the recipient's state.`);
2434
+ } else if (consumption === 'consumed') {
2435
+ console.log(`✅ Submitted via ${submitData.strategy}${tail} — consumed as a new turn.`);
2436
+ } else if (consumption === 'unknown') {
2437
+ console.log(`✅ Submitted via ${submitData.strategy}${tail} (consumption=unknown — delivered to PTY; turn-consumption not observable).`);
2438
+ } else {
2439
+ console.log(`✅ Submitted via ${submitData.strategy}${tail}.`);
2440
+ }
2248
2441
  } else if (submitRes && submitRes.status === 504) {
2249
2442
  // Soft failure: REPL never readied. Orchestrator scripts depend on
2250
2443
  // exit 0 here — surface a clear remediation hint but do not exit
@@ -2520,8 +2713,19 @@ async function main() {
2520
2713
  }
2521
2714
 
2522
2715
  if (cmd === 'multicast') {
2716
+ if (interceptSubcommandHelp(cmd, args.slice(1))) return; // telepty#51: help must never fan out as data
2717
+ const multicastSepIndex = args.indexOf('--');
2718
+ const multicastHadSeparator = multicastSepIndex !== -1;
2719
+ if (multicastHadSeparator) args.splice(multicastSepIndex, 1);
2523
2720
  const sessionIdsRaw = args[1]; const prompt = args.slice(2).join(' ');
2524
2721
  if (!sessionIdsRaw || !prompt) { console.error('❌ Usage: telepty multicast <id1,id2,...> "<prompt text>"'); process.exit(1); }
2722
+ // telepty#51 defense-in-depth: a payload that is exactly a help flag is almost
2723
+ // certainly a swallowed `--help`, never a real prompt. Refuse unless the caller
2724
+ // opted into the literal send with an explicit `--`.
2725
+ if (!multicastHadSeparator && isHelpLikePayload(prompt)) {
2726
+ 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.');
2727
+ process.exit(1);
2728
+ }
2525
2729
  const sessionRefs = sessionIdsRaw.split(',').map(s => s.trim()).filter(s => s);
2526
2730
  try {
2527
2731
  const discovered = await discoverSessions({ silent: true });
@@ -2561,10 +2765,21 @@ async function main() {
2561
2765
  }
2562
2766
 
2563
2767
  if (cmd === 'broadcast') {
2768
+ if (interceptSubcommandHelp(cmd, args.slice(1))) return; // telepty#51: help must never fan out to every session
2769
+ const broadcastSepIndex = args.indexOf('--');
2770
+ const broadcastHadSeparator = broadcastSepIndex !== -1;
2771
+ if (broadcastHadSeparator) args.splice(broadcastSepIndex, 1);
2564
2772
  const { useRef, refFilePath } = parseRefOption(args);
2565
2773
 
2566
2774
  const prompt = args.slice(1).join(' ');
2567
2775
  if (!prompt && !refFilePath) { console.error('❌ Usage: telepty broadcast [--ref [file]] "<prompt text>"'); process.exit(1); }
2776
+ // telepty#51 defense-in-depth: a payload that is exactly a help flag is almost
2777
+ // certainly a swallowed `--help`, never a real prompt. Refuse unless the caller
2778
+ // opted into the literal send with an explicit `--`.
2779
+ if (!broadcastHadSeparator && isHelpLikePayload(prompt)) {
2780
+ 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.');
2781
+ process.exit(1);
2782
+ }
2568
2783
  try {
2569
2784
  const discovered = await discoverSessions({ silent: true });
2570
2785
  const aggregate = { successful: [], failed: [] };
@@ -3787,6 +4002,7 @@ Discuss the following topic from your project's perspective. Engage with other s
3787
4002
 
3788
4003
  \x1b[1mOther:\x1b[0m
3789
4004
  telepty update Update to latest version
4005
+ telepty uninstall [--purge] [--dry-run] Stop daemon + unload service; keep user data unless --purge
3790
4006
  telepty layout [grid|tall|stack] Arrange terminal windows
3791
4007
  telepty status-report --phase <p> [options] Emit structured status event
3792
4008
 
@@ -3821,4 +4037,8 @@ module.exports = {
3821
4037
  sanitizePathArg, // #26: path-arg validation/normalization
3822
4038
  decideDaemonAction, // #567: pure restart-decision policy (meta-primary; no I/O)
3823
4039
  ensureDaemonRunning, // #567: orchestrator (injectable probes for unit-testing)
4040
+ helpRequested, // telepty#51: bare -h/--help before `--` → show help, not payload
4041
+ isHelpLikePayload, // telepty#51: defense-in-depth payload guard for broadcast/multicast
4042
+ formatDaemonStopDiagnostic, // telepty#15: actionable can't-stop-daemon diagnostic (pure)
4043
+ restartDaemonGraceful, // telepty#15: injectable seams for the blocked-restart fail-fast path
3824
4044
  };
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
@@ -294,6 +313,13 @@ const IDLE_UNCONFIRMED_SETTLE_SECONDS = Number(process.env.TELEPTY_IDLE_UNCONFIR
294
313
  // Output advanced during the settle window while still idle-classified (sparse TUI redraw) →
295
314
  // re-settle, bounded so periodic idle redraws cannot starve the genuinely-unconsumed signal.
296
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);
297
323
 
298
324
  function pendingReportHasSubmitEvidence(pendingReport) {
299
325
  return !!(pendingReport && (
@@ -303,6 +329,31 @@ function pendingReportHasSubmitEvidence(pendingReport) {
303
329
  ));
304
330
  }
305
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
+
306
357
  function getPendingReport(sessionId, registry = pendingReports) {
307
358
  if (typeof sessionId !== 'string') return null;
308
359
  if (sessionId === '__proto__' || sessionId === 'prototype' || sessionId === 'constructor') return null;
@@ -371,6 +422,9 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
371
422
  const st = sessionStateManager.getState(sid);
372
423
  return st && st.state ? st.state : null;
373
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));
374
428
 
375
429
  const elapsedNum = (_now() - new Date(pendingReport.injectedAt).getTime()) / 1000;
376
430
  const elapsed = elapsedNum.toFixed(1);
@@ -449,6 +503,7 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
449
503
  const armSettle = () => {
450
504
  const liveAtArm = _sessions[targetId] || targetSession;
451
505
  const activityAtArm = liveAtArm ? liveAtArm.lastActivityAt : null;
506
+ const cpuAtArm = _sampleChildCpu(liveAtArm); // #52: null when unobservable
452
507
  pendingReport.unconfirmedSettleTimer = _setTimeout(() => {
453
508
  pendingReport.unconfirmedSettleTimer = null;
454
509
  const currentPending = getPendingReport(targetId, _pendingReports);
@@ -468,6 +523,18 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
468
523
  armSettle();
469
524
  return;
470
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
+ }
471
538
  pendingReport.unconfirmedSettleDone = true;
472
539
  fireAutoReport(targetId, liveSession || targetSession, currentPending, trigger, deps);
473
540
  }, settleMs);
@@ -477,6 +544,23 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
477
544
  return;
478
545
  }
479
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
+
480
564
  pendingReport.idleNotified = true;
481
565
  pendingReport.idleAt = new Date(_now()).toISOString();
482
566
 
@@ -1613,6 +1697,9 @@ async function deliverInjectionToSession(id, session, prompt, options = {}) {
1613
1697
 
1614
1698
  function appendToOutputRing(session, data) {
1615
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;
1616
1703
  session.outputRing.push(data);
1617
1704
  // Keep total data under ~200KB limit by trimming old entries
1618
1705
  let totalLen = session.outputRing.reduce((sum, d) => sum + d.length, 0);
@@ -2728,6 +2815,9 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2728
2815
  const settleEnabled = req.body?.input_settle_gate !== false;
2729
2816
  let strategy = await gatedTerminalSubmit(id, session, injectedBody, settleEnabled);
2730
2817
  let submittedAtMs = Date.now();
2818
+ // #53: outputRing watermark at the CR — scopes consumption-evidence matching to frames
2819
+ // appended AFTER this submit (composer redraw / new-turn render), surviving ring trimming.
2820
+ let ringBytesAtSubmit = session.outputRingTotalBytes || 0;
2731
2821
  let attempts = strategy ? 1 : 0;
2732
2822
  if (!strategy) {
2733
2823
  if (injectedBody) {
@@ -2749,12 +2839,15 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2749
2839
  // shot is enough. A retry is idempotent only when the body is still visible.
2750
2840
  let verify = null;
2751
2841
  let confirm = null;
2842
+ let consumption = null; // #53: 'consumed' | 'queued' | 'unknown'
2843
+ let consumptionReason = null;
2752
2844
  if (injectedBody && injectedBody.length > 0) {
2753
2845
  confirm = await confirmSubmitAfterDispatch(id, session, injectedBody, submittedAtMs, verifyTimeoutMs);
2754
2846
  while (confirm && !confirm.accepted && confirm.retryable && attempts <= retries) {
2755
2847
  await new Promise(resolve => setTimeout(resolve, retryDelayMs));
2756
2848
  const retryStrategy = await gatedTerminalSubmit(id, session, injectedBody, settleEnabled);
2757
2849
  submittedAtMs = Date.now();
2850
+ ringBytesAtSubmit = session.outputRingTotalBytes || 0;
2758
2851
  if (!retryStrategy) break;
2759
2852
  strategy = retryStrategy;
2760
2853
  attempts++;
@@ -2762,6 +2855,25 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2762
2855
  }
2763
2856
  verify = buildSubmitVerify(confirm);
2764
2857
 
2858
+ // #53: consumption-evidence on the DELIVERY path. `confirm.accepted` can read a BUSY
2859
+ // recipient's mid-turn output as success (the isAcceptedSubmitState last_output_at leak),
2860
+ // so additionally classify whether the body was CONSUMED as a fresh turn vs QUEUED in a
2861
+ // busy composer vs UNKNOWN — and surface it to the caller. Advisory + additive: it does
2862
+ // NOT change accepted/retryable (back-compat); it only tells the sender what telepty can
2863
+ // actually observe past the PTY layer. Conservative (never-false-consumed).
2864
+ const consumptionResult = await submitGate.classifyInjectConsumption(session, injectedBody, {
2865
+ submittedAtMs,
2866
+ sinceBytes: ringBytesAtSubmit,
2867
+ getState: () => sessionStateManager.getState(id),
2868
+ stripAnsi: stripAnsiState,
2869
+ });
2870
+ consumption = consumptionResult.status;
2871
+ consumptionReason = consumptionResult.reason;
2872
+ if (verify) {
2873
+ verify.consumption = consumptionResult.status;
2874
+ verify.consumption_reason = consumptionResult.reason;
2875
+ }
2876
+
2765
2877
  if (confirm && !confirm.accepted) {
2766
2878
  const reason = gatedDispatchAfterTimeout ? 'gated_dispatch_unconsumed' : 'submit_unconfirmed';
2767
2879
  const failBody = {
@@ -2776,6 +2888,7 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2776
2888
  gate_wait_ms: gateResult.waited_ms,
2777
2889
  verify,
2778
2890
  confirm,
2891
+ ...(consumption ? { consumption, consumption_reason: consumptionReason } : {}),
2779
2892
  gated_dispatch_after_timeout: true,
2780
2893
  ...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
2781
2894
  };
@@ -2798,6 +2911,7 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2798
2911
  gate_wait_ms: gateResult.waited_ms,
2799
2912
  verify,
2800
2913
  confirm,
2914
+ ...(consumption ? { consumption, consumption_reason: consumptionReason } : {}),
2801
2915
  ...(gatedDispatchAfterTimeout ? { gated_dispatch_after_timeout: true } : {}),
2802
2916
  ...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
2803
2917
  };
@@ -3004,6 +3118,8 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
3004
3118
  submitExpected: !!no_enter,
3005
3119
  noEnter: !!no_enter,
3006
3120
  injectedBodyPreview: prompt.slice(0, 500),
3121
+ // #52: echo-evidence watermark — only frames appended after this inject count.
3122
+ ringBytesAtInject: session.outputRingTotalBytes || 0,
3007
3123
  awaitingReport: true,
3008
3124
  idleNotified: false
3009
3125
  };
@@ -3762,6 +3878,7 @@ if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1')
3762
3878
  const address = server.address();
3763
3879
  boundPort = (address && address.port) || Number(PORT);
3764
3880
  console.log(`🔐 aigentry-telepty broker listening on https://${HOST}:${boundPort} (/broker/*)`);
3881
+ console.log(formatBindHint(HOST)); // telepty#50
3765
3882
  runStartupBootstrapRestore();
3766
3883
  });
3767
3884
  } else {
@@ -3769,6 +3886,7 @@ if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1')
3769
3886
  const address = server.address();
3770
3887
  boundPort = (address && address.port) || Number(PORT);
3771
3888
  console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${boundPort}`);
3889
+ console.log(formatBindHint(HOST)); // telepty#50
3772
3890
  runStartupBootstrapRestore();
3773
3891
  // #42 node-mode (§2F-ii): start the broker-client if broker config is present.
3774
3892
  // Absent ⇒ no-op (default-OFF). Started after listen so sessions/delivery are live.
@@ -4151,4 +4269,6 @@ module.exports = {
4151
4269
  loadNodeBrokerConfig, // node-mode: resolve broker.json / env config (or null)
4152
4270
  startNodeBrokerClient, // node-mode: start createBrokerClient (default-OFF; in-process deliver)
4153
4271
  deliverInjectionToSession, // §4.3: the in-process delivery wired into the broker-client
4272
+ resolveBindHost, // telepty#50: pure bind-address policy (loopback default, env opt-in)
4273
+ formatBindHint, // telepty#50: startup bind/exposure banner line
4154
4274
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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/idle-unconfirmed-settle.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/idle-unconfirmed-settle.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/idle-unconfirmed-settle.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/inject-consumption-evidence.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/inject-consumption-evidence.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/inject-consumption-evidence.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
  },
@@ -59,7 +61,7 @@
59
61
  ],
60
62
  "author": "dmsdc-ai",
61
63
  "license": "ISC",
62
- "description": "Universal terminal session bridge \u2014 connect any terminal to any terminal, any machine",
64
+ "description": "Universal terminal session bridge connect any terminal to any terminal, any machine",
63
65
  "repository": {
64
66
  "type": "git",
65
67
  "url": "git+https://github.com/dmsdc-ai/aigentry-telepty.git"
@@ -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,144 @@ 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
+
441
+ // #53: classify whether an injected+submitted body was CONSUMED as a new turn by the
442
+ // recipient TUI, vs QUEUED in a busy composer, vs UNKNOWN. This is the DELIVERY-side dual
443
+ // of #52 (which gates the IDLE signal on consumption evidence). A bare `Submitted via
444
+ // pty_cr` only proves bytes reached the PTY master; a BUSY Claude Code TUI parks the CR'd
445
+ // text in its composer ("Press up to edit queued messages") and never starts a turn, so the
446
+ // sender must be able to tell `queued` from `consumed`.
447
+ //
448
+ // Hard boundary (#53): telepty cannot see inside the TUI's turn loop, so `consumed` is only
449
+ // claimable from OBSERVABLE evidence, and never-false-consumed is conservative:
450
+ //
451
+ // consumed — the recipient was NOT already busy at the CR and then began a FRESH turn:
452
+ // an idle→working/thinking transition whose since_ms ≥ submittedAtMs (a genuine
453
+ // new turn, NOT mere continued output from a turn already running — the leak
454
+ // that made `last_output_at ≥ submittedAtMs` read a busy queue as success).
455
+ // queued — the injected body is still observably PARKED on screen after a short settle
456
+ // (windowed echo match — #52 technique — tolerates composer line-wrap/borders).
457
+ // A recipient already busy at the CR can only ever land here (fact 1: busy CR
458
+ // queues, never fires), never in `consumed`.
459
+ // unknown — neither positive signal within the window (conservative default).
460
+ //
461
+ // Pure: DI getState + outputRing-only, DI now/sleep/stripAnsi — no I/O, no daemon coupling.
462
+ async function classifyInjectConsumption(session, bodyText, opts = {}) {
463
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 1200;
464
+ const settleMs = Number.isFinite(opts.settleMs) ? opts.settleMs : 250;
465
+ const intervalMs = Number.isFinite(opts.intervalMs) ? opts.intervalMs : 80;
466
+ const minChars = Number.isFinite(opts.minChars) ? opts.minChars : 24;
467
+ const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
468
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
469
+ const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
470
+ const getState = typeof opts.getState === 'function' ? opts.getState : null;
471
+ const submittedAtMs = Number.isFinite(opts.submittedAtMs) ? opts.submittedAtMs : now();
472
+ const sinceBytes = Number.isFinite(opts.sinceBytes) ? opts.sinceBytes : null;
473
+
474
+ const body = normalize(bodyText);
475
+ if (body.length === 0) return { status: 'unknown', reason: 'empty_body', waited_ms: 0 };
476
+ if (body.length < minChars) return { status: 'unknown', reason: 'body_too_short', waited_ms: 0 };
477
+ if (!session || !Array.isArray(session.outputRing)) {
478
+ return { status: 'unknown', reason: 'no_ring', waited_ms: 0 };
479
+ }
480
+
481
+ // Was the recipient ALREADY busy when the CR was written? A busy claude-code TUI parks the
482
+ // CR'd text and never starts a turn (#53 fact 1), so we may observe `queued` there but must
483
+ // NEVER claim `consumed` — that is the hard boundary that produced the false success.
484
+ const initial = getState ? getState() : null;
485
+ const startedBusy = !!(initial && ACCEPTED_AFTER_SUBMIT_STATES.has(initial.state)
486
+ && Number.isFinite(initial.since_ms) && initial.since_ms < submittedAtMs);
487
+
488
+ const observeParked = () => observeInjectEcho(session, bodyText, { stripAnsi, sinceBytes, minChars });
489
+
490
+ const start = now();
491
+ while (true) {
492
+ // consumed — only from a non-busy start that produced a fresh idle→working/thinking turn.
493
+ if (!startedBusy && getState) {
494
+ const st = getState();
495
+ if (st && ACCEPTED_AFTER_SUBMIT_STATES.has(st.state)
496
+ && Number.isFinite(st.since_ms) && st.since_ms >= submittedAtMs) {
497
+ return { status: 'consumed', reason: `turn_started_${st.state}`, waited_ms: now() - start };
498
+ }
499
+ }
500
+
501
+ const elapsed = now() - start;
502
+ // Busy recipient cannot consume — short-circuit to `queued` as soon as the parked body
503
+ // settles, instead of waiting out the full window for a turn that will never come.
504
+ if (startedBusy && elapsed >= settleMs) {
505
+ const echo = observeParked();
506
+ if (echo.observed) return { status: 'queued', reason: 'busy_parked', waited_ms: elapsed };
507
+ }
508
+
509
+ if (elapsed >= timeoutMs) {
510
+ const echo = observeParked();
511
+ if (echo.observed) {
512
+ return { status: 'queued', reason: startedBusy ? 'busy_parked' : 'body_parked', waited_ms: elapsed };
513
+ }
514
+ return { status: 'unknown', reason: startedBusy ? 'busy_no_evidence' : 'no_turn', waited_ms: elapsed };
515
+ }
516
+ await sleep(intervalMs);
517
+ }
518
+ }
519
+
382
520
  function isAcceptedSubmitState(state, submittedAtMs) {
383
521
  if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
384
522
  if (!Number.isFinite(submittedAtMs)) {
@@ -553,6 +691,8 @@ module.exports = {
553
691
  verifyBodyConsumed,
554
692
  confirmSubmitAccepted,
555
693
  observeBodyVisibility,
694
+ observeInjectEcho,
695
+ classifyInjectConsumption,
556
696
  awaitPromptSymbol,
557
697
  defaultReadScreen,
558
698
  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
+ };