@dmsdc-ai/aigentry-telepty 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -1
- package/cli.js +212 -6
- package/daemon-control.js +84 -1
- package/daemon.js +179 -25
- package/package.json +6 -4
- package/scripts/preuninstall.js +23 -0
- package/src/child-cpu.js +54 -0
- package/src/submit-gate.js +60 -0
- package/src/uninstall.js +148 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [0.6.3] - 2026-06-13
|
|
6
|
+
|
|
7
|
+
### ⚠️ BREAKING — daemon binds 127.0.0.1 by default (#50)
|
|
8
|
+
|
|
9
|
+
- **The daemon (and broker host) now binds `127.0.0.1` instead of `0.0.0.0`.** A fresh install no
|
|
10
|
+
longer exposes the inject/control API to the local network. **Cross-machine setups where a peer
|
|
11
|
+
dials this daemon directly over LAN will stop working after the daemon restarts** — opt back in
|
|
12
|
+
explicitly on the daemon host with `TELEPTY_BIND=0.0.0.0` (the legacy `HOST` env override is
|
|
13
|
+
still honored; `TELEPTY_BIND` wins when both are set). The startup banner now prints the bind
|
|
14
|
+
address and a one-line exposure hint. SSH-tunnel peers (`telepty connect`) and the #42 broker
|
|
15
|
+
node mode (outbound-only) are unaffected.
|
|
16
|
+
|
|
17
|
+
### Added — `telepty uninstall` + npm preuninstall hook (#49)
|
|
18
|
+
|
|
19
|
+
- **`telepty uninstall [--purge] [--dry-run]`**: stops running daemons (full discovery chain),
|
|
20
|
+
unloads **and removes** the launchd plists (`com.aigentry.telepty`, `com.aigentry.telepty-broker`)
|
|
21
|
+
on macOS, and reports the 3 state directories (`~/.telepty`, `~/.aigentry`,
|
|
22
|
+
`~/.config/aigentry-telepty`). **User data is kept by default** — the paths are printed; deletion
|
|
23
|
+
requires the explicit `--purge`. `--dry-run` reports without touching anything.
|
|
24
|
+
- **npm `preuninstall` hook**: daemon stop + plist unload only, quietly; it can never fail (a broken
|
|
25
|
+
hook would break `npm rm` itself). Note: npm 7+ no longer executes uninstall lifecycle scripts —
|
|
26
|
+
the reliable path is running `telepty uninstall` before `npm rm -g`.
|
|
27
|
+
|
|
28
|
+
### Fixed — blocked daemon restarts: actionable diagnostic, no per-command noise (#15)
|
|
29
|
+
|
|
30
|
+
- When the running daemon cannot be stopped (no `daemon-state.json`, owned by a parent app such as
|
|
31
|
+
an aterm bundle, EPERM), the CLI used to retry the restart **3 times with backoff and repeat the
|
|
32
|
+
full mismatch + failure banner on every command**, even though sessions kept working. Now: the
|
|
33
|
+
discovery chain (state file → process-title scan → port-owner via `lsof`/`Get-NetTCPConnection`)
|
|
34
|
+
is checked once — if the port owner survives cleanup, the restart **fails fast** with one
|
|
35
|
+
actionable diagnostic naming the parent process (`Daemon (PID X) is owned by parent Y (pid Z) —
|
|
36
|
+
restart that app … or run: kill X && telepty daemon`), discovered via new
|
|
37
|
+
`findParentProcessInfo` (PPID lookup). An identical blocked state (same versions + blocking pid)
|
|
38
|
+
warns **once** and is then silent (`~/.telepty/restart-failure.json` marker) until the state
|
|
39
|
+
changes or a restart succeeds.
|
|
40
|
+
|
|
41
|
+
### Fixed — `--help` is now always safe on payload subcommands (#51)
|
|
42
|
+
|
|
43
|
+
- `telepty broadcast --help` used to **broadcast the literal string `--help` to every active
|
|
44
|
+
session**, and `telepty allow --help` spawned a junk `<dir>---help` session. A bare `-h`/`--help`
|
|
45
|
+
before an explicit `--` separator now always prints the subcommand usage with zero network or
|
|
46
|
+
fan-out side effects (broadcast/multicast/inject/allow + aliases). Sending the literal text
|
|
47
|
+
requires the explicit separator: `telepty broadcast -- --help`. Defense-in-depth: broadcast and
|
|
48
|
+
multicast refuse a payload that is exactly a help flag unless `--` was used.
|
|
49
|
+
|
|
50
|
+
## [0.6.2] - 2026-06-10
|
|
51
|
+
|
|
52
|
+
### Fixed — TASK_IDLE_UNCONFIRMED false positives (#48)
|
|
53
|
+
|
|
54
|
+
- **`TASK_IDLE_UNCONFIRMED` fired ~0–0.5s after nearly every inject** even when the inject was
|
|
55
|
+
processed, destroying the signal's value. Two proven causes: (a) the bridge re-sends `ready` on
|
|
56
|
+
every TUI prompt-glyph redraw after an inject, and "working" evidence was only recorded on a
|
|
57
|
+
transition *into* working — so an inject landing on an already-working session left zero evidence
|
|
58
|
+
and the notifier fired on the first weak snapshot; (b) codex's spinner-less TUI (5s silence +
|
|
59
|
+
`›` prompt glyph) flips the real-idle classifier mid-work.
|
|
60
|
+
- **Fix: settle-and-recheck.** A would-be `TASK_IDLE_UNCONFIRMED` is held for
|
|
61
|
+
`TELEPTY_IDLE_UNCONFIRMED_SETTLE_SECONDS` (default 5) and re-checked against the **live** session
|
|
62
|
+
state: working/thinking → suppressed; output advanced while idle-classified → bounded re-settle
|
|
63
|
+
(`TELEPTY_IDLE_UNCONFIRMED_SETTLE_MAX_REARMS`, default 3); still idle and stalled → notify, so the
|
|
64
|
+
genuine "inject not consumed" signal is preserved. The report label is pinned at arm time, so the
|
|
65
|
+
settle window can never promote a stale idle snapshot to `TASK_COMPLETE` (the never-false-complete
|
|
66
|
+
invariant is kept). Message format is unchanged.
|
|
6
67
|
|
|
7
68
|
## [0.6.1] - 2026-06-09
|
|
8
69
|
|
package/cli.js
CHANGED
|
@@ -11,7 +11,15 @@ const prompts = require('prompts');
|
|
|
11
11
|
const updateNotifier = require('update-notifier');
|
|
12
12
|
const pkg = require('./package.json');
|
|
13
13
|
const { getConfig } = require('./auth');
|
|
14
|
-
const {
|
|
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')) {
|
|
@@ -2520,8 +2699,19 @@ async function main() {
|
|
|
2520
2699
|
}
|
|
2521
2700
|
|
|
2522
2701
|
if (cmd === 'multicast') {
|
|
2702
|
+
if (interceptSubcommandHelp(cmd, args.slice(1))) return; // telepty#51: help must never fan out as data
|
|
2703
|
+
const multicastSepIndex = args.indexOf('--');
|
|
2704
|
+
const multicastHadSeparator = multicastSepIndex !== -1;
|
|
2705
|
+
if (multicastHadSeparator) args.splice(multicastSepIndex, 1);
|
|
2523
2706
|
const sessionIdsRaw = args[1]; const prompt = args.slice(2).join(' ');
|
|
2524
2707
|
if (!sessionIdsRaw || !prompt) { console.error('❌ Usage: telepty multicast <id1,id2,...> "<prompt text>"'); process.exit(1); }
|
|
2708
|
+
// telepty#51 defense-in-depth: a payload that is exactly a help flag is almost
|
|
2709
|
+
// certainly a swallowed `--help`, never a real prompt. Refuse unless the caller
|
|
2710
|
+
// opted into the literal send with an explicit `--`.
|
|
2711
|
+
if (!multicastHadSeparator && isHelpLikePayload(prompt)) {
|
|
2712
|
+
console.error('❌ Refusing to multicast a bare help flag. Use `telepty multicast --help` for usage, or `telepty multicast <ids> -- --help` to send the literal text.');
|
|
2713
|
+
process.exit(1);
|
|
2714
|
+
}
|
|
2525
2715
|
const sessionRefs = sessionIdsRaw.split(',').map(s => s.trim()).filter(s => s);
|
|
2526
2716
|
try {
|
|
2527
2717
|
const discovered = await discoverSessions({ silent: true });
|
|
@@ -2561,10 +2751,21 @@ async function main() {
|
|
|
2561
2751
|
}
|
|
2562
2752
|
|
|
2563
2753
|
if (cmd === 'broadcast') {
|
|
2754
|
+
if (interceptSubcommandHelp(cmd, args.slice(1))) return; // telepty#51: help must never fan out to every session
|
|
2755
|
+
const broadcastSepIndex = args.indexOf('--');
|
|
2756
|
+
const broadcastHadSeparator = broadcastSepIndex !== -1;
|
|
2757
|
+
if (broadcastHadSeparator) args.splice(broadcastSepIndex, 1);
|
|
2564
2758
|
const { useRef, refFilePath } = parseRefOption(args);
|
|
2565
2759
|
|
|
2566
2760
|
const prompt = args.slice(1).join(' ');
|
|
2567
2761
|
if (!prompt && !refFilePath) { console.error('❌ Usage: telepty broadcast [--ref [file]] "<prompt text>"'); process.exit(1); }
|
|
2762
|
+
// telepty#51 defense-in-depth: a payload that is exactly a help flag is almost
|
|
2763
|
+
// certainly a swallowed `--help`, never a real prompt. Refuse unless the caller
|
|
2764
|
+
// opted into the literal send with an explicit `--`.
|
|
2765
|
+
if (!broadcastHadSeparator && isHelpLikePayload(prompt)) {
|
|
2766
|
+
console.error('❌ Refusing to broadcast a bare help flag to every session. Use `telepty broadcast --help` for usage, or `telepty broadcast -- --help` to send the literal text.');
|
|
2767
|
+
process.exit(1);
|
|
2768
|
+
}
|
|
2568
2769
|
try {
|
|
2569
2770
|
const discovered = await discoverSessions({ silent: true });
|
|
2570
2771
|
const aggregate = { successful: [], failed: [] };
|
|
@@ -3787,6 +3988,7 @@ Discuss the following topic from your project's perspective. Engage with other s
|
|
|
3787
3988
|
|
|
3788
3989
|
\x1b[1mOther:\x1b[0m
|
|
3789
3990
|
telepty update Update to latest version
|
|
3991
|
+
telepty uninstall [--purge] [--dry-run] Stop daemon + unload service; keep user data unless --purge
|
|
3790
3992
|
telepty layout [grid|tall|stack] Arrange terminal windows
|
|
3791
3993
|
telepty status-report --phase <p> [options] Emit structured status event
|
|
3792
3994
|
|
|
@@ -3821,4 +4023,8 @@ module.exports = {
|
|
|
3821
4023
|
sanitizePathArg, // #26: path-arg validation/normalization
|
|
3822
4024
|
decideDaemonAction, // #567: pure restart-decision policy (meta-primary; no I/O)
|
|
3823
4025
|
ensureDaemonRunning, // #567: orchestrator (injectable probes for unit-testing)
|
|
4026
|
+
helpRequested, // telepty#51: bare -h/--help before `--` → show help, not payload
|
|
4027
|
+
isHelpLikePayload, // telepty#51: defense-in-depth payload guard for broadcast/multicast
|
|
4028
|
+
formatDaemonStopDiagnostic, // telepty#15: actionable can't-stop-daemon diagnostic (pure)
|
|
4029
|
+
restartDaemonGraceful, // telepty#15: injectable seams for the blocked-restart fail-fast path
|
|
3824
4030
|
};
|
package/daemon-control.js
CHANGED
|
@@ -275,6 +275,85 @@ function probeTeleptyOnPort(port, opts) {
|
|
|
275
275
|
});
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
// telepty#15: identify who launched a daemon we cannot stop (e.g. an older
|
|
279
|
+
// daemon bundled inside a parent app such as aterm), so the CLI can print an
|
|
280
|
+
// actionable diagnostic instead of retrying blindly. Returns
|
|
281
|
+
// { ppid, command } for `pid`'s parent, or null when it cannot be resolved.
|
|
282
|
+
function findParentProcessInfo(pid, opts) {
|
|
283
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
284
|
+
const o = opts || {};
|
|
285
|
+
const platform = o.platform || process.platform;
|
|
286
|
+
const exec = o.execSync || execSync;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
if (platform === 'win32') {
|
|
290
|
+
const script =
|
|
291
|
+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; ` +
|
|
292
|
+
`if ($p) { $pp = Get-CimInstance Win32_Process -Filter "ProcessId=$($p.ParentProcessId)"; ` +
|
|
293
|
+
`"$($p.ParentProcessId)|$(if ($pp) { $pp.Name } else { '' })" }`;
|
|
294
|
+
const output = String(exec(`powershell.exe -NoProfile -Command "${script}"`, {
|
|
295
|
+
encoding: 'utf8',
|
|
296
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
297
|
+
})).trim();
|
|
298
|
+
if (!output) return null;
|
|
299
|
+
const [ppidRaw, name] = output.split('|');
|
|
300
|
+
const ppid = Number(ppidRaw);
|
|
301
|
+
if (!Number.isInteger(ppid) || ppid <= 0) return null;
|
|
302
|
+
return { ppid, command: (name || '').trim() || null };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const ppidRaw = String(exec(`ps -p ${pid} -o ppid=`, {
|
|
306
|
+
encoding: 'utf8',
|
|
307
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
308
|
+
})).trim();
|
|
309
|
+
const ppid = Number(ppidRaw);
|
|
310
|
+
if (!Number.isInteger(ppid) || ppid <= 0) return null;
|
|
311
|
+
const command = String(exec(`ps -p ${ppid} -o comm=`, {
|
|
312
|
+
encoding: 'utf8',
|
|
313
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
314
|
+
})).trim() || null;
|
|
315
|
+
return { ppid, command };
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// telepty#15: once-per-mismatch warning marker. A daemon the CLI cannot stop
|
|
322
|
+
// (foreign-owned port, EPERM, parent respawns it) used to produce the full
|
|
323
|
+
// mismatch + 3-retries + failure banner on EVERY command. The CLI records the
|
|
324
|
+
// blocked state's signature here after warning once; identical signatures are
|
|
325
|
+
// then silent until the blocking pid / version pair changes or a restart
|
|
326
|
+
// succeeds. `opts.file` is an injectable path so tests never touch ~/.telepty.
|
|
327
|
+
const RESTART_FAILURE_FILE = path.join(TELEPTY_DIR, 'restart-failure.json');
|
|
328
|
+
|
|
329
|
+
function readRestartFailureMarker(opts) {
|
|
330
|
+
const file = (opts && opts.file) || RESTART_FAILURE_FILE;
|
|
331
|
+
try {
|
|
332
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function writeRestartFailureMarker(marker, opts) {
|
|
339
|
+
const file = (opts && opts.file) || RESTART_FAILURE_FILE;
|
|
340
|
+
try {
|
|
341
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
342
|
+
fs.writeFileSync(file, JSON.stringify(marker, null, 2), { mode: 0o600 });
|
|
343
|
+
} catch {
|
|
344
|
+
// Best-effort: losing the marker only means a repeated warning, never a failure.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function clearRestartFailureMarker(opts) {
|
|
349
|
+
const file = (opts && opts.file) || RESTART_FAILURE_FILE;
|
|
350
|
+
try {
|
|
351
|
+
fs.rmSync(file, { force: true });
|
|
352
|
+
} catch {
|
|
353
|
+
// Best-effort (see writeRestartFailureMarker).
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
278
357
|
// Confirm via local process scan that `pid`'s command line looks like a
|
|
279
358
|
// telepty daemon (fallback when HTTP probe fails — daemon may be stuck).
|
|
280
359
|
function pidMatchesTeleptyCmdline(pid) {
|
|
@@ -343,11 +422,15 @@ module.exports = {
|
|
|
343
422
|
claimDaemonState,
|
|
344
423
|
cleanupDaemonProcesses,
|
|
345
424
|
clearDaemonState,
|
|
425
|
+
clearRestartFailureMarker,
|
|
426
|
+
findParentProcessInfo,
|
|
346
427
|
findPortOwnerPid,
|
|
347
428
|
isLikelyTeleptyDaemon,
|
|
348
429
|
isProcessRunning,
|
|
349
430
|
listDaemonProcesses,
|
|
350
431
|
pidMatchesTeleptyCmdline,
|
|
351
432
|
probeTeleptyOnPort,
|
|
352
|
-
readDaemonState
|
|
433
|
+
readDaemonState,
|
|
434
|
+
readRestartFailureMarker,
|
|
435
|
+
writeRestartFailureMarker
|
|
353
436
|
};
|
package/daemon.js
CHANGED
|
@@ -18,6 +18,7 @@ const { UnixSocketNotifier } = require('./src/mailbox/notifier');
|
|
|
18
18
|
const { SessionStateManager, STATE_DISPLAY, stripAnsi: stripAnsiState } = require('./session-state');
|
|
19
19
|
const { classifyReportPrompt, buildAutoSummary } = require('./src/report-enforcement');
|
|
20
20
|
const submitGate = require('./src/submit-gate');
|
|
21
|
+
const { sampleChildCpuSeconds } = require('./src/child-cpu'); // #52: quiet-thinking CPU recheck
|
|
21
22
|
const readyRegistry = require('./src/prompt-symbol-registry');
|
|
22
23
|
const lifecycle = require('./src/lifecycle');
|
|
23
24
|
const { SURFACE_ORPHAN_SECONDS, SURFACE_MISMATCH_SECONDS, decideSurfaceGc, applySurfaceMismatchProbe } = lifecycle;
|
|
@@ -265,7 +266,25 @@ const PORT = process.env.PORT || 3848;
|
|
|
265
266
|
// Reported by /api/meta so callers (e.g. the test harness) can read it back.
|
|
266
267
|
let boundPort = Number(PORT);
|
|
267
268
|
|
|
268
|
-
|
|
269
|
+
// telepty#50: bind loopback by default — a fresh install must not expose the
|
|
270
|
+
// inject/control API to the local network. Network exposure is an explicit
|
|
271
|
+
// opt-in: TELEPTY_BIND=0.0.0.0 (preferred) or the legacy HOST override.
|
|
272
|
+
// BREAKING: cross-machine peers that dialed this daemon directly over LAN
|
|
273
|
+
// need the opt-in on the daemon host after a restart (see CHANGELOG).
|
|
274
|
+
function resolveBindHost(env) {
|
|
275
|
+
return env.TELEPTY_BIND || env.HOST || '127.0.0.1';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// One-line bind-address banner so operators can see (and fix) their exposure
|
|
279
|
+
// posture at startup without reading docs.
|
|
280
|
+
function formatBindHint(host) {
|
|
281
|
+
if (host === '127.0.0.1' || host === 'localhost' || host === '::1') {
|
|
282
|
+
return ` bind: ${host} (loopback only) — LAN peers cannot connect; opt in with TELEPTY_BIND=0.0.0.0`;
|
|
283
|
+
}
|
|
284
|
+
return ` bind: ${host} — reachable from the network (TELEPTY_BIND/HOST opt-in)`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const HOST = resolveBindHost(process.env);
|
|
269
288
|
process.title = 'telepty-daemon';
|
|
270
289
|
|
|
271
290
|
// Singleton claim — guarded so a test require neither exits (when a daemon is running) nor
|
|
@@ -286,6 +305,21 @@ const AUTO_REPORT_IDLE_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_IDLE_SEC
|
|
|
286
305
|
// by the recipient. Below this elapsed floor the idle is NOT trusted as a processed-inject
|
|
287
306
|
// completion; the text-inject is relabeled so a stuck/hung target is never reported as DONE.
|
|
288
307
|
const AUTO_REPORT_MIN_REAL_SECONDS = Number(process.env.TELEPTY_AUTO_REPORT_MIN_REAL_SECONDS) || 1.0;
|
|
308
|
+
// #48: a momentary idle/ready snapshot right after an inject (the bridge re-sends 'ready' on a
|
|
309
|
+
// TUI prompt-glyph redraw; codex's silence+glyph flips real-idle mid-work) is almost always a
|
|
310
|
+
// transition-gap false positive — the session is, or moments later is, working. Before emitting
|
|
311
|
+
// TASK_IDLE_UNCONFIRMED, hold for this settle window and recheck the LIVE session state.
|
|
312
|
+
const IDLE_UNCONFIRMED_SETTLE_SECONDS = Number(process.env.TELEPTY_IDLE_UNCONFIRMED_SETTLE_SECONDS) || 5;
|
|
313
|
+
// Output advanced during the settle window while still idle-classified (sparse TUI redraw) →
|
|
314
|
+
// re-settle, bounded so periodic idle redraws cannot starve the genuinely-unconsumed signal.
|
|
315
|
+
const IDLE_UNCONFIRMED_SETTLE_MAX_REARMS = Math.max(0, Number(process.env.TELEPTY_IDLE_UNCONFIRMED_SETTLE_MAX_REARMS) || 3);
|
|
316
|
+
// #52: codex quiet-thinking (no output, no spinner) outlasts the settle chain — the recheck
|
|
317
|
+
// consults the same screen classifier that produced the false idle. Auxiliary heuristic: a
|
|
318
|
+
// wrapped child whose CPU time advanced ≥ this delta across the settle window is working
|
|
319
|
+
// (quiet thinking) — re-settle instead of notifying, on its own (larger) bound so a long
|
|
320
|
+
// no-output stretch is survivable while a pathological always-busy child still signals.
|
|
321
|
+
const IDLE_UNCONFIRMED_CPU_DELTA_SECONDS = Number(process.env.TELEPTY_IDLE_UNCONFIRMED_CPU_DELTA_SECONDS) || 0.1;
|
|
322
|
+
const IDLE_UNCONFIRMED_CPU_MAX_REARMS = Math.max(0, Number(process.env.TELEPTY_IDLE_UNCONFIRMED_CPU_MAX_REARMS) || 24);
|
|
289
323
|
|
|
290
324
|
function pendingReportHasSubmitEvidence(pendingReport) {
|
|
291
325
|
return !!(pendingReport && (
|
|
@@ -295,6 +329,31 @@ function pendingReportHasSubmitEvidence(pendingReport) {
|
|
|
295
329
|
));
|
|
296
330
|
}
|
|
297
331
|
|
|
332
|
+
// #52: the TASK_IDLE_UNCONFIRMED semantic is "inject may NOT have been processed" — gate it
|
|
333
|
+
// on CONSUMPTION evidence the daemon already owns instead of screen idleness. Evidence:
|
|
334
|
+
// - a screen-VERIFIED submit confirmation (body consumed from the composer, or a state
|
|
335
|
+
// transition observed after the CR) — 'force'/ambiguous accepts are NOT verification;
|
|
336
|
+
// - the injected body echoed in PTY frames appended after the inject (composer/transcript
|
|
337
|
+
// redraw), matched conservatively (submit-gate observeInjectEcho).
|
|
338
|
+
// A definitively failed submit (accepted:false — body observed stuck in the composer /
|
|
339
|
+
// no-land) is positive NON-consumption and can never be overridden by echo, so the
|
|
340
|
+
// never-false-complete invariant of #48 holds: a genuinely unconsumed inject still signals.
|
|
341
|
+
function observeConsumptionEvidence(pendingReport, session) {
|
|
342
|
+
const confirm = pendingReport.submitConfirm;
|
|
343
|
+
if (confirm && confirm.accepted === false) {
|
|
344
|
+
return { observed: false, reason: 'submit_failed' };
|
|
345
|
+
}
|
|
346
|
+
if (confirm && confirm.accepted === true && !confirm.ambiguous
|
|
347
|
+
&& (confirm.reason === 'body_consumed' || /^state_(working|thinking)$/.test(String(confirm.reason)))) {
|
|
348
|
+
return { observed: true, reason: `submit_${confirm.reason}` };
|
|
349
|
+
}
|
|
350
|
+
const echo = submitGate.observeInjectEcho(session, pendingReport.injectedBodyPreview, {
|
|
351
|
+
sinceBytes: Number.isFinite(pendingReport.ringBytesAtInject) ? pendingReport.ringBytesAtInject : null,
|
|
352
|
+
stripAnsi: stripAnsiState,
|
|
353
|
+
});
|
|
354
|
+
return { observed: echo.observed === true, reason: echo.reason };
|
|
355
|
+
}
|
|
356
|
+
|
|
298
357
|
function getPendingReport(sessionId, registry = pendingReports) {
|
|
299
358
|
if (typeof sessionId !== 'string') return null;
|
|
300
359
|
if (sessionId === '__proto__' || sessionId === 'prototype' || sessionId === 'constructor') return null;
|
|
@@ -358,6 +417,14 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
|
|
|
358
417
|
const _sessions = deps.sessions || sessions;
|
|
359
418
|
const _pendingReports = deps.pendingReports || pendingReports;
|
|
360
419
|
const _deliver = deps.deliverInjectionToSession || deliverInjectionToSession;
|
|
420
|
+
// #48: live auto-state lookup for the settle recheck (DI for unit tests).
|
|
421
|
+
const _getAutoState = deps.getAutoState || ((sid) => {
|
|
422
|
+
const st = sessionStateManager.getState(sid);
|
|
423
|
+
return st && st.state ? st.state : null;
|
|
424
|
+
});
|
|
425
|
+
// #52: wrapped-child CPU sampler for the quiet-thinking recheck (DI for unit tests).
|
|
426
|
+
const _sampleChildCpu = deps.sampleChildCpu || ((sess) =>
|
|
427
|
+
sampleChildCpuSeconds(sess ? (sess.ptyPid || (sess.ptyProcess && sess.ptyProcess.pid) || null) : null));
|
|
361
428
|
|
|
362
429
|
const elapsedNum = (_now() - new Date(pendingReport.injectedAt).getTime()) / 1000;
|
|
363
430
|
const elapsed = elapsedNum.toFixed(1);
|
|
@@ -390,25 +457,6 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
|
|
|
390
457
|
}
|
|
391
458
|
}
|
|
392
459
|
|
|
393
|
-
pendingReport.idleNotified = true;
|
|
394
|
-
pendingReport.idleAt = new Date(_now()).toISOString();
|
|
395
|
-
|
|
396
|
-
// Richer bus event (observability) — now also carries the trigger provenance.
|
|
397
|
-
_broadcast('TASK_IDLE_NO_REPORT', targetId, targetSession, {
|
|
398
|
-
extra: {
|
|
399
|
-
source: pendingReport.source,
|
|
400
|
-
inject_id: pendingReport.injectId,
|
|
401
|
-
elapsed_secs: Number(elapsed),
|
|
402
|
-
injected_at: pendingReport.injectedAt,
|
|
403
|
-
trigger
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
console.log(`[ENFORCE-REPORT] ${targetId} idle after ${elapsed}s (trigger=${trigger}) — awaiting REPORT from ${pendingReport.source}`);
|
|
407
|
-
|
|
408
|
-
const srcId = _resolveAlias(pendingReport.source) || pendingReport.source;
|
|
409
|
-
const srcSession = _sessions[srcId];
|
|
410
|
-
if (!srcSession) return;
|
|
411
|
-
|
|
412
460
|
// #537 / Bug B: a never-started worker (transient submit failure → claude startup
|
|
413
461
|
// busy→idle settle at ~4.5s) must NOT be reported TASK_COMPLETE. When a submit was
|
|
414
462
|
// expected, the elapsed floor and startup-polluted sawWorkingAfterInject are NOT trusted
|
|
@@ -428,13 +476,110 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
|
|
|
428
476
|
const idleEvidenceUnreliable = trigger === 'real-idle'
|
|
429
477
|
&& pendingReport.submitExpected
|
|
430
478
|
&& deps.idleEvidenceReliable === false;
|
|
431
|
-
|
|
479
|
+
// #48: a settled recheck re-enters ONLY to emit the UNCONFIRMED label — pinned at arm time,
|
|
480
|
+
// so elapsed growing past the floor during the settle window can never promote a stale idle
|
|
481
|
+
// snapshot to TASK_COMPLETE (never a false complete).
|
|
482
|
+
const confirmed = pendingReport.unconfirmedSettleDone
|
|
432
483
|
? false
|
|
433
|
-
:
|
|
484
|
+
: trigger === 'ready-signal' && pendingReport.submitExpected
|
|
434
485
|
? false
|
|
435
|
-
:
|
|
436
|
-
?
|
|
437
|
-
:
|
|
486
|
+
: idleEvidenceUnreliable
|
|
487
|
+
? false
|
|
488
|
+
: pendingReport.submitExpected
|
|
489
|
+
? strongSubmitConfirmed
|
|
490
|
+
: (elapsedNum >= AUTO_REPORT_MIN_REAL_SECONDS || hasSubmitEvidence);
|
|
491
|
+
|
|
492
|
+
// #48: settle-and-recheck before any UNCONFIRMED notification. The first weak idle/ready
|
|
493
|
+
// snapshot right after an inject is almost always a transition gap — the bridge re-sends
|
|
494
|
+
// 'ready' on a TUI prompt-glyph redraw (with no state transition, no evidence flag is ever
|
|
495
|
+
// set even though the session IS working), and codex's silence+glyph heuristic flips
|
|
496
|
+
// real-idle mid-work. Hold the notification for a settle window and recheck the LIVE
|
|
497
|
+
// session: notify only when it is still not working AND its output has not advanced.
|
|
498
|
+
// Suppression does NOT consume the once-only idleNotified guard, so a later genuine
|
|
499
|
+
// busy→idle transition re-enters this path (and an evidence-backed one reports COMPLETE).
|
|
500
|
+
if (!confirmed && !pendingReport.unconfirmedSettleDone) {
|
|
501
|
+
if (pendingReport.unconfirmedSettleTimer) return; // settle window already open
|
|
502
|
+
const settleMs = Math.max(50, Math.round(IDLE_UNCONFIRMED_SETTLE_SECONDS * 1000));
|
|
503
|
+
const armSettle = () => {
|
|
504
|
+
const liveAtArm = _sessions[targetId] || targetSession;
|
|
505
|
+
const activityAtArm = liveAtArm ? liveAtArm.lastActivityAt : null;
|
|
506
|
+
const cpuAtArm = _sampleChildCpu(liveAtArm); // #52: null when unobservable
|
|
507
|
+
pendingReport.unconfirmedSettleTimer = _setTimeout(() => {
|
|
508
|
+
pendingReport.unconfirmedSettleTimer = null;
|
|
509
|
+
const currentPending = getPendingReport(targetId, _pendingReports);
|
|
510
|
+
// REPORT arrived / entry replaced / another path already notified — stand down.
|
|
511
|
+
if (currentPending !== pendingReport || currentPending.idleNotified) return;
|
|
512
|
+
const liveSession = _sessions[targetId] || targetSession;
|
|
513
|
+
const autoState = _getAutoState(targetId);
|
|
514
|
+
if (autoState === 'working' || autoState === 'thinking') {
|
|
515
|
+
console.log(`[AUTO-REPORT] ${targetId} idle-unconfirmed suppressed after settle — session is ${autoState} (trigger=${trigger})`);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const activityNow = liveSession ? liveSession.lastActivityAt : null;
|
|
519
|
+
if (activityNow !== activityAtArm
|
|
520
|
+
&& (pendingReport.unconfirmedSettleRearms || 0) < IDLE_UNCONFIRMED_SETTLE_MAX_REARMS) {
|
|
521
|
+
pendingReport.unconfirmedSettleRearms = (pendingReport.unconfirmedSettleRearms || 0) + 1;
|
|
522
|
+
console.log(`[AUTO-REPORT] ${targetId} output advanced during settle — re-settling (${pendingReport.unconfirmedSettleRearms}/${IDLE_UNCONFIRMED_SETTLE_MAX_REARMS})`);
|
|
523
|
+
armSettle();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// #52: screen idle + output stalled, but the wrapped child's CPU time advanced
|
|
527
|
+
// across the settle window → quiet thinking (codex no-spinner blind spot). Treat
|
|
528
|
+
// as working: re-settle on its own bound instead of notifying.
|
|
529
|
+
const cpuNow = _sampleChildCpu(liveSession);
|
|
530
|
+
if (cpuAtArm != null && cpuNow != null
|
|
531
|
+
&& (cpuNow - cpuAtArm) >= IDLE_UNCONFIRMED_CPU_DELTA_SECONDS
|
|
532
|
+
&& (pendingReport.unconfirmedCpuRearms || 0) < IDLE_UNCONFIRMED_CPU_MAX_REARMS) {
|
|
533
|
+
pendingReport.unconfirmedCpuRearms = (pendingReport.unconfirmedCpuRearms || 0) + 1;
|
|
534
|
+
console.log(`[AUTO-REPORT] ${targetId} child CPU advanced ${(cpuNow - cpuAtArm).toFixed(2)}s during settle — quiet-thinking; re-settling (${pendingReport.unconfirmedCpuRearms}/${IDLE_UNCONFIRMED_CPU_MAX_REARMS})`);
|
|
535
|
+
armSettle();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
pendingReport.unconfirmedSettleDone = true;
|
|
539
|
+
fireAutoReport(targetId, liveSession || targetSession, currentPending, trigger, deps);
|
|
540
|
+
}, settleMs);
|
|
541
|
+
};
|
|
542
|
+
armSettle();
|
|
543
|
+
console.log(`[AUTO-REPORT] ${targetId} idle unconfirmed at ${elapsed}s (trigger=${trigger}) — settling ${IDLE_UNCONFIRMED_SETTLE_SECONDS}s before notify`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// #52: before emitting the unconfirmed-DELIVERY warning, check for inject-consumption
|
|
548
|
+
// evidence (screen-verified submit / post-inject echo). Idle-looking + consumed is at
|
|
549
|
+
// most a TASK_IDLE fact — not "inject may NOT have been processed". Suppression does not
|
|
550
|
+
// consume the once-only idleNotified guard, so a later evidence-backed genuine busy→idle
|
|
551
|
+
// transition can still report TASK_COMPLETE, and the pending entry stays armed until the
|
|
552
|
+
// worker's content REPORT arrives. Confirmed completions (confirmed === true) are
|
|
553
|
+
// untouched — this gate only ever silences a would-be false warning, never a signal that
|
|
554
|
+
// a genuinely unconsumed inject produced (no echo + no verified submit ⇒ falls through).
|
|
555
|
+
if (!confirmed) {
|
|
556
|
+
const _observeConsumption = deps.observeConsumptionEvidence || observeConsumptionEvidence;
|
|
557
|
+
const consumption = _observeConsumption(pendingReport, _sessions[targetId] || targetSession);
|
|
558
|
+
if (consumption.observed) {
|
|
559
|
+
console.log(`[AUTO-REPORT] ${targetId} idle-unconfirmed suppressed — inject consumption observed (${consumption.reason}, trigger=${trigger})`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
pendingReport.idleNotified = true;
|
|
565
|
+
pendingReport.idleAt = new Date(_now()).toISOString();
|
|
566
|
+
|
|
567
|
+
// Richer bus event (observability) — now also carries the trigger provenance.
|
|
568
|
+
_broadcast('TASK_IDLE_NO_REPORT', targetId, targetSession, {
|
|
569
|
+
extra: {
|
|
570
|
+
source: pendingReport.source,
|
|
571
|
+
inject_id: pendingReport.injectId,
|
|
572
|
+
elapsed_secs: Number(elapsed),
|
|
573
|
+
injected_at: pendingReport.injectedAt,
|
|
574
|
+
trigger
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
console.log(`[ENFORCE-REPORT] ${targetId} idle after ${elapsed}s (trigger=${trigger}) — awaiting REPORT from ${pendingReport.source}`);
|
|
578
|
+
|
|
579
|
+
const srcId = _resolveAlias(pendingReport.source) || pendingReport.source;
|
|
580
|
+
const srcSession = _sessions[srcId];
|
|
581
|
+
if (!srcSession) return;
|
|
582
|
+
|
|
438
583
|
const injTag = pendingReport.injectId ? ` inject=${pendingReport.injectId}` : '';
|
|
439
584
|
const reportMsg = confirmed
|
|
440
585
|
? `TASK_COMPLETE: ${targetId} is now idle after processing inject (${elapsed}s, via ${trigger}${injTag})`
|
|
@@ -1552,6 +1697,9 @@ async function deliverInjectionToSession(id, session, prompt, options = {}) {
|
|
|
1552
1697
|
|
|
1553
1698
|
function appendToOutputRing(session, data) {
|
|
1554
1699
|
if (!session.outputRing) session.outputRing = [];
|
|
1700
|
+
// #52: monotonic byte counter — the inject-time watermark that scopes echo-evidence
|
|
1701
|
+
// matching to frames appended AFTER the inject (survives ring trimming below).
|
|
1702
|
+
session.outputRingTotalBytes = (session.outputRingTotalBytes || 0) + data.length;
|
|
1555
1703
|
session.outputRing.push(data);
|
|
1556
1704
|
// Keep total data under ~200KB limit by trimming old entries
|
|
1557
1705
|
let totalLen = session.outputRing.reduce((sum, d) => sum + d.length, 0);
|
|
@@ -2943,6 +3091,8 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
|
|
|
2943
3091
|
submitExpected: !!no_enter,
|
|
2944
3092
|
noEnter: !!no_enter,
|
|
2945
3093
|
injectedBodyPreview: prompt.slice(0, 500),
|
|
3094
|
+
// #52: echo-evidence watermark — only frames appended after this inject count.
|
|
3095
|
+
ringBytesAtInject: session.outputRingTotalBytes || 0,
|
|
2946
3096
|
awaitingReport: true,
|
|
2947
3097
|
idleNotified: false
|
|
2948
3098
|
};
|
|
@@ -3701,6 +3851,7 @@ if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1')
|
|
|
3701
3851
|
const address = server.address();
|
|
3702
3852
|
boundPort = (address && address.port) || Number(PORT);
|
|
3703
3853
|
console.log(`🔐 aigentry-telepty broker listening on https://${HOST}:${boundPort} (/broker/*)`);
|
|
3854
|
+
console.log(formatBindHint(HOST)); // telepty#50
|
|
3704
3855
|
runStartupBootstrapRestore();
|
|
3705
3856
|
});
|
|
3706
3857
|
} else {
|
|
@@ -3708,6 +3859,7 @@ if (require.main === module || process.env.AIGENTRY_TELEPTY_DAEMON_MAIN === '1')
|
|
|
3708
3859
|
const address = server.address();
|
|
3709
3860
|
boundPort = (address && address.port) || Number(PORT);
|
|
3710
3861
|
console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${boundPort}`);
|
|
3862
|
+
console.log(formatBindHint(HOST)); // telepty#50
|
|
3711
3863
|
runStartupBootstrapRestore();
|
|
3712
3864
|
// #42 node-mode (§2F-ii): start the broker-client if broker config is present.
|
|
3713
3865
|
// Absent ⇒ no-op (default-OFF). Started after listen so sessions/delivery are live.
|
|
@@ -4090,4 +4242,6 @@ module.exports = {
|
|
|
4090
4242
|
loadNodeBrokerConfig, // node-mode: resolve broker.json / env config (or null)
|
|
4091
4243
|
startNodeBrokerClient, // node-mode: start createBrokerClient (default-OFF; in-process deliver)
|
|
4092
4244
|
deliverInjectionToSession, // §4.3: the in-process delivery wired into the broker-client
|
|
4245
|
+
resolveBindHost, // telepty#50: pure bind-address policy (loopback default, env opt-in)
|
|
4246
|
+
formatBindHint, // telepty#50: startup bind/exposure banner line
|
|
4093
4247
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aigentry-telepty": "install.js",
|
|
@@ -29,15 +29,17 @@
|
|
|
29
29
|
"install.ps1",
|
|
30
30
|
"mcp-server/",
|
|
31
31
|
"scripts/postinstall.js",
|
|
32
|
+
"scripts/preuninstall.js",
|
|
32
33
|
"src/",
|
|
33
34
|
"skills/",
|
|
34
35
|
"CHANGELOG.md"
|
|
35
36
|
],
|
|
36
37
|
"scripts": {
|
|
37
38
|
"postinstall": "node scripts/postinstall.js",
|
|
38
|
-
"
|
|
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/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
41
|
+
"test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
|
|
42
|
+
"test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
|
|
41
43
|
"typecheck": "tsc --noEmit",
|
|
42
44
|
"regen-fixtures": "node scripts/regen-snippet-fixtures.js"
|
|
43
45
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// telepty#49 — npm preuninstall hook: stop the running daemon and unload the
|
|
5
|
+
// launchd plist QUIETLY before the package files disappear, so `npm rm -g`
|
|
6
|
+
// does not leave a live daemon answering on 3848 or a plist that launchd keeps
|
|
7
|
+
// trying to resurrect. State directories and the plist file itself are left to
|
|
8
|
+
// the explicit `telepty uninstall [--purge]` path.
|
|
9
|
+
//
|
|
10
|
+
// NOTE: npm 7+ no longer executes uninstall lifecycle scripts — this hook
|
|
11
|
+
// covers npm 6 and package managers that still honor it. The documented,
|
|
12
|
+
// reliable cleanup path is `telepty uninstall` (run it BEFORE `npm rm -g`).
|
|
13
|
+
//
|
|
14
|
+
// HARD RULE: this script must never fail — a broken preuninstall would break
|
|
15
|
+
// `npm rm` itself, which is strictly worse than leaving a daemon running.
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const { runPreuninstall } = require('../src/uninstall');
|
|
19
|
+
runPreuninstall();
|
|
20
|
+
} catch {
|
|
21
|
+
// Swallow everything — see HARD RULE above.
|
|
22
|
+
}
|
|
23
|
+
process.exit(0);
|
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,65 @@ async function awaitInputSettled(session, bodyText, opts = {}) {
|
|
|
379
379
|
}
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
// #52: observe whether the injected body was ECHOED by the target TUI in frames
|
|
383
|
+
// appended AFTER the inject (composer/transcript redraw) — consumption evidence that
|
|
384
|
+
// gates the TASK_IDLE_UNCONFIRMED warning. Conservative by design (never-false-complete):
|
|
385
|
+
// - the normalized body must be ≥ minChars (short/common strings claim nothing);
|
|
386
|
+
// - matching samples fixed-length windows of the body (step minChars/2) so a echo
|
|
387
|
+
// wrapped across bordered composer lines is still observed, while requiring
|
|
388
|
+
// `needed` distinct window hits for long bodies;
|
|
389
|
+
// - only frames past the `sinceBytes` watermark count, and a window that ALSO appears
|
|
390
|
+
// in the pre-inject portion of the ring is discarded — a redraw of an identical
|
|
391
|
+
// earlier message is NOT fresh echo.
|
|
392
|
+
// Pure: outputRing-only, DI stripAnsi — no I/O, no daemon coupling.
|
|
393
|
+
function observeInjectEcho(session, bodyText, opts = {}) {
|
|
394
|
+
const minChars = Number.isFinite(opts.minChars) ? opts.minChars : 24;
|
|
395
|
+
const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
|
|
396
|
+
const sinceBytes = Number.isFinite(opts.sinceBytes) ? opts.sinceBytes : null;
|
|
397
|
+
|
|
398
|
+
const body = normalize(bodyText);
|
|
399
|
+
if (body.length < minChars) {
|
|
400
|
+
return { observed: false, reason: body.length === 0 ? 'empty_body' : 'body_too_short' };
|
|
401
|
+
}
|
|
402
|
+
if (!session || !Array.isArray(session.outputRing) || session.outputRing.length === 0) {
|
|
403
|
+
return { observed: false, reason: 'no_ring' };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Split the ring at the inject watermark: only post-inject frames may witness echo,
|
|
407
|
+
// and pre-inject frames veto windows that already existed on screen before the inject.
|
|
408
|
+
let preRaw = '';
|
|
409
|
+
let postRaw = '';
|
|
410
|
+
if (sinceBytes !== null && Number.isFinite(session.outputRingTotalBytes)) {
|
|
411
|
+
const appended = Math.max(0, session.outputRingTotalBytes - sinceBytes);
|
|
412
|
+
if (appended === 0) return { observed: false, reason: 'no_frames_since_inject' };
|
|
413
|
+
const all = session.outputRing.join('');
|
|
414
|
+
const splitAt = Math.max(0, all.length - appended);
|
|
415
|
+
preRaw = all.slice(0, splitAt);
|
|
416
|
+
postRaw = all.slice(splitAt);
|
|
417
|
+
} else {
|
|
418
|
+
// No watermark (legacy entry) — treat the whole ring as post-inject, with no veto.
|
|
419
|
+
postRaw = session.outputRing.join('');
|
|
420
|
+
}
|
|
421
|
+
const post = normalize(stripAnsi(postRaw));
|
|
422
|
+
const pre = normalize(stripAnsi(preRaw));
|
|
423
|
+
|
|
424
|
+
const step = Math.max(1, Math.floor(minChars / 2));
|
|
425
|
+
const windows = [];
|
|
426
|
+
for (let i = 0; i + minChars <= body.length; i += step) {
|
|
427
|
+
windows.push(body.slice(i, i + minChars));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const needed = body.length >= minChars * 2 ? 2 : 1;
|
|
431
|
+
let hits = 0;
|
|
432
|
+
for (const w of windows) {
|
|
433
|
+
if (post.indexOf(w) !== -1 && pre.indexOf(w) === -1) {
|
|
434
|
+
hits++;
|
|
435
|
+
if (hits >= needed) return { observed: true, reason: 'echo', windows_matched: hits };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { observed: false, reason: 'no_echo', windows_matched: hits };
|
|
439
|
+
}
|
|
440
|
+
|
|
382
441
|
function isAcceptedSubmitState(state, submittedAtMs) {
|
|
383
442
|
if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
|
|
384
443
|
if (!Number.isFinite(submittedAtMs)) {
|
|
@@ -553,6 +612,7 @@ module.exports = {
|
|
|
553
612
|
verifyBodyConsumed,
|
|
554
613
|
confirmSubmitAccepted,
|
|
555
614
|
observeBodyVisibility,
|
|
615
|
+
observeInjectEcho,
|
|
556
616
|
awaitPromptSymbol,
|
|
557
617
|
defaultReadScreen,
|
|
558
618
|
isReady,
|
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
|
+
};
|