@dmsdc-ai/aigentry-telepty 0.3.5 → 0.4.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/cli.js +36 -15
  3. package/daemon.js +355 -5
  4. package/package.json +25 -1
  5. package/session-state.js +23 -0
  6. package/src/prompt-symbol-registry.js +43 -1
  7. package/.claude/commands/telepty-allow.md +0 -58
  8. package/.claude/commands/telepty-attach.md +0 -22
  9. package/.claude/commands/telepty-inject.md +0 -72
  10. package/.claude/commands/telepty-list.md +0 -22
  11. package/.claude/commands/telepty-manual-test.md +0 -73
  12. package/.claude/commands/telepty-start.md +0 -25
  13. package/.claude/commands/telepty-test.md +0 -25
  14. package/.claude/commands/telepty.md +0 -82
  15. package/AGENTS.md +0 -97
  16. package/BOUNDARY.md +0 -31
  17. package/BUS_EVENT_SCHEMA.md +0 -206
  18. package/CLAUDE.md +0 -100
  19. package/GEMINI.md +0 -10
  20. package/URGENT_ISSUES.resolved.md +0 -1
  21. package/docs/reports/2026-05-05-issue-8-claude-review.md +0 -194
  22. package/docs/specs/2026-05-05-issue-8-telepty-init.md +0 -477
  23. package/docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md +0 -447
  24. package/docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md +0 -571
  25. package/docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md +0 -608
  26. package/docs/superpowers/specs/2026-05-02-submit-force-and-retry.md +0 -139
  27. package/protocol/mailbox.md +0 -244
  28. package/scripts/regen-snippet-fixtures.js +0 -42
  29. package/specs/codex-inject-spec.md +0 -201
  30. package/specs/enforce-report-spec.md +0 -237
  31. package/templates/AGENTS.md +0 -71
  32. package/tests/snippet-protocol/v1/golden-agents.json +0 -1
  33. package/tests/snippet-protocol/v1/golden-agents.md +0 -17
  34. package/tests/snippet-protocol/v1/golden-all.json +0 -3
  35. package/tests/snippet-protocol/v1/golden-all.md +0 -53
  36. package/tests/snippet-protocol/v1/golden-claude.json +0 -1
  37. package/tests/snippet-protocol/v1/golden-claude.md +0 -17
  38. package/tests/snippet-protocol/v1/golden-gemini.json +0 -1
  39. package/tests/snippet-protocol/v1/golden-gemini.md +0 -17
package/CHANGELOG.md CHANGED
@@ -2,6 +2,78 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
+ ## [0.4.0] — 2026-05-15
6
+
7
+ ### Added — Phase 1 sidecar supervisor spike (M1–M5)
8
+
9
+ Out-of-process Rust supervisor (`crates/telepty-supervisor-{core,bin}`)
10
+ incubating the future spawn/kill/IPC backend for `daemon.js`. Five
11
+ milestones complete; **incubating only — not on the request path** in
12
+ 0.4.0. Daemon (`daemon.js`) and CLI (`cli.js`) routing is unchanged.
13
+
14
+ - **M1** — spawn + observe (commit `07cd2e7`).
15
+ - **M2** — graceful + forced kill gate, manifest cleanup invariant A8
16
+ (commit `ec00412`).
17
+ - **M3** — IPC + wire contract conformance, NDJSON UDS frames + golden
18
+ fixtures (commit `76cde35`).
19
+ - **M4** — cross-OS POSIX parity + reproducible RSS measurement, GitHub
20
+ Actions matrix (`.github/workflows/phase1-spike-ci.yml`); RSS PASS at
21
+ 2.9–3.0 MiB / supervisor on macOS arm64 (commit `eb04c73`).
22
+ - **M5** — manual integration bridge (`scripts/bridge-phase1.js`, 194
23
+ LOC Node stdlib only) — four parity scenarios A/B/C/D, exit 0 iff all
24
+ PASS; one-line Rust correctness fix (emit `shutdown_drain` before IPC
25
+ shutdown so connected clients receive the frame) (commit `be091e0`).
26
+
27
+ Phase 1 LOC ceiling 1500 honored (Rust src/ tokei = 1240, 260 LOC
28
+ headroom unused). Test suite: 23/23 (lib unit 12 + wire_golden 6 +
29
+ ipc_protocol 5). Spec: `docs/specs/2026-05-10-supervisor-c3-kill-gate-spec.md`.
30
+ Plan: `docs/plans/2026-05-12-phase1-sidecar-spike-plan.md`.
31
+
32
+ ### Fixed
33
+
34
+ - **#18** — Bootstrap inject queue race. Welcome-banner bypass via
35
+ positive-override `is_ready` so queued injects flush in the correct
36
+ order without colliding with the banner (commit `744ad6a`).
37
+ - **#16** — REPORT-based idle status detection. Replaces heuristic
38
+ prompt-symbol detection with explicit REPORT-frame anchoring
39
+ (commit `3ed1e83`).
40
+
41
+ ### Build
42
+
43
+ - `package.json` — added `files` whitelist (22 entries) to constrain
44
+ npm-published surface to actual runtime distribution. Tarball
45
+ reduction: 228 MB → 123 kB (1850×). The Rust spike artifacts
46
+ (`target/`, `crates/`, `Cargo.lock/Cargo.toml`, `rust-toolchain.toml`)
47
+ ship in git but **not** to npm (commit `a0baf84`).
48
+
49
+ ### Docs
50
+
51
+ - MD audit wave-2 fix: `CLAUDE.md` converted to `@AGENTS.md` stub
52
+ (101 → 27 lines), `AGENTS.md` gained Session Environment section
53
+ (`$TELEPTY_SESSION_ID`, `$TELEPTY_AVAILABLE`) and disclosed
54
+ cross-repo ADR location for `2026-05-05-telepty-devkit-boundary §6.2.1`.
55
+ Score delta `AGENTS.md` 80 → 87, `CLAUDE.md` 66 → 87 (commit `74a6374`,
56
+ full report `docs/reports/2026-05-14-md-audit.md`).
57
+
58
+ ### Notes
59
+
60
+ - **Snyk SAST deferred for this release** — see follow-up task #130.
61
+ Waiver basis (Rule 32-A track B):
62
+ - M1–M5 spike code is Rust and is **excluded from the npm tarball** by
63
+ the new `files` whitelist — first-party code shipped to consumers is
64
+ JS only.
65
+ - The shipped JS files are unchanged or only minimally changed since
66
+ `0.3.5` (cli.js +51 / daemon.js +360 / src/prompt-symbol-registry.js
67
+ +44 / new session-state.js — additive, no breaks per Phase 1 audit).
68
+ - Dependency-side coverage exists via `npm audit` (10 pre-existing
69
+ vulns documented; not introduced by this release).
70
+ - Per CLAUDE.md user-instruction "Snyk At Inception" scope = *new
71
+ first-party code shipped* — 0 net-new shipped JS code in this
72
+ release, so the at-inception rule does not bind here. Follow-up
73
+ task #130 will land the standing SAST gate as a release-script
74
+ primitive (so future releases scan automatically without per-run
75
+ auth steps).
76
+
5
77
  ## [0.3.5] — 2026-05-05
6
78
 
7
79
  ### Added — `telepty init --print-snippet` (Issue #8)
package/cli.js CHANGED
@@ -20,6 +20,7 @@ const { runInteractiveSkillInstaller } = require('./skill-installer');
20
20
  const crossMachine = require('./cross-machine');
21
21
  const { parseHostSpec, buildDaemonUrl, buildDaemonWsUrl } = require('./host-spec');
22
22
  const { FileMailbox } = require('./src/mailbox/index');
23
+ const readyRegistry = require('./src/prompt-symbol-registry');
23
24
  const args = process.argv.slice(2);
24
25
  let pendingTerminalInputError = null;
25
26
  let simulatedPromptErrorInjected = false;
@@ -1080,15 +1081,14 @@ async function main() {
1080
1081
 
1081
1082
  spawnChild();
1082
1083
 
1083
- // Prompt-ready detection for safe inject delivery
1084
- const PROMPT_PATTERNS = {
1085
- claude: /[❯>]\s*$/,
1086
- gemini: /[❯>]\s*$/,
1087
- codex: /[❯>]\s*$/,
1088
- };
1089
- const cmdBase = path.basename(command).replace(/\..*$/, '');
1090
- const promptPattern = PROMPT_PATTERNS[cmdBase] || /[❯>$#%]\s*$/;
1084
+ // Prompt-ready detection for safe inject delivery.
1085
+ // Known AI CLIs use the centralized geometry-aware registry; generic
1086
+ // commands keep the permissive legacy prompt regex for compatibility.
1087
+ const knownAiCli = readyRegistry.isKnownAiCli(command);
1088
+ const promptPattern = /[❯>$#%]\s*$/;
1091
1089
  let promptReady = false; // wait for CLI prompt before accepting inject
1090
+ let firstReadyObserved = false;
1091
+ let outputTail = '';
1092
1092
  let lastUserInputTime = 0; // timestamp of last user keystroke
1093
1093
  const IDLE_THRESHOLD = 2000; // ms after last user input to consider idle
1094
1094
 
@@ -1128,6 +1128,14 @@ async function main() {
1128
1128
  return promptReady && (Date.now() - lastUserInputTime > IDLE_THRESHOLD);
1129
1129
  }
1130
1130
 
1131
+ function observePromptReady(data) {
1132
+ if (knownAiCli) {
1133
+ outputTail = (outputTail + data).slice(-20000);
1134
+ return !!readyRegistry.detectOutput(command, outputTail).found;
1135
+ }
1136
+ return promptPattern.test(data);
1137
+ }
1138
+
1131
1139
  let queueFlushTimer = null;
1132
1140
  let idleCheckTimer = null;
1133
1141
 
@@ -1164,8 +1172,9 @@ async function main() {
1164
1172
  flushBridgeMailbox();
1165
1173
  }
1166
1174
  }, 500);
1167
- // Safety: flush after 5s regardless (prevent stuck queue when prompt not detected)
1168
- if (!queueFlushTimer) {
1175
+ // Safety fallback is compatibility-only during known AI CLI bootstrap:
1176
+ // first dispatch must wait for a strong ready signal.
1177
+ if ((!knownAiCli || firstReadyObserved) && !queueFlushTimer) {
1169
1178
  queueFlushTimer = setTimeout(() => {
1170
1179
  queueFlushTimer = null;
1171
1180
  if (bridgePendingCount > 0) {
@@ -1238,10 +1247,16 @@ async function main() {
1238
1247
 
1239
1248
  const isCr = chunk === '\r';
1240
1249
  if (isCr && bridgePendingCount > 0) {
1241
- // CR with pending queued text — queue CR too and flush immediately.
1250
+ // CR with pending queued text — queue CR too and wait for the
1251
+ // same readiness gate as the text. This preserves order during
1252
+ // bootstrap and busy-session delivery.
1242
1253
  enqueueBridgeMessage(chunk);
1243
- if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1244
- flushBridgeMailbox();
1254
+ if (isIdle()) {
1255
+ if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1256
+ flushBridgeMailbox();
1257
+ } else {
1258
+ scheduleIdleFlush();
1259
+ }
1245
1260
  } else if (isCr) {
1246
1261
  // CR always written immediately — never idle-gated.
1247
1262
  child.write(chunk);
@@ -1353,8 +1368,9 @@ async function main() {
1353
1368
  daemonWs.send(JSON.stringify({ type: 'output', data }));
1354
1369
  }
1355
1370
  // Detect prompt in output to enable inject delivery
1356
- if (promptPattern.test(data)) {
1371
+ if (observePromptReady(data)) {
1357
1372
  promptReady = true;
1373
+ firstReadyObserved = true;
1358
1374
  flushBridgeMailbox();
1359
1375
  // Notify daemon that CLI is ready for inject
1360
1376
  if (!readyNotified && wsReady && daemonWs.readyState === 1) {
@@ -1383,6 +1399,10 @@ async function main() {
1383
1399
  setTimeout(() => {
1384
1400
  try {
1385
1401
  spawnChild();
1402
+ promptReady = false;
1403
+ firstReadyObserved = false;
1404
+ readyNotified = false;
1405
+ outputTail = '';
1386
1406
  // Re-attach output relay, prompt detection, and exit handler
1387
1407
  child.onData((data) => {
1388
1408
  const rewritten = rewriteTitleSequences(data);
@@ -1390,8 +1410,9 @@ async function main() {
1390
1410
  if (wsReady && daemonWs.readyState === 1) {
1391
1411
  daemonWs.send(JSON.stringify({ type: 'output', data }));
1392
1412
  }
1393
- if (promptPattern.test(data)) {
1413
+ if (observePromptReady(data)) {
1394
1414
  promptReady = true;
1415
+ firstReadyObserved = true;
1395
1416
  flushBridgeMailbox();
1396
1417
  if (wsReady && daemonWs.readyState === 1) {
1397
1418
  daemonWs.send(JSON.stringify({ type: 'ready' }));
package/daemon.js CHANGED
@@ -15,6 +15,7 @@ const { UnixSocketNotifier } = require('./src/mailbox/notifier');
15
15
  const { SessionStateManager, STATE_DISPLAY, stripAnsi: stripAnsiState } = require('./session-state');
16
16
  const { classifyReportPrompt, buildAutoSummary } = require('./src/report-enforcement');
17
17
  const submitGate = require('./src/submit-gate');
18
+ const readyRegistry = require('./src/prompt-symbol-registry');
18
19
 
19
20
  const config = getConfig();
20
21
  const EXPECTED_TOKEN = config.authToken;
@@ -26,6 +27,8 @@ const SESSION_STALE_SECONDS = Math.max(1, Number(process.env.TELEPTY_SESSION_STA
26
27
  const SESSION_CLEANUP_SECONDS = Math.max(SESSION_STALE_SECONDS, Number(process.env.TELEPTY_SESSION_CLEANUP_SECONDS || 300));
27
28
  const DELIVERY_TIMEOUT_MS = Math.max(100, Number(process.env.TELEPTY_DELIVERY_TIMEOUT_MS || 5000));
28
29
  const HEALTH_POLL_MS = Math.max(100, Number(process.env.TELEPTY_HEALTH_POLL_MS || 10000));
30
+ const BOOTSTRAP_READY_TIMEOUT_MS = Math.max(500, Number(process.env.TELEPTY_BOOTSTRAP_READY_TIMEOUT_MS || 30000));
31
+ const WRAPPED_SUBMIT_DELAY_MS = 500;
29
32
 
30
33
  // Session state machine manager — auto-detects session state from PTY output
31
34
  const sessionStateManager = new SessionStateManager({
@@ -354,6 +357,292 @@ function getSessionHealthReason(session, healthStatus) {
354
357
  return session.ptyProcess && !session.ptyProcess.killed ? 'PTY_RUNNING' : 'PTY_EXITED';
355
358
  }
356
359
 
360
+ function sleep(ms) {
361
+ return new Promise((resolve) => setTimeout(resolve, ms));
362
+ }
363
+
364
+ function isBootstrapGatedSession(session) {
365
+ return !!(session && session.type === 'wrapped' && readyRegistry.isKnownAiCli(session.command));
366
+ }
367
+
368
+ function initializeBootstrapState(session) {
369
+ if (!session) return session;
370
+ if (!Array.isArray(session.bootstrapQueue)) {
371
+ session.bootstrapQueue = [];
372
+ }
373
+ session.bootstrapDraining = session.bootstrapDraining === true;
374
+ session.bootstrapDrainPromise = session.bootstrapDrainPromise || null;
375
+ session.bootstrapPromptPoll = session.bootstrapPromptPoll || null;
376
+
377
+ if (isBootstrapGatedSession(session)) {
378
+ session.bootstrapReady = session.bootstrapReady === true;
379
+ session.bootstrapReadyAt = session.bootstrapReadyAt || null;
380
+ session.bootstrapReadyReason = session.bootstrapReadyReason || null;
381
+ session.ready = session.bootstrapReady === true;
382
+ } else {
383
+ session.bootstrapReady = true;
384
+ session.bootstrapReadyAt = session.bootstrapReadyAt || new Date().toISOString();
385
+ session.bootstrapReadyReason = session.bootstrapReadyReason || 'generic_command_compat';
386
+ session.ready = true;
387
+ }
388
+ return session;
389
+ }
390
+
391
+ function isBootstrapReady(session) {
392
+ return !isBootstrapGatedSession(session) || session.bootstrapReady === true;
393
+ }
394
+
395
+ function buildBootstrapBlock(session) {
396
+ return {
397
+ gated: isBootstrapGatedSession(session),
398
+ ready: isBootstrapReady(session),
399
+ ready_at: session.bootstrapReadyAt || null,
400
+ reason: session.bootstrapReadyReason || null,
401
+ queued: Array.isArray(session.bootstrapQueue) ? session.bootstrapQueue.length : 0,
402
+ draining: session.bootstrapDraining === true
403
+ };
404
+ }
405
+
406
+ function shouldQueueBootstrapOperation(session) {
407
+ return isBootstrapGatedSession(session) && !isBootstrapReady(session);
408
+ }
409
+
410
+ function hasBootstrapBacklog(session) {
411
+ return !!(session && Array.isArray(session.bootstrapQueue) && session.bootstrapQueue.length > 0);
412
+ }
413
+
414
+ function emitBootstrapEvent(eventType, sessionId, session, extra = {}) {
415
+ broadcastSessionEvent(eventType, sessionId, session, {
416
+ extra: {
417
+ bootstrap: buildBootstrapBlock(session),
418
+ ...extra
419
+ }
420
+ });
421
+ }
422
+
423
+ function enqueueBootstrapOperation(sessionId, session, operation) {
424
+ initializeBootstrapState(session);
425
+ const op = {
426
+ op_id: crypto.randomUUID(),
427
+ queued_at: new Date().toISOString(),
428
+ ...operation
429
+ };
430
+
431
+ if (op.type === 'submit') {
432
+ op.promise = new Promise((resolve) => {
433
+ op.resolve = resolve;
434
+ });
435
+ }
436
+
437
+ session.bootstrapQueue.push(op);
438
+ emitBootstrapEvent('bootstrap_queue_queued', sessionId, session, {
439
+ op_id: op.op_id,
440
+ operation: op.type,
441
+ depth: session.bootstrapQueue.length
442
+ });
443
+ scheduleBootstrapPromptPoll(sessionId, session);
444
+ return op;
445
+ }
446
+
447
+ function resolveBootstrapSubmit(op, result) {
448
+ if (op && typeof op.resolve === 'function') {
449
+ op.resolve(result);
450
+ op.resolve = null;
451
+ }
452
+ }
453
+
454
+ function bootstrapQueuedResponse(op, extra = {}) {
455
+ return {
456
+ success: true,
457
+ strategy: 'bootstrap_queue',
458
+ queued: true,
459
+ bootstrap_queued: true,
460
+ bootstrap_op_id: op.op_id,
461
+ ...extra
462
+ };
463
+ }
464
+
465
+ async function executeBootstrapInject(sessionId, session, op) {
466
+ const prompt = typeof op.prompt === 'string' ? op.prompt : '';
467
+ const textResult = await writeDataToSession(sessionId, session, prompt);
468
+ if (!textResult.success) return textResult;
469
+
470
+ if (!op.noEnter) {
471
+ await sleep(WRAPPED_SUBMIT_DELAY_MS);
472
+ const submitResult = await writeDataToSession(sessionId, session, '\r');
473
+ if (!submitResult.success) return submitResult;
474
+ }
475
+
476
+ session.lastActivityAt = new Date().toISOString();
477
+ return {
478
+ success: true,
479
+ strategy: 'bootstrap_direct',
480
+ submit: op.noEnter ? 'skipped' : 'sent'
481
+ };
482
+ }
483
+
484
+ async function executeBootstrapSubmit(sessionId, session, op) {
485
+ const strategy = terminalLevelSubmit(sessionId, session);
486
+ if (!strategy) {
487
+ return {
488
+ status: 503,
489
+ body: {
490
+ error: 'Submit failed via all strategies (kitty/cmux/pty)',
491
+ strategy: 'none',
492
+ attempts: 0,
493
+ gated: false,
494
+ bootstrap_queued: true
495
+ }
496
+ };
497
+ }
498
+ return {
499
+ status: 200,
500
+ body: {
501
+ success: true,
502
+ strategy,
503
+ attempts: 1,
504
+ gated: false,
505
+ verify: null,
506
+ bootstrap_queued: true
507
+ }
508
+ };
509
+ }
510
+
511
+ async function drainBootstrapQueue(sessionId, session) {
512
+ if (!session || session.bootstrapDraining) {
513
+ return session ? session.bootstrapDrainPromise : null;
514
+ }
515
+ if (!isBootstrapReady(session)) {
516
+ return null;
517
+ }
518
+
519
+ session.bootstrapDraining = true;
520
+ session.bootstrapDrainPromise = (async () => {
521
+ while (hasBootstrapBacklog(session)) {
522
+ const op = session.bootstrapQueue.shift();
523
+ try {
524
+ if (op.cancelled) {
525
+ continue;
526
+ }
527
+ if (op.type === 'inject') {
528
+ const result = await executeBootstrapInject(sessionId, session, op);
529
+ if (!result.success) {
530
+ emitBootstrapEvent('bootstrap_queue_failed', sessionId, session, {
531
+ op_id: op.op_id,
532
+ operation: op.type,
533
+ code: result.code || 'DELIVERY_FAILED',
534
+ error: result.error || 'bootstrap delivery failed'
535
+ });
536
+ }
537
+ } else if (op.type === 'submit') {
538
+ const result = await executeBootstrapSubmit(sessionId, session, op);
539
+ resolveBootstrapSubmit(op, result);
540
+ if (result.status >= 400) {
541
+ emitBootstrapEvent('bootstrap_queue_failed', sessionId, session, {
542
+ op_id: op.op_id,
543
+ operation: op.type,
544
+ code: result.body.code || 'SUBMIT_FAILED',
545
+ error: result.body.error || 'bootstrap submit failed'
546
+ });
547
+ }
548
+ }
549
+ } catch (error) {
550
+ if (op.type === 'submit') {
551
+ resolveBootstrapSubmit(op, {
552
+ status: 500,
553
+ body: {
554
+ error: error.message || 'bootstrap submit failed',
555
+ strategy: 'none',
556
+ attempts: 0,
557
+ gated: false,
558
+ bootstrap_queued: true
559
+ }
560
+ });
561
+ }
562
+ emitBootstrapEvent('bootstrap_queue_failed', sessionId, session, {
563
+ op_id: op.op_id,
564
+ operation: op.type,
565
+ code: 'BOOTSTRAP_DRAIN_FAILED',
566
+ error: error.message || 'bootstrap drain failed'
567
+ });
568
+ }
569
+ }
570
+
571
+ emitBootstrapEvent('bootstrap_queue_drained', sessionId, session);
572
+ })().finally(() => {
573
+ session.bootstrapDraining = false;
574
+ session.bootstrapDrainPromise = null;
575
+ });
576
+
577
+ return session.bootstrapDrainPromise;
578
+ }
579
+
580
+ function markBootstrapReady(sessionId, session, reason) {
581
+ if (!session) return false;
582
+ initializeBootstrapState(session);
583
+ if (!isBootstrapGatedSession(session)) {
584
+ return false;
585
+ }
586
+ if (session.bootstrapReady === true) {
587
+ return false;
588
+ }
589
+
590
+ session.bootstrapReady = true;
591
+ session.bootstrapReadyAt = new Date().toISOString();
592
+ session.bootstrapReadyReason = reason || 'ready';
593
+ session.ready = true;
594
+ emitBootstrapEvent('bootstrap_ready', sessionId, session, { reason: session.bootstrapReadyReason });
595
+ drainBootstrapQueue(sessionId, session);
596
+ return true;
597
+ }
598
+
599
+ function scheduleBootstrapPromptPoll(sessionId, session) {
600
+ if (!session || !isBootstrapGatedSession(session) || isBootstrapReady(session)) return;
601
+ if (session.bootstrapPromptPoll || session.backend !== 'cmux' || !session.cmuxWorkspaceId) return;
602
+ if (!isOpenWebSocket(session.ownerWs)) return;
603
+
604
+ session.bootstrapPromptPoll = submitGate.awaitPromptSymbol(session, {
605
+ timeoutMs: BOOTSTRAP_READY_TIMEOUT_MS
606
+ }).then((result) => {
607
+ session.bootstrapPromptPoll = null;
608
+ if (result && result.ready && isOpenWebSocket(session.ownerWs)) {
609
+ markBootstrapReady(sessionId, session, 'cmux_prompt_symbol');
610
+ } else if (result && result.reason) {
611
+ emitBootstrapEvent('bootstrap_ready_timeout', sessionId, session, {
612
+ reason: result.reason,
613
+ waited_ms: result.waited_ms || 0
614
+ });
615
+ }
616
+ }).catch((error) => {
617
+ session.bootstrapPromptPoll = null;
618
+ emitBootstrapEvent('bootstrap_ready_timeout', sessionId, session, {
619
+ reason: 'prompt_symbol_error',
620
+ error: error.message || String(error)
621
+ });
622
+ });
623
+ }
624
+
625
+ async function waitForBootstrapSubmit(op, session, timeoutMs) {
626
+ const timeout = sleep(timeoutMs).then(() => {
627
+ op.cancelled = true;
628
+ return {
629
+ status: 504,
630
+ body: {
631
+ error: 'Submit bootstrap-timeout — target CLI did not become ready',
632
+ reason: 'bootstrap_not_ready',
633
+ last_state: sessionStateManager.getState(session.id)?.state || null,
634
+ strategy: 'none',
635
+ attempts: 0,
636
+ gated: true,
637
+ bootstrap_queued: true,
638
+ bootstrap_op_id: op.op_id,
639
+ bootstrap: buildBootstrapBlock(session)
640
+ }
641
+ };
642
+ });
643
+ return Promise.race([op.promise, timeout]);
644
+ }
645
+
357
646
  function buildSessionTransportBlock(session, options = {}) {
358
647
  if (!session) {
359
648
  return null;
@@ -380,7 +669,8 @@ function buildSessionTransportBlock(session, options = {}) {
380
669
  last_disconnected_at: session.lastDisconnectedAt || null,
381
670
  last_inject_from: session.lastInjectFrom || null,
382
671
  last_reply_to: session.lastInjectReplyTo || null,
383
- last_thread_id: session.lastThreadId || null
672
+ last_thread_id: session.lastThreadId || null,
673
+ bootstrap: buildBootstrapBlock(session)
384
674
  };
385
675
  }
386
676
 
@@ -645,6 +935,28 @@ function terminalLevelSubmit(id, session) {
645
935
 
646
936
  async function deliverInjectionToSession(id, session, prompt, options = {}) {
647
937
  const now = Date.now();
938
+ if (!options.bypassBootstrapQueue && shouldQueueBootstrapOperation(session)) {
939
+ const healthStatus = getSessionHealthStatus(session, { nowMs: now });
940
+ if (healthStatus === 'STALE') {
941
+ return { success: false, httpStatus: 410, code: 'STALE', error: 'Session is stale and awaiting cleanup.' };
942
+ }
943
+ const op = enqueueBootstrapOperation(id, session, {
944
+ type: 'inject',
945
+ prompt,
946
+ noEnter: !!options.noEnter,
947
+ options: {
948
+ source: options.source || 'inject',
949
+ from: options.from || 'daemon'
950
+ }
951
+ });
952
+ session.lastActivityAt = new Date(now).toISOString();
953
+ return bootstrapQueuedResponse(op, {
954
+ msg_id: op.op_id,
955
+ pending: session.bootstrapQueue.length,
956
+ submit: options.noEnter ? 'skipped' : 'queued'
957
+ });
958
+ }
959
+
648
960
  const injectFailure = getInjectFailure(session, { nowMs: now });
649
961
  if (injectFailure) {
650
962
  return { success: false, ...injectFailure };
@@ -834,6 +1146,7 @@ for (const [id, meta] of Object.entries(_persisted)) {
834
1146
  lastStateReportAt: meta.lastStateReportAt || null,
835
1147
  stateReport: meta.stateReport || null,
836
1148
  clients: new Set(), isClosing: false, outputRing: [], ready: true, };
1149
+ initializeBootstrapState(sessions[id]);
837
1150
  console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
838
1151
  }
839
1152
  }
@@ -1020,6 +1333,7 @@ app.post('/api/sessions/register', (req, res) => {
1020
1333
  existing.ready = true;
1021
1334
  markSessionConnected(existing);
1022
1335
  }
1336
+ initializeBootstrapState(existing);
1023
1337
  console.log(`[REGISTER] Re-registered session ${session_id} (type: ${existing.type}, updated metadata)`);
1024
1338
  return res.status(200).json({ session_id, type: existing.type, command: existing.command, cwd: existing.cwd, reregistered: true });
1025
1339
  }
@@ -1049,8 +1363,9 @@ app.post('/api/sessions/register', (req, res) => {
1049
1363
  clients: new Set(),
1050
1364
  isClosing: false,
1051
1365
  outputRing: [],
1052
- ready: true, // all sessions are injectable once registered (#150)
1053
- };
1366
+ ready: true, // unknown commands remain injectable once registered (#150)
1367
+ };
1368
+ initializeBootstrapState(sessionRecord);
1054
1369
  // Check for existing session with same base alias and emit replaced event
1055
1370
  const baseAlias = session_id.replace(/-\d+$/, '');
1056
1371
  const replaced = Object.keys(sessions).find(id => {
@@ -1521,6 +1836,18 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
1521
1836
 
1522
1837
  console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}${gateOff ? ' [gate=off]' : ''}`);
1523
1838
 
1839
+ if (isBootstrapGatedSession(session) && (!isBootstrapReady(session) || hasBootstrapBacklog(session) || session.bootstrapDraining)) {
1840
+ const op = enqueueBootstrapOperation(id, session, {
1841
+ type: 'submit',
1842
+ body: { ...(req.body || {}) }
1843
+ });
1844
+ if (isBootstrapReady(session)) {
1845
+ drainBootstrapQueue(id, session);
1846
+ }
1847
+ const queuedSubmit = await waitForBootstrapSubmit(op, session, gateTimeoutMs);
1848
+ return res.status(queuedSubmit.status).json(queuedSubmit.body);
1849
+ }
1850
+
1524
1851
  function emitSubmitBus(payload) {
1525
1852
  const busMsg = JSON.stringify({
1526
1853
  type: 'submit',
@@ -1805,6 +2132,12 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
1805
2132
  delete pendingReports[senderAlias];
1806
2133
  const elapsedSecs = Number(((Date.now() - new Date(senderPending.injectedAt).getTime()) / 1000).toFixed(1));
1807
2134
  const senderSession = sessions[senderAlias];
2135
+ sessionStateManager.markIdle(senderAlias, 1.0, {
2136
+ trigger: 'report_inject',
2137
+ report_inject_id: inject_id,
2138
+ report_status: classification,
2139
+ source: senderPending.source
2140
+ });
1808
2141
  const eventType =
1809
2142
  classification === 'report_blocked' ? 'TASK_BLOCKED_WITH_REASON' :
1810
2143
  classification === 'report_dismissed' ? 'TASK_DISMISSED' :
@@ -1881,7 +2214,17 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
1881
2214
  });
1882
2215
  }
1883
2216
 
1884
- res.json({ success: true, inject_id, strategy: delivery.strategy, submit: delivery.submit });
2217
+ res.json({
2218
+ success: true,
2219
+ inject_id,
2220
+ strategy: delivery.strategy,
2221
+ submit: delivery.submit,
2222
+ ...(delivery.bootstrap_queued ? {
2223
+ bootstrap_queued: true,
2224
+ bootstrap_op_id: delivery.bootstrap_op_id || delivery.msg_id,
2225
+ pending: delivery.pending
2226
+ } : {})
2227
+ });
1885
2228
  } catch (err) {
1886
2229
  emitInjectFailureEvent(id, 'DELIVERY_FAILED', err.message, { inject_id }, session);
1887
2230
  res.status(500).json(buildErrorBody('DELIVERY_FAILED', err.message));
@@ -2607,6 +2950,7 @@ wss.on('connection', (ws, req) => {
2607
2950
  outputRing: [],
2608
2951
  ready: true,
2609
2952
  };
2953
+ initializeBootstrapState(autoSession);
2610
2954
  sessions[sessionId] = autoSession;
2611
2955
  console.log(`[WS] Auto-registered wrapped session ${sessionId} on reconnect`);
2612
2956
  // Set tab title via kitty (no \x0c redraw — it causes flickering on multi-session reconnect)
@@ -2639,7 +2983,9 @@ wss.on('connection', (ws, req) => {
2639
2983
  }
2640
2984
  activeSession.ownerWs = ws;
2641
2985
  markSessionConnected(activeSession);
2986
+ initializeBootstrapState(activeSession);
2642
2987
  console.log(`[WS] Wrap owner ${isOwnerConnect && activeSession.clients.size > 1 ? 're-' : ''}connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
2988
+ scheduleBootstrapPromptPoll(sessionId, activeSession);
2643
2989
  if (hadDisconnectedOwner) {
2644
2990
  emitSessionLifecycleEvent('session_reconnect', sessionId, activeSession);
2645
2991
  }
@@ -2665,7 +3011,11 @@ wss.on('connection', (ws, req) => {
2665
3011
  }
2666
3012
  });
2667
3013
  } else if (type === 'ready') {
2668
- activeSession.ready = true;
3014
+ if (isBootstrapGatedSession(activeSession)) {
3015
+ markBootstrapReady(sessionId, activeSession, 'bridge_ready');
3016
+ } else {
3017
+ activeSession.ready = true;
3018
+ }
2669
3019
  activeSession.lastActivityAt = new Date().toISOString();
2670
3020
  console.log(`[READY] Session ${sessionId} CLI is ready for inject`);
2671
3021
  // Broadcast readiness to bus (cmux/kitty paths now enabled for this session)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -8,6 +8,30 @@
8
8
  "telepty-install": "install.js",
9
9
  "telepty-mcp": "mcp-server/index.mjs"
10
10
  },
11
+ "files": [
12
+ "cli.js",
13
+ "daemon.js",
14
+ "install.js",
15
+ "auth.js",
16
+ "cross-machine.js",
17
+ "daemon-control.js",
18
+ "entitlement.js",
19
+ "host-spec.js",
20
+ "interactive-terminal.js",
21
+ "runtime-info.js",
22
+ "session-routing.js",
23
+ "session-state.js",
24
+ "shared-context.js",
25
+ "skill-installer.js",
26
+ "terminal-backend.js",
27
+ "tui.js",
28
+ "install.sh",
29
+ "install.ps1",
30
+ "mcp-server/",
31
+ "src/",
32
+ "skills/",
33
+ "CHANGELOG.md"
34
+ ],
11
35
  "scripts": {
12
36
  "test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.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/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/init.test.js && git diff --exit-code tests/snippet-protocol/v1/",
13
37
  "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.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/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/submit-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/host-spec.test.js test/cross-host-inject.test.js test/init.test.js",