@dmsdc-ai/aigentry-telepty 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,68 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
+ ## [0.6.6] - 2026-06-14
6
+
7
+ ### Fixed — duplicate same-id `allow` flap loop + `kill` doesn't stick (#56)
8
+
9
+ - **A second `telepty allow --id <X>` no longer makes the session undeliverable-to.** Previously a
10
+ duplicate wrap-owner caused the daemon to oscillate between owners (`Total: 1 ↔ 2`, repeated
11
+ "Replacing stale ownerWs"), so the session never stayed `ready for inject` and **injects were
12
+ silently dropped**. The daemon now does a **durable last-writer-wins Replace**: the displaced
13
+ owner is closed with a dedicated **`4001 'Owner replaced'`** code (reason-independent — a bare
14
+ terminate delivered an empty reason that the bridge mis-read as a reconnect), and the CLI treats
15
+ `4001` as a clean exit with **no reconnect**. The `1000 'Session destroyed'` path is untouched;
16
+ the `#536` owner-token guard still suppresses the displaced bridge's stale-token DELETE (no
17
+ shared-fate cascade). Total settles at 1.
18
+ - **`telepty kill --force` now sticks.** The owning wrap-owner PID is captured at `?owner=1` claim
19
+ time (previously only set on reconnect-register, so a first-connect owner had a null pid and could
20
+ re-register after a kill). Combined with the durable Replace, a killed session no longer respawns.
21
+ Cross-platform (`taskkill /T /F` on Windows).
22
+
23
+ ### Added — daemon lifecycle: `daemon start` (detached) / `stop` / `restart` (#55)
24
+
25
+ - **`telepty daemon` is no longer foreground-only.** Previously the `daemon` command ignored its
26
+ subcommand argument and always started a foreground daemon (so `daemon stop` actually *started*
27
+ one, and there was no `restart`). Now:
28
+ - **`telepty daemon start`** — starts the daemon **detached/background** and returns control to the
29
+ shell immediately (prints pid + listen URL). Fixes one-command install/automation flows.
30
+ - **`telepty daemon stop`** — terminates the daemon process (SIGTERM → SIGKILL) and frees the port.
31
+ **Surgical**: it targets only the state-file pid / configured-port owner and force-disables the
32
+ system-wide process scan, so it can **never reap an unrelated telepty daemon**.
33
+ - **`telepty daemon restart`** — stop + detached start (a cross-platform restart; replaces the
34
+ mac-only `launchctl kickstart` and gives Windows a restart it never had).
35
+ - Bare `telepty daemon` keeps its foreground behavior (install/launchd flows depend on it); the
36
+ internal version-mismatch auto-restart (`ensureDaemonRunning`) is unchanged. `telepty allow`
37
+ (session bridges) stays foreground by design.
38
+
39
+ ## [0.6.5] - 2026-06-13
40
+
41
+ ### Fixed — orchestrator REPORT loss: hold-and-redeliver queued injects until idle (#617)
42
+
43
+ - **A REPORT injected into a busy orchestrator TUI is no longer lost.** When `inject --submit`
44
+ is classified `queued` (consumed=false, recipient busy), the daemon now watches the
45
+ recipient's auto-state and **re-fires the CR when it transitions to idle** — bounded
46
+ (`MAX_REDELIVER=3` + total-time deadline; never an unbounded loop), and never-double-deliver
47
+ (re-fires only while the body is still parked, gated by the #615 consumption check before
48
+ AND after idle). Detached fire-and-forget; kill-switch `TELEPTY_REDELIVER=off`. Closes the
49
+ "`Submitted via pty_cr` succeeds but the busy recipient never starts a new turn" gap that
50
+ forced manual pull-fallback in every orchestration wave.
51
+
52
+ ### Fixed — TASK_IDLE_UNCONFIRMED cry-wolf on long-running Claude turns (#619, telepty#54)
53
+
54
+ - **A genuinely-completed long Claude TUI turn no longer reports `TASK_IDLE_UNCONFIRMED`.**
55
+ Consumption is an EARLY event (the turn fires ~T+2s after inject) but the #52/#545 idle-gate
56
+ evaluated it LATE (at idle, often 13–23 min later) by re-deriving from the output-ring /
57
+ OSC133 marks — by then a long turn's injected body has scrolled off and no fresh REPL-done
58
+ mark remains, so the gate fell back to UNCONFIRMED on every long completion (cry-wolf, which
59
+ trains the orchestrator to ignore the signal and defeats #52's own safety purpose). The
60
+ daemon now **persists the consumption fact at consumption-time** (`maybeRecordInjectConsumption()`
61
+ records `injectConsumedAt` on the first genuine non-busy→working/thinking turn after the CR,
62
+ reusing the #615 consumed signal) and the idle-gate reads that **decay-proof stored fact**
63
+ instead of re-deriving from a stale screen. **#52 guarantee preserved — never a false
64
+ COMPLETE**: the fact is only recorded for a real turn AFTER the CR (startup / sub-state flips
65
+ and busy-park excluded), so a never-consumed inject still yields UNCONFIRMED.
66
+
5
67
  ## [0.6.4] - 2026-06-13
6
68
 
7
69
  ### Added — inject consumption-evidence: consumed | queued | unknown (#53)
package/cli.js CHANGED
@@ -18,6 +18,7 @@ const {
18
18
  findPortOwnerPid,
19
19
  readDaemonState,
20
20
  readRestartFailureMarker,
21
+ stopDaemon,
21
22
  writeRestartFailureMarker
22
23
  } = require('./daemon-control');
23
24
  const { attachInteractiveTerminal, getTerminalSize, restoreTerminalModes } = require('./interactive-terminal');
@@ -428,6 +429,7 @@ function startDetachedDaemon() {
428
429
  stdio: 'ignore'
429
430
  });
430
431
  cp.unref();
432
+ return cp;
431
433
  }
432
434
 
433
435
  async function waitForDaemonHealth(maxMs = 5000) {
@@ -580,6 +582,15 @@ function isDaemonDestroyClose(code, reason) {
580
582
  return code === 1000 && reasonText === 'Session destroyed';
581
583
  }
582
584
 
585
+ // telepty#56: a dedicated 4001 close means the daemon deterministically replaced this wrap-owner
586
+ // with a newer ?owner=1 claim (durable last-writer-wins). The displaced bridge must EXIT, not
587
+ // reconnect — reconnecting would re-contend for the id and oscillate (Total flaps 1<->2). The code
588
+ // is the discriminator (not the reason): a half-open socket may never deliver the close reason.
589
+ // Pure predicate, exposed for unit-testing.
590
+ function isOwnerReplacedClose(code) {
591
+ return code === 4001;
592
+ }
593
+
583
594
  function runUpdateInstall() {
584
595
  if (process.env.TELEPTY_SKIP_PACKAGE_UPDATE === '1') {
585
596
  return;
@@ -1231,6 +1242,58 @@ async function main() {
1231
1242
  }
1232
1243
 
1233
1244
  if (cmd === 'daemon') {
1245
+ // telepty#55: real daemon-lifecycle surface. Pre-0.6.6 this block ignored
1246
+ // args[1] entirely and ALWAYS started a foreground daemon — so `daemon start`
1247
+ // blocked the shell, `daemon stop` actually STARTED a daemon, and `restart`
1248
+ // didn't exist. We now parse the subcommand; bare `telepty daemon` keeps the
1249
+ // foreground behavior for back-compat (install/launchd flows depend on it).
1250
+ const sub = args[1];
1251
+
1252
+ if (sub === 'start') {
1253
+ // Detached/background start: return control to the shell immediately
1254
+ // (cross-platform spawn with detached + stdio:'ignore' + unref). The child
1255
+ // IS the daemon process, so cp.pid is the daemon's pid.
1256
+ const cp = startDetachedDaemon();
1257
+ console.log(`\x1b[32m✅ Telepty daemon started (pid ${cp.pid}) → ${DAEMON_URL}\x1b[0m`);
1258
+ return;
1259
+ }
1260
+
1261
+ if (sub === 'stop') {
1262
+ // Terminate the running daemon (state-file pid + configured-port owner),
1263
+ // graceful SIGTERM→SIGKILL. Surgical: never a system-wide process sweep
1264
+ // (that's `cleanup-daemons`). Internal auto-restart is untouched.
1265
+ const results = stopDaemon({ port: Number(PORT) });
1266
+ if (results.stopped.length === 0 && results.failed.length === 0) {
1267
+ console.log('No telepty daemon running.');
1268
+ } else {
1269
+ if (results.stopped.length > 0) {
1270
+ console.log(`\x1b[32m✅ Stopped telepty daemon (${results.stopped.map((d) => `pid ${d.pid}`).join(', ')}).\x1b[0m`);
1271
+ }
1272
+ if (results.failed.length > 0) {
1273
+ console.error(`\x1b[31m❌ Failed to stop ${results.failed.length} daemon process(es): ${results.failed.map((d) => `pid ${d.pid}`).join(', ')}.\x1b[0m`);
1274
+ process.exitCode = 1;
1275
+ }
1276
+ }
1277
+ return;
1278
+ }
1279
+
1280
+ if (sub === 'restart') {
1281
+ // Clean cross-platform restart = surgical stop + detached start. Replaces
1282
+ // the mac-only `launchctl kickstart` and gives Windows a restart it never
1283
+ // had. Internal auto-restart (ensureDaemonRunning) is NOT touched.
1284
+ stopDaemon({ port: Number(PORT) });
1285
+ const cp = startDetachedDaemon();
1286
+ console.log(`\x1b[32m✅ Telepty daemon restarted (pid ${cp.pid}) → ${DAEMON_URL}\x1b[0m`);
1287
+ return;
1288
+ }
1289
+
1290
+ if (sub) {
1291
+ console.error('❌ Usage: telepty daemon [start|stop|restart]');
1292
+ process.exit(1);
1293
+ }
1294
+
1295
+ // Bare `telepty daemon` — FOREGROUND (back-compat: install/launchd flows run
1296
+ // this and expect a blocking process). `daemon start` is the detached path.
1234
1297
  console.log('Starting telepty daemon...');
1235
1298
  // daemon.js binds the port only when launched as the daemon. The CLI reaches
1236
1299
  // it via require() (not as require.main), so signal intent explicitly — tests
@@ -1769,7 +1832,9 @@ async function main() {
1769
1832
  // Connect to daemon WebSocket with auto-reconnect
1770
1833
  // owner=1 tells daemon this is the allow bridge (owner), not an attach viewer.
1771
1834
  // Daemon uses this to reclaim ownership even if a stale ownerWs is still registered.
1772
- const wsUrl = `${daemonWsUrl(REMOTE_HOST)}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(getAuthToken())}&owner=1`;
1835
+ // telepty#56: owner_pid lets the daemon record this bridge's PID at claim time so
1836
+ // `kill --force` can SIGKILL the owning process (kill-stick), independent of register timing.
1837
+ const wsUrl = `${daemonWsUrl(REMOTE_HOST)}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(getAuthToken())}&owner=1&owner_pid=${process.pid}`;
1773
1838
  let daemonWs = null;
1774
1839
  let wsReady = false;
1775
1840
  let reconnectAttempts = 0;
@@ -1889,6 +1954,16 @@ async function main() {
1889
1954
  }
1890
1955
  return;
1891
1956
  }
1957
+ // #56: the daemon replaced this owner with a newer ?owner=1 claim (close 4001). Exit
1958
+ // cleanly without reconnecting — reconnecting re-contends and oscillates. The teardown
1959
+ // DELETE carries our now-stale ownerToken and is suppressed by the daemon's #536 guard,
1960
+ // so the live new owner is not torn down (no shared-fate cascade).
1961
+ if (isOwnerReplacedClose(code)) {
1962
+ if (closeAllowSession()) {
1963
+ exitAllowSession(0);
1964
+ }
1965
+ return;
1966
+ }
1892
1967
  scheduleReconnect();
1893
1968
  });
1894
1969
 
@@ -3958,7 +4033,8 @@ Discuss the following topic from your project's perspective. Engage with other s
3958
4033
  \x1b[1mtelepty\x1b[0m — Connect any terminal to any terminal, any machine.
3959
4034
 
3960
4035
  \x1b[1mSession Management:\x1b[0m
3961
- telepty daemon Start the background daemon (port 3848)
4036
+ telepty daemon Start the daemon in the foreground (port 3848)
4037
+ telepty daemon start|stop|restart Start (detached) / stop / restart the background daemon
3962
4038
  telepty spawn --id <id> <command> [args...] Spawn a new background session
3963
4039
  telepty allow [--id <id>] [--idle-ttl 1h|off] [--auto-restart] <command> [args...] Wrap a CLI for remote control
3964
4040
  telepty list [--json] List sessions (local + Tailnet)
@@ -4034,6 +4110,7 @@ if (require.main === module) {
4034
4110
  module.exports = {
4035
4111
  classifyBackend, // #29: TERM_PROGRAM/CMUX/kitty → backend string
4036
4112
  isDaemonDestroyClose, // #17 OQ-2: 1000 'Session destroyed' → terminate-not-reconnect
4113
+ isOwnerReplacedClose, // #56: 4001 'Owner replaced' → exit-not-reconnect (durable Replace)
4037
4114
  sanitizePathArg, // #26: path-arg validation/normalization
4038
4115
  decideDaemonAction, // #567: pure restart-decision policy (meta-primary; no I/O)
4039
4116
  ensureDaemonRunning, // #567: orchestrator (injectable probes for unit-testing)
package/daemon-control.js CHANGED
@@ -417,10 +417,32 @@ function cleanupDaemonProcesses(opts) {
417
417
  return { stopped, failed };
418
418
  }
419
419
 
420
+ // telepty#55: user-facing `telepty daemon stop`. Unlike cleanupDaemonProcesses
421
+ // (which ALSO sweeps the whole process table for ANY telepty daemon — the right
422
+ // behavior for `cleanup-daemons` and internal repair), stop must be SURGICAL: it
423
+ // targets only the daemon THIS CLI is configured for — the state-file pid and the
424
+ // owner of the configured port (default 3848). It never blind-scans the process
425
+ // table, so it can never reap an unrelated telepty daemon (e.g. another node's
426
+ // daemon on a different port). This mirrors the #44/#15 survivor-detection surface
427
+ // (state-file pid + port owner) and reuses cleanupDaemonProcesses' graceful
428
+ // SIGTERM→SIGKILL kill path + stale-state cleanup with the global scan disabled.
429
+ function stopDaemon(opts) {
430
+ const o = opts || {};
431
+ return cleanupDaemonProcesses({
432
+ ...o,
433
+ port: Number.isInteger(o.port) && o.port > 0 ? o.port : 3848,
434
+ // Force-disable the system-wide process scan unconditionally (never honor a
435
+ // caller-provided one) — this is the surgical guarantee: stop reaps only the
436
+ // state-file pid and the configured port's owner, never a table sweep.
437
+ listDaemonProcesses: () => []
438
+ });
439
+ }
440
+
420
441
  module.exports = {
421
442
  DAEMON_STATE_FILE,
422
443
  claimDaemonState,
423
444
  cleanupDaemonProcesses,
445
+ stopDaemon,
424
446
  clearDaemonState,
425
447
  clearRestartFailureMarker,
426
448
  findParentProcessInfo,
package/daemon.js CHANGED
@@ -43,6 +43,16 @@ const BOOTSTRAP_READY_TIMEOUT_MS = Math.max(500, Number(process.env.TELEPTY_BOOT
43
43
  // 2026-05-30 surface-ownership verdict — telepty no longer foregrounds surfaces.
44
44
  const WRAPPED_SUBMIT_DELAY_MS = 500;
45
45
 
46
+ // #617 hold-and-redeliver — when an inject(--submit) is classified `queued` (busy
47
+ // recipient parked the CR'd body in its composer, no turn fired), the daemon holds the
48
+ // parked body and re-fires the CR on the recipient's next busy→idle transition so the
49
+ // dropped REPORT turn finally starts. Bounded + never-double-deliver. Kill-switch
50
+ // TELEPTY_REDELIVER=off restores the pre-0.6.5 detect-only behavior (back-compat).
51
+ const REDELIVER_ENABLED = String(process.env.TELEPTY_REDELIVER || '').toLowerCase() !== 'off';
52
+ const REDELIVER_MAX_ATTEMPTS = Math.max(1, Number(process.env.TELEPTY_REDELIVER_MAX_ATTEMPTS || 3));
53
+ const REDELIVER_TOTAL_TIMEOUT_MS = Math.max(1000, Number(process.env.TELEPTY_REDELIVER_TOTAL_TIMEOUT_MS || 600000));
54
+ const REDELIVER_IDLE_WAIT_MS = Math.max(1000, Number(process.env.TELEPTY_REDELIVER_IDLE_WAIT_MS || 120000));
55
+
46
56
  // Session state machine manager — auto-detects session state from PTY output
47
57
  const sessionStateManager = new SessionStateManager({
48
58
  idle_timeout_ms: Number(process.env.TELEPTY_STATE_IDLE_TIMEOUT_MS || 5000),
@@ -82,6 +92,11 @@ sessionStateManager.onTransition((sessionId, from, to, detail) => {
82
92
  pendingReport.sawWorkingAfterInject = true;
83
93
  pendingReport.workingAfterInjectAt = new Date().toISOString();
84
94
  }
95
+ // #619: capture the durable early-consumption fact the instant a genuine fresh turn
96
+ // fires, so the idle-gate (evaluated minutes later on a scrolled-off ring) reads the
97
+ // stored fact instead of failing to re-derive it. since_ms is set at this transition.
98
+ const consumedSinceMs = sessionStateManager.getState(sessionId)?.since_ms;
99
+ maybeRecordInjectConsumption(pendingReport, from, to, consumedSinceMs);
85
100
  }
86
101
 
87
102
  // Fire TASK_IDLE_NO_REPORT on idle transition (for sessions with pendingReports).
@@ -329,6 +344,35 @@ function pendingReportHasSubmitEvidence(pendingReport) {
329
344
  ));
330
345
  }
331
346
 
347
+ // #619: persist inject-CONSUMPTION as a DURABLE FACT at consumption-time. The #52/#545 idle-
348
+ // gate re-derives consumption from the outputRing/OSC133 marks AT IDLE-TIME; on a long Claude
349
+ // turn (idle at T+13-23min) the injected body has scrolled off the ring, so the gate fails to
350
+ // re-derive a genuine completion → false TASK_IDLE_UNCONFIRMED. Recording the fact the instant
351
+ // the turn fires makes the idle-gate decay-proof (it reads the stored fact instead).
352
+ //
353
+ // never-false-complete (the #52 invariant) is preserved by recording ONLY the #615 `consumed`
354
+ // signal — a genuine FRESH turn that started at/after the inject CR:
355
+ // - the transition must enter a turn (→ working/thinking) FROM a non-busy state (idle/waiting);
356
+ // a `starting`→working startup flip (#537 pollution) and a working↔thinking mid-turn sub-
357
+ // state flip (an already-running turn, NOT ours) are both excluded;
358
+ // - the turn's since_ms must be ≥ the inject's submit-start (a turn that predates our CR is the
359
+ // #617 busy-park case — never our consumption);
360
+ // - a submit must have been attempted (submitStartedAt) — a non-submit text-inject records nothing.
361
+ // A never-consumed inject therefore never gets a fact and still signals UNCONFIRMED. Pure +
362
+ // idempotent (first genuine turn wins); mutates the passed pendingReport, returns whether it recorded.
363
+ function maybeRecordInjectConsumption(pendingReport, fromState, toState, transitionSinceMs) {
364
+ if (!pendingReport || pendingReport.injectConsumedAt) return false;
365
+ if (toState !== 'working' && toState !== 'thinking') return false;
366
+ if (fromState !== 'idle' && fromState !== 'waiting') return false;
367
+ if (!pendingReport.submitStartedAt) return false;
368
+ const submitStartedMs = new Date(pendingReport.submitStartedAt).getTime();
369
+ if (!Number.isFinite(submitStartedMs)) return false;
370
+ if (!Number.isFinite(transitionSinceMs) || transitionSinceMs < submitStartedMs) return false;
371
+ pendingReport.injectConsumedAt = new Date(transitionSinceMs).toISOString();
372
+ pendingReport.injectConsumedSinceMs = transitionSinceMs;
373
+ return true;
374
+ }
375
+
332
376
  // #52: the TASK_IDLE_UNCONFIRMED semantic is "inject may NOT have been processed" — gate it
333
377
  // on CONSUMPTION evidence the daemon already owns instead of screen idleness. Evidence:
334
378
  // - a screen-VERIFIED submit confirmation (body consumed from the composer, or a state
@@ -339,6 +383,12 @@ function pendingReportHasSubmitEvidence(pendingReport) {
339
383
  // no-land) is positive NON-consumption and can never be overridden by echo, so the
340
384
  // never-false-complete invariant of #48 holds: a genuinely unconsumed inject still signals.
341
385
  function observeConsumptionEvidence(pendingReport, session) {
386
+ // #619: a durable early-consumption fact (recorded at turn-start) is decay-proof — prefer it
387
+ // over re-deriving from the possibly scrolled-off outputRing at idle-time. This also covers the
388
+ // #48 settle re-entry path (where `confirmed` is force-false) as a suppression backstop.
389
+ if (pendingReport.injectConsumedAt) {
390
+ return { observed: true, reason: 'consumed_recorded' };
391
+ }
342
392
  const confirm = pendingReport.submitConfirm;
343
393
  if (confirm && confirm.accepted === false) {
344
394
  return { observed: false, reason: 'submit_failed' };
@@ -463,6 +513,11 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
463
513
  // as proof of processing — require positive submit confirmation (screen-poll verify /
464
514
  // honest force / gate-off). Paths with no submit expected keep the legacy floor/work rule.
465
515
  const strongSubmitConfirmed = !!(
516
+ // #619: a durable early-consumption fact (a genuine fresh turn fired by the inject) is
517
+ // the strongest completion proof there is — stronger than a screen-derived submit confirm
518
+ // and decay-proof at idle-time. Recorded conservatively (maybeRecordInjectConsumption), so
519
+ // this never promotes a never-consumed inject.
520
+ pendingReport.injectConsumedAt ||
466
521
  pendingReport.submitConfirmedAt ||
467
522
  (pendingReport.submitConfirm && pendingReport.submitConfirm.accepted === true)
468
523
  );
@@ -473,9 +528,14 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
473
528
  // symptom — a submit-confirmed worker still thinking), consistent with the BUG-B confirm gate;
474
529
  // plain non-submit injects keep their existing floor-based completion. Absent flag / other
475
530
  // triggers preserve prior behavior.
531
+ // #619: a recorded early-consumption fact overrides the decayed at-idle evidence. The
532
+ // `idleEvidenceReliable === false` downgrade exists because the screen-derived evidence is
533
+ // weak; a stored consumption fact IS the (decay-proof) evidence, so the downgrade no longer
534
+ // applies. Without a recorded fact, behavior is unchanged (#545/#52 conservative UNCONFIRMED).
476
535
  const idleEvidenceUnreliable = trigger === 'real-idle'
477
536
  && pendingReport.submitExpected
478
- && deps.idleEvidenceReliable === false;
537
+ && deps.idleEvidenceReliable === false
538
+ && !pendingReport.injectConsumedAt;
479
539
  // #48: a settled recheck re-enters ONLY to emit the UNCONFIRMED label — pinned at arm time,
480
540
  // so elapsed growing past the floor during the settle window can never promote a stale idle
481
541
  // snapshot to TASK_COMPLETE (never a false complete).
@@ -984,6 +1044,97 @@ async function gatedTerminalSubmit(id, session, injectedBody, settleEnabled) {
984
1044
  return terminalLevelSubmit(id, session);
985
1045
  }
986
1046
 
1047
+ // #617 — detached hold-and-redeliver for a `queued` inject. The /submit response has
1048
+ // already returned (the worker's `telepty inject` got consumption='queued' / a plain
1049
+ // force success and EXITS), so the daemon owns delivery from here: it re-classifies
1050
+ // when the status is unknown (the force path skips the synchronous classify), and if
1051
+ // `queued`, runs the bounded submitGate.holdAndRedeliver loop — watching the recipient's
1052
+ // auto-state for busy→idle (awaitReplReady) and re-firing the bare CR (the body is still
1053
+ // parked in the composer) until it is consumed as a fresh turn. Fire-and-forget: never
1054
+ // awaited by the handler, so it cannot affect the response or block the caller.
1055
+ function scheduleQueuedRedeliver(id, session, injectedBody, opts = {}) {
1056
+ if (!REDELIVER_ENABLED) return;
1057
+ if (!injectedBody || injectedBody.length === 0) return;
1058
+ if (!session) return;
1059
+ // One in-flight redeliver per session — never stack idle-watchers for the same composer.
1060
+ if (session._redeliverInFlight) return;
1061
+ session._redeliverInFlight = true;
1062
+
1063
+ const emitSubmitBus = typeof opts.emitSubmitBus === 'function' ? opts.emitSubmitBus : () => {};
1064
+ const knownConsumption = opts.knownConsumption || null;
1065
+
1066
+ const isParked = () =>
1067
+ submitGate.observeBodyVisibility(session, injectedBody, { stripAnsi: stripAnsiState }).visible === true;
1068
+
1069
+ // Re-fire a bare CR (body already parked) then re-classify against a FRESH watermark:
1070
+ // the recipient is now idle, so a genuine idle→working/thinking turn is observable as
1071
+ // `consumed` (#53). A still-`queued` result means the CR did not fire the composer — retry.
1072
+ const fireCR = async () => {
1073
+ const strategy = await gatedTerminalSubmit(id, session, injectedBody, true);
1074
+ if (!strategy) return { redelivered: false, reason: 'strategy_failed' };
1075
+ const submittedAtMs = Date.now();
1076
+ const sinceBytes = session.outputRingTotalBytes || 0;
1077
+ const c = await submitGate.classifyInjectConsumption(session, injectedBody, {
1078
+ submittedAtMs,
1079
+ sinceBytes,
1080
+ getState: () => sessionStateManager.getState(id),
1081
+ stripAnsi: stripAnsiState,
1082
+ });
1083
+ return { redelivered: c.status === 'consumed', reason: c.reason };
1084
+ };
1085
+
1086
+ const waitForIdle = (remainingMs) =>
1087
+ submitGate.awaitReplReady(id, sessionStateManager, {
1088
+ timeoutMs: Math.min(remainingMs, REDELIVER_IDLE_WAIT_MS),
1089
+ });
1090
+
1091
+ const run = async () => {
1092
+ // Force path skips the synchronous classify — establish the `queued` precondition here
1093
+ // before holding an idle-watcher. Only a busy-parked body needs rescue.
1094
+ if (knownConsumption !== 'queued') {
1095
+ if (Number.isFinite(opts.submittedAtMs)) {
1096
+ const c = await submitGate.classifyInjectConsumption(session, injectedBody, {
1097
+ submittedAtMs: opts.submittedAtMs,
1098
+ sinceBytes: Number.isFinite(opts.ringBytesAtSubmit) ? opts.ringBytesAtSubmit : (session.outputRingTotalBytes || 0),
1099
+ getState: () => sessionStateManager.getState(id),
1100
+ stripAnsi: stripAnsiState,
1101
+ });
1102
+ if (c.status !== 'queued') return; // consumed / unknown — nothing to redeliver
1103
+ } else {
1104
+ return; // no watermark to classify against — cannot safely redeliver
1105
+ }
1106
+ }
1107
+
1108
+ console.log(`[REDELIVER] ${id} inject queued on busy recipient — holding for idle to re-fire`);
1109
+ const result = await submitGate.holdAndRedeliver({
1110
+ waitForIdle,
1111
+ isStillParked: isParked,
1112
+ fireCR,
1113
+ maxAttempts: REDELIVER_MAX_ATTEMPTS,
1114
+ totalTimeoutMs: REDELIVER_TOTAL_TIMEOUT_MS,
1115
+ onAttempt: ({ attempt }) =>
1116
+ console.log(`[REDELIVER] ${id} idle — re-firing queued inject (attempt ${attempt}/${REDELIVER_MAX_ATTEMPTS})`),
1117
+ onExhausted: ({ reason, attempts }) => {
1118
+ console.log(`[REDELIVER] ${id} redeliver-exhausted (${reason}, attempts=${attempts})`);
1119
+ emitSubmitBus({ redeliver: 'exhausted', redeliver_reason: reason, redeliver_attempts: attempts });
1120
+ },
1121
+ });
1122
+
1123
+ if (result.status === 'redelivered') {
1124
+ console.log(`[REDELIVER] ${id} queued inject redelivered after ${result.attempts} attempt(s)`);
1125
+ markPendingReportSubmitConfirmed(id, { reason: 'redelivered', attempts: result.attempts });
1126
+ emitSubmitBus({ redeliver: 'redelivered', redeliver_attempts: result.attempts });
1127
+ } else if (result.status === 'already_consumed') {
1128
+ console.log(`[REDELIVER] ${id} queued inject already consumed (${result.reason}) — no re-fire`);
1129
+ }
1130
+ };
1131
+
1132
+ Promise.resolve()
1133
+ .then(run)
1134
+ .catch((err) => console.log(`[REDELIVER] ${id} redeliver error: ${err && err.message}`))
1135
+ .finally(() => { session._redeliverInFlight = false; });
1136
+ }
1137
+
987
1138
  async function executeBootstrapSubmit(sessionId, session, op) {
988
1139
  const body = op.body || {};
989
1140
  const injectedBody = typeof body.injected_body === 'string' ? body.injected_body : null;
@@ -2689,7 +2840,9 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2689
2840
  if (injectedBody) {
2690
2841
  markPendingReportSubmitStarted(id, injectedBody);
2691
2842
  }
2843
+ const forceRingBytesAtSubmit = session.outputRingTotalBytes || 0;
2692
2844
  const strategy = terminalLevelSubmit(id, session);
2845
+ const forceSubmittedAtMs = Date.now();
2693
2846
  if (strategy) {
2694
2847
  // #537 / Bug B: force-confirm must reflect ACTUAL delivery. A pty_cr fallback on a
2695
2848
  // cmux surface means cmux send-key failed and Enter never reached the CLI — record
@@ -2702,6 +2855,17 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2702
2855
  markPendingReportSubmitUnconfirmed(id, { reason: 'cmux_send_failed', attempts: 1, retryable: true });
2703
2856
  }
2704
2857
  }
2858
+ // #617: the force path skips the synchronous consumption classify, so a busy-parked
2859
+ // body would silently drop (this IS the worker's `--submit-force` REPORT path). Hand it
2860
+ // to the detached redeliver — it classifies against the CR watermark and only re-fires
2861
+ // if `queued`. No-op when the body was consumed/unknown or absent.
2862
+ if (injectedBody && deliveredToSurface) {
2863
+ scheduleQueuedRedeliver(id, session, injectedBody, {
2864
+ submittedAtMs: forceSubmittedAtMs,
2865
+ ringBytesAtSubmit: forceRingBytesAtSubmit,
2866
+ emitSubmitBus,
2867
+ });
2868
+ }
2705
2869
  emitSubmitBus({ strategy, attempts: 1, gated: false, forced: true, submit_confirmed: deliveredToSurface });
2706
2870
  return res.json({ success: true, strategy, attempts: 1, gated: false, forced: true, submit_confirmed: deliveredToSurface });
2707
2871
  }
@@ -2874,6 +3038,19 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2874
3038
  verify.consumption_reason = consumptionResult.reason;
2875
3039
  }
2876
3040
 
3041
+ // #617: a `queued` body was parked on a busy recipient and will never fire on its own.
3042
+ // Hand it to the detached hold-and-redeliver loop (re-fires the CR on busy→idle). This
3043
+ // runs independent of whether the handler returns 200 or 504 below — delivery is the
3044
+ // daemon's responsibility now that the worker no longer needs to poll the status.
3045
+ if (consumption === 'queued') {
3046
+ scheduleQueuedRedeliver(id, session, injectedBody, {
3047
+ knownConsumption: 'queued',
3048
+ submittedAtMs,
3049
+ ringBytesAtSubmit,
3050
+ emitSubmitBus,
3051
+ });
3052
+ }
3053
+
2877
3054
  if (confirm && !confirm.accepted) {
2878
3055
  const reason = gatedDispatchAfterTimeout ? 'gated_dispatch_unconsumed' : 'submit_unconfirmed';
2879
3056
  const failBody = {
@@ -4253,6 +4430,7 @@ if (require.main === module) {
4253
4430
  // production call sites is unchanged. NOT a public API — internal/test use only.
4254
4431
  module.exports = {
4255
4432
  fireAutoReport, // #32: provenance-tagged auto-report (deps DI: now/deliver/...)
4433
+ maybeRecordInjectConsumption, // #619: durable early-consumption fact capture (idle-gate decay-proofing)
4256
4434
  forceSubmitDeliveredToSurface, // #544/#537/Bug B: PTY-native force-confirm (pty_cr = delivered)
4257
4435
  terminalLevelSubmit, // #544: PTY-only submit path (pty_cr | null)
4258
4436
  submitViaPty, // #544: bare-0x0D submit into the innermost node-pty
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -37,9 +37,9 @@
37
37
  "scripts": {
38
38
  "postinstall": "node scripts/postinstall.js",
39
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/",
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/dupid-flap-kill-stick-56.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/daemon-lifecycle-55.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/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.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/dupid-flap-kill-stick-56.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/daemon-lifecycle-55.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/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.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/dupid-flap-kill-stick-56.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/daemon-lifecycle-55.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/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.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/",
43
43
  "typecheck": "tsc --noEmit",
44
44
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
45
45
  },
@@ -10,8 +10,9 @@ telepty is a PTY multiplexer and session orchestrator. It creates, connects, and
10
10
  ## Quick Start
11
11
 
12
12
  ```bash
13
- # Start daemon
14
- telepty daemon
13
+ # Start daemon in the background (detached; returns the shell immediately)
14
+ telepty daemon start
15
+ # (bare `telepty daemon` runs it in the FOREGROUND — used by install/launchd)
15
16
 
16
17
  # Create a session wrapping Claude Code
17
18
  telepty allow --id my-session claude
@@ -45,7 +46,7 @@ telepty tui
45
46
 
46
47
  - For humans: prefer natural-language examples and TUI, then raw CLI commands
47
48
  - For AI agents: use raw `telepty` commands directly for execution
48
- - When daemon is broken: repair first with `telepty cleanup-daemons && telepty daemon`
49
+ - When daemon is broken: repair first with `telepty cleanup-daemons && telepty daemon start` (or `telepty daemon restart`)
49
50
 
50
51
  ## Related Skills
51
52
 
@@ -57,6 +57,22 @@ COLUMNS=120 LINES=40 telepty allow --id headless-session claude
57
57
  | `TELEPTY_SESSION_ID` | The session ID you specified |
58
58
  | `TELEPTY_AVAILABLE` | `true` |
59
59
 
60
+ ### Duplicate `--id` is last-writer-wins (deterministic replace)
61
+
62
+ Running `telepty allow --id <X> …` again for an id that already has a live wrap-owner
63
+ **deterministically replaces** the old owner (last-writer-wins): the newer allow takes over the id,
64
+ and the **older bridge exits** (close code `4001 'Owner replaced'` — it does not reconnect). The
65
+ session stays continuously `ready for inject`; there is no owner flap and no dropped injects.
66
+
67
+ This is the intended path for a clean restart (e.g. `orchestrator-boot.sh` kill-9s a stale bridge
68
+ then re-`allow`s). You do not need to `kill` the old session first — the new `allow` reclaims it.
69
+
70
+ ### kill stops the owning process (kill sticks)
71
+
72
+ `telepty kill <X> --force` terminates the **owning wrap-owner process**, not just the session
73
+ record. The owner's PID is captured when the bridge claims the id, so `--force` SIGKILLs it
74
+ (cross-platform: `taskkill /T /F` on Windows). A killed session does not silently re-register.
75
+
60
76
  ### Aliases
61
77
 
62
78
  `telepty enable` and `telepty wrap` are aliases for `telepty allow`.
@@ -1,20 +1,38 @@
1
1
  ---
2
2
  name: telepty-daemon
3
- description: 'Manage the telepty daemon — start, stop, repair, update, and TUI dashboard. Use when daemon is broken or needs maintenance. 키워드: 데몬 시작, 데몬 재시작, 데몬 종료, TUI, 대시보드, 데몬 상태, daemon'
3
+ description: 'Manage the telepty daemon — start (detached), stop, restart, repair, update, and TUI dashboard. Use when daemon is broken or needs maintenance. 키워드: 데몬 시작, 데몬 재시작, 데몬 종료, 백그라운드, TUI, 대시보드, 데몬 상태, daemon'
4
4
  ---
5
5
 
6
6
  # telepty-daemon — Daemon Management
7
7
 
8
- ## daemon — Start the daemon
8
+ ## daemon — Start the daemon (foreground)
9
9
 
10
10
  ```bash
11
11
  telepty daemon
12
12
  ```
13
13
 
14
- Starts the telepty daemon on port 3848. The daemon manages all sessions, handles inject delivery, and serves the HTTP/WS API.
14
+ Starts the telepty daemon on port 3848 **in the foreground** (blocks the shell). The daemon manages all sessions, handles inject delivery, and serves the HTTP/WS API. This is the form install/launchd flows use; for interactive/automation use, prefer `telepty daemon start` below.
15
15
 
16
16
  The daemon auto-starts when needed (e.g., `telepty allow` starts it automatically). Manual start is rarely needed.
17
17
 
18
+ ## daemon start | stop | restart — Background lifecycle
19
+
20
+ ```bash
21
+ telepty daemon start # start DETACHED in the background, return the shell immediately
22
+ telepty daemon stop # gracefully stop the running daemon (SIGTERM → SIGKILL), free the port
23
+ telepty daemon restart # stop, then start detached (clean cross-platform restart)
24
+ ```
25
+
26
+ Cross-platform (macOS / Linux / Windows):
27
+
28
+ - **`start`** spawns the daemon detached (`stdio` ignored, unref'd) and returns control to the shell at once, printing the pid and listen URL. Use this for one-command install/automation instead of the blocking foreground `telepty daemon`.
29
+ - **`stop`** is surgical — it terminates only the daemon this CLI is configured for (the state-file pid and/or the owner of the configured port, default 3848). It does **not** sweep the whole process table (that is `cleanup-daemons`), so it never reaps an unrelated telepty daemon.
30
+ - **`restart`** = `stop` then detached `start`. Replaces the macOS-only `launchctl kickstart` and gives Windows a restart it never had.
31
+
32
+ Natural-language: "데몬 백그라운드로 시작", "데몬 종료해줘", "데몬 재시작", "start the daemon in the background", "stop/restart the daemon".
33
+
34
+ > Internal auto-restart (version-mismatch recovery) is unaffected — these are user-facing controls layered on top.
35
+
18
36
  ## cleanup-daemons — Kill stale daemon processes
19
37
 
20
38
  ```bash
@@ -517,6 +517,92 @@ async function classifyInjectConsumption(session, bodyText, opts = {}) {
517
517
  }
518
518
  }
519
519
 
520
+ // #617 hold-and-redeliver — close the gap between #53 DETECTION and ACTION.
521
+ //
522
+ // A recipient that is BUSY when the inject CR is written parks the body in its
523
+ // composer ("Press up to edit queued messages") and never starts a turn (#53 fact 1).
524
+ // classifyInjectConsumption already DETECTS this (`queued`), but 0.6.4 only reports
525
+ // the status — the worker's `telepty inject` never reads it and exits, so the parked
526
+ // REPORT turn is silently dropped. This loop is the ACTION: hold the parked body,
527
+ // wait for the recipient's busy→idle transition, then re-fire the CR so the queued
528
+ // turn finally fires. The body is ALREADY in the composer, so a bare CR (fireCR)
529
+ // fires it — never re-inject the body (that would duplicate text).
530
+ //
531
+ // Invariants:
532
+ // - bounded: at most `maxAttempts` CR re-fires AND a `totalTimeoutMs` deadline —
533
+ // never an unbounded redeliver loop (Rule 27: redelivery is the fix, not a
534
+ // forever-retry workaround).
535
+ // - never-double-deliver: re-fire ONLY while the body is still parked
536
+ // (`isStillParked`, reusing #52/#53 echo observation at the daemon seam). The
537
+ // gate is checked before the idle wait AND again right after idle — a turn that
538
+ // auto-consumed the queue as it ended must not get a second CR.
539
+ // - back-compat: the daemon runs this DETACHED from the /submit response; a caller
540
+ // that ignores `consumption` is wholly unaffected.
541
+ //
542
+ // Pure: every effect (idle watch, parked check, CR fire) is injected; DI now. No
543
+ // daemon coupling — the daemon wires real implementations, tests wire doubles.
544
+ //
545
+ // @param {{
546
+ // waitForIdle: (remainingMs: number) => Promise<{ ready: boolean, reason?: string }>,
547
+ // isStillParked: () => boolean,
548
+ // fireCR: () => Promise<{ redelivered: boolean, reason?: string }>,
549
+ // maxAttempts?: number, totalTimeoutMs?: number,
550
+ // onAttempt?: Function, onExhausted?: Function, now?: Function
551
+ // }} opts
552
+ // @returns {Promise<{ status: 'redelivered'|'already_consumed'|'exhausted', reason: string, attempts: number, waited_ms: number }>}
553
+ async function holdAndRedeliver(opts = {}) {
554
+ const maxAttempts = Number.isFinite(opts.maxAttempts) ? opts.maxAttempts : 3;
555
+ const totalTimeoutMs = Number.isFinite(opts.totalTimeoutMs) ? opts.totalTimeoutMs : 600000;
556
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
557
+ const waitForIdle = typeof opts.waitForIdle === 'function' ? opts.waitForIdle : null;
558
+ const isStillParked = typeof opts.isStillParked === 'function' ? opts.isStillParked : () => true;
559
+ const fireCR = typeof opts.fireCR === 'function' ? opts.fireCR : null;
560
+ const onAttempt = typeof opts.onAttempt === 'function' ? opts.onAttempt : () => {};
561
+ const onExhausted = typeof opts.onExhausted === 'function' ? opts.onExhausted : () => {};
562
+
563
+ const start = now();
564
+ if (!waitForIdle || !fireCR) {
565
+ return { status: 'exhausted', reason: 'no_dependencies', attempts: 0, waited_ms: 0 };
566
+ }
567
+
568
+ let attempts = 0;
569
+ while (attempts < maxAttempts) {
570
+ if (now() - start >= totalTimeoutMs) {
571
+ onExhausted({ reason: 'deadline', attempts });
572
+ return { status: 'exhausted', reason: 'deadline', attempts, waited_ms: now() - start };
573
+ }
574
+
575
+ // never-double-deliver (pre-wait): if the body already left the composer, stop.
576
+ if (!isStillParked()) {
577
+ return { status: 'already_consumed', reason: 'not_parked', attempts, waited_ms: now() - start };
578
+ }
579
+
580
+ const remaining = totalTimeoutMs - (now() - start);
581
+ const idle = await waitForIdle(remaining);
582
+ if (!idle || !idle.ready) {
583
+ onExhausted({ reason: (idle && idle.reason) || 'idle_timeout', attempts });
584
+ return { status: 'exhausted', reason: 'idle_timeout', attempts, waited_ms: now() - start };
585
+ }
586
+
587
+ // never-double-deliver (post-idle): the turn that just ended may have auto-fired
588
+ // the queued body. Re-check before committing a CR.
589
+ if (!isStillParked()) {
590
+ return { status: 'already_consumed', reason: 'consumed_on_idle', attempts, waited_ms: now() - start };
591
+ }
592
+
593
+ attempts++;
594
+ onAttempt({ attempt: attempts });
595
+ const fired = await fireCR();
596
+ if (fired && fired.redelivered) {
597
+ return { status: 'redelivered', reason: fired.reason || 'consumed', attempts, waited_ms: now() - start };
598
+ }
599
+ // still queued/unknown — loop to await the next idle window (bounded by maxAttempts).
600
+ }
601
+
602
+ onExhausted({ reason: 'max_attempts', attempts });
603
+ return { status: 'exhausted', reason: 'max_attempts', attempts, waited_ms: now() - start };
604
+ }
605
+
520
606
  function isAcceptedSubmitState(state, submittedAtMs) {
521
607
  if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
522
608
  if (!Number.isFinite(submittedAtMs)) {
@@ -693,6 +779,7 @@ module.exports = {
693
779
  observeBodyVisibility,
694
780
  observeInjectEcho,
695
781
  classifyInjectConsumption,
782
+ holdAndRedeliver,
696
783
  awaitPromptSymbol,
697
784
  defaultReadScreen,
698
785
  isReady,
@@ -103,11 +103,31 @@ function installWebSocketTransport(deps) {
103
103
  if (activeSession.type === 'wrapped' && (!activeSession.ownerWs || isOwnerConnect)) {
104
104
  const hadDisconnectedOwner = !isOpenWebSocket(activeSession.ownerWs) && activeSession.lastDisconnectedAt;
105
105
  if (isOwnerConnect && activeSession.ownerWs && activeSession.ownerWs !== ws) {
106
- // Terminate the stale owner connection before claiming ownership
106
+ // telepty#56 (durable last-writer-wins Replace): close the displaced owner with the
107
+ // dedicated terminal code 4001 'Owner replaced' instead of a bare terminate(). A
108
+ // terminate() is an abnormal 1006 close, which a bridge reads as a transient drop and
109
+ // RECONNECTS — re-contending for the id and oscillating forever (Total flaps 1<->2,
110
+ // injects dropped). 4001 is reason-independent (WS 4000-4999 = app-reserved), so the
111
+ // displaced bridge exits without reconnecting even when the close reason is lost on a
112
+ // half-open TCP socket. The session RECORD survives under the new owner; no shared-fate
113
+ // cascade — the displaced bridge's now-stale ownerToken is suppressed by the #536 DELETE
114
+ // guard. A fallback terminate() reaps a half-open socket that never ACKs the close.
107
115
  console.log(`[WS] Replacing stale ownerWs for session ${sessionId}`);
108
- activeSession.ownerWs.terminate();
116
+ const displaced = activeSession.ownerWs;
117
+ try { displaced.close(4001, 'Owner replaced'); } catch {}
118
+ setTimeout(() => {
119
+ if (displaced.readyState !== 3) { try { displaced.terminate(); } catch {} }
120
+ }, 1000);
109
121
  }
110
122
  activeSession.ownerWs = ws;
123
+ // telepty#56 (kill-stick): capture the owner PID at claim time. The reconnect-register POST
124
+ // only carries owner_pid on reconnect, so a first-connect owner would otherwise have a null
125
+ // ownerPid and `kill --force` could not SIGKILL the owning process. The bridge passes its pid
126
+ // on the ?owner=1 URL; record it so the kill path always has a process to signal.
127
+ const claimOwnerPid = Number(url.searchParams.get('owner_pid'));
128
+ if (Number.isInteger(claimOwnerPid) && claimOwnerPid > 0) {
129
+ activeSession.ownerPid = claimOwnerPid;
130
+ }
111
131
  // BUG-C: mint a fresh per-owner token on every claim/reclaim and push it to this owner.
112
132
  // The token is the exact "are-you-the-current-owner" discriminator the DELETE guard uses
113
133
  // to suppress a stale/displaced owner's teardown (shared-fate fix). Reclaim refreshes it,