@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 +58 -1
- package/cli.js +227 -7
- package/daemon-control.js +84 -1
- package/daemon.js +121 -1
- package/package.json +7 -5
- package/scripts/preuninstall.js +23 -0
- package/src/child-cpu.js +54 -0
- package/src/submit-gate.js +140 -0
- package/src/uninstall.js +148 -0
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
|
-
## [
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
494
|
+
startDaemon();
|
|
458
495
|
|
|
459
496
|
// (d) Wait for new daemon to respond with correct version
|
|
460
|
-
const meta = await
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
39
|
-
"test
|
|
40
|
-
"test:
|
|
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
|
|
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);
|
package/src/child-cpu.js
ADDED
|
@@ -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 };
|
package/src/submit-gate.js
CHANGED
|
@@ -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,
|
package/src/uninstall.js
ADDED
|
@@ -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
|
+
};
|