@dmsdc-ai/aigentry-telepty 0.3.5 → 0.4.1

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 (40) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/cli.js +41 -16
  3. package/daemon.js +355 -5
  4. package/package.json +28 -4
  5. package/session-state.js +23 -0
  6. package/src/prompt-symbol-registry.js +43 -1
  7. package/src/win-resolve-executable.js +87 -0
  8. package/.claude/commands/telepty-allow.md +0 -58
  9. package/.claude/commands/telepty-attach.md +0 -22
  10. package/.claude/commands/telepty-inject.md +0 -72
  11. package/.claude/commands/telepty-list.md +0 -22
  12. package/.claude/commands/telepty-manual-test.md +0 -73
  13. package/.claude/commands/telepty-start.md +0 -25
  14. package/.claude/commands/telepty-test.md +0 -25
  15. package/.claude/commands/telepty.md +0 -82
  16. package/AGENTS.md +0 -97
  17. package/BOUNDARY.md +0 -31
  18. package/BUS_EVENT_SCHEMA.md +0 -206
  19. package/CLAUDE.md +0 -100
  20. package/GEMINI.md +0 -10
  21. package/URGENT_ISSUES.resolved.md +0 -1
  22. package/docs/reports/2026-05-05-issue-8-claude-review.md +0 -194
  23. package/docs/specs/2026-05-05-issue-8-telepty-init.md +0 -477
  24. package/docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md +0 -447
  25. package/docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md +0 -571
  26. package/docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md +0 -608
  27. package/docs/superpowers/specs/2026-05-02-submit-force-and-retry.md +0 -139
  28. package/protocol/mailbox.md +0 -244
  29. package/scripts/regen-snippet-fixtures.js +0 -42
  30. package/specs/codex-inject-spec.md +0 -201
  31. package/specs/enforce-report-spec.md +0 -237
  32. package/templates/AGENTS.md +0 -71
  33. package/tests/snippet-protocol/v1/golden-agents.json +0 -1
  34. package/tests/snippet-protocol/v1/golden-agents.md +0 -17
  35. package/tests/snippet-protocol/v1/golden-all.json +0 -3
  36. package/tests/snippet-protocol/v1/golden-all.md +0 -53
  37. package/tests/snippet-protocol/v1/golden-claude.json +0 -1
  38. package/tests/snippet-protocol/v1/golden-claude.md +0 -17
  39. package/tests/snippet-protocol/v1/golden-gemini.json +0 -1
  40. package/tests/snippet-protocol/v1/golden-gemini.md +0 -17
package/CHANGELOG.md CHANGED
@@ -2,6 +2,102 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
+ ## [0.4.1] - 2026-05-17
6
+
7
+ ### Fixed
8
+
9
+ - **#25** — Windows PATHEXT resolution for `telepty allow`. npm-global CLIs
10
+ (`claude`, `codex`, `gemini`) now spawn correctly with bare names on
11
+ Windows. Previously `telepty allow … claude` failed with
12
+ `Cannot create process, error code: 2` (ERROR_FILE_NOT_FOUND) because
13
+ node-pty's `CreateProcessW` does not walk `%PATHEXT%` the way `cmd.exe`
14
+ does, so the npm-global `claude.cmd` shim was unreachable from the bare
15
+ name. New: `src/win-resolve-executable.js` resolver (Windows-only branch
16
+ walks `PATH` × `PATHEXT`; POSIX no-op) + 14 unit tests. macOS/Linux
17
+ behavior unchanged.
18
+
19
+ ### Notes
20
+
21
+ - **Snyk SAST scan on changed files** — `src/win-resolve-executable.js`
22
+ + `test/win-resolve-executable.test.js` = **0 findings** (At-Inception
23
+ clean). `cli.js` shows **5 pre-existing findings** (2 Medium Command
24
+ Injection at `execSync` L469 + `pty.spawn` L1075, 3 Low Path Traversal
25
+ at L2287/L2289/L2598) verified identical fingerprint vs HEAD~1 — out
26
+ of #25 surgical scope. Tracked in **dmsdc-ai/aigentry-telepty#26** for
27
+ follow-up PR.
28
+
29
+ ## [0.4.0] — 2026-05-15
30
+
31
+ ### Added — Phase 1 sidecar supervisor spike (M1–M5)
32
+
33
+ Out-of-process Rust supervisor (`crates/telepty-supervisor-{core,bin}`)
34
+ incubating the future spawn/kill/IPC backend for `daemon.js`. Five
35
+ milestones complete; **incubating only — not on the request path** in
36
+ 0.4.0. Daemon (`daemon.js`) and CLI (`cli.js`) routing is unchanged.
37
+
38
+ - **M1** — spawn + observe (commit `07cd2e7`).
39
+ - **M2** — graceful + forced kill gate, manifest cleanup invariant A8
40
+ (commit `ec00412`).
41
+ - **M3** — IPC + wire contract conformance, NDJSON UDS frames + golden
42
+ fixtures (commit `76cde35`).
43
+ - **M4** — cross-OS POSIX parity + reproducible RSS measurement, GitHub
44
+ Actions matrix (`.github/workflows/phase1-spike-ci.yml`); RSS PASS at
45
+ 2.9–3.0 MiB / supervisor on macOS arm64 (commit `eb04c73`).
46
+ - **M5** — manual integration bridge (`scripts/bridge-phase1.js`, 194
47
+ LOC Node stdlib only) — four parity scenarios A/B/C/D, exit 0 iff all
48
+ PASS; one-line Rust correctness fix (emit `shutdown_drain` before IPC
49
+ shutdown so connected clients receive the frame) (commit `be091e0`).
50
+
51
+ Phase 1 LOC ceiling 1500 honored (Rust src/ tokei = 1240, 260 LOC
52
+ headroom unused). Test suite: 23/23 (lib unit 12 + wire_golden 6 +
53
+ ipc_protocol 5). Spec: `docs/specs/2026-05-10-supervisor-c3-kill-gate-spec.md`.
54
+ Plan: `docs/plans/2026-05-12-phase1-sidecar-spike-plan.md`.
55
+
56
+ ### Fixed
57
+
58
+ - **#18** — Bootstrap inject queue race. Welcome-banner bypass via
59
+ positive-override `is_ready` so queued injects flush in the correct
60
+ order without colliding with the banner (commit `744ad6a`).
61
+ - **#16** — REPORT-based idle status detection. Replaces heuristic
62
+ prompt-symbol detection with explicit REPORT-frame anchoring
63
+ (commit `3ed1e83`).
64
+
65
+ ### Build
66
+
67
+ - `package.json` — added `files` whitelist (22 entries) to constrain
68
+ npm-published surface to actual runtime distribution. Tarball
69
+ reduction: 228 MB → 123 kB (1850×). The Rust spike artifacts
70
+ (`target/`, `crates/`, `Cargo.lock/Cargo.toml`, `rust-toolchain.toml`)
71
+ ship in git but **not** to npm (commit `a0baf84`).
72
+
73
+ ### Docs
74
+
75
+ - MD audit wave-2 fix: `CLAUDE.md` converted to `@AGENTS.md` stub
76
+ (101 → 27 lines), `AGENTS.md` gained Session Environment section
77
+ (`$TELEPTY_SESSION_ID`, `$TELEPTY_AVAILABLE`) and disclosed
78
+ cross-repo ADR location for `2026-05-05-telepty-devkit-boundary §6.2.1`.
79
+ Score delta `AGENTS.md` 80 → 87, `CLAUDE.md` 66 → 87 (commit `74a6374`,
80
+ full report `docs/reports/2026-05-14-md-audit.md`).
81
+
82
+ ### Notes
83
+
84
+ - **Snyk SAST deferred for this release** — see follow-up task #130.
85
+ Waiver basis (Rule 32-A track B):
86
+ - M1–M5 spike code is Rust and is **excluded from the npm tarball** by
87
+ the new `files` whitelist — first-party code shipped to consumers is
88
+ JS only.
89
+ - The shipped JS files are unchanged or only minimally changed since
90
+ `0.3.5` (cli.js +51 / daemon.js +360 / src/prompt-symbol-registry.js
91
+ +44 / new session-state.js — additive, no breaks per Phase 1 audit).
92
+ - Dependency-side coverage exists via `npm audit` (10 pre-existing
93
+ vulns documented; not introduced by this release).
94
+ - Per CLAUDE.md user-instruction "Snyk At Inception" scope = *new
95
+ first-party code shipped* — 0 net-new shipped JS code in this
96
+ release, so the at-inception rule does not bind here. Follow-up
97
+ task #130 will land the standing SAST gate as a release-script
98
+ primitive (so future releases scan automatically without per-run
99
+ auth steps).
100
+
5
101
  ## [0.3.5] — 2026-05-05
6
102
 
7
103
  ### Added — `telepty init --print-snippet` (Issue #8)
package/cli.js CHANGED
@@ -17,9 +17,11 @@ const { getRuntimeInfo } = require('./runtime-info');
17
17
  const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./session-routing');
18
18
  const { buildSharedContextPrompt, createSharedContextDescriptor, ensureSharedContextFile } = require('./shared-context');
19
19
  const { runInteractiveSkillInstaller } = require('./skill-installer');
20
+ const { resolveWindowsExecutable } = require('./src/win-resolve-executable');
20
21
  const crossMachine = require('./cross-machine');
21
22
  const { parseHostSpec, buildDaemonUrl, buildDaemonWsUrl } = require('./host-spec');
22
23
  const { FileMailbox } = require('./src/mailbox/index');
24
+ const readyRegistry = require('./src/prompt-symbol-registry');
23
25
  const args = process.argv.slice(2);
24
26
  let pendingTerminalInputError = null;
25
27
  let simulatedPromptErrorInjected = false;
@@ -1067,7 +1069,10 @@ async function main() {
1067
1069
  }
1068
1070
 
1069
1071
  function spawnChild() {
1070
- child = pty.spawn(command, cmdArgs, {
1072
+ // Windows: walk %PATHEXT% so bare names (`claude`, `codex`, `gemini`)
1073
+ // resolve to their npm-global `.cmd`/`.ps1` shims. POSIX: no-op. (#25)
1074
+ const resolvedCommand = resolveWindowsExecutable(command, process.env);
1075
+ child = pty.spawn(resolvedCommand, cmdArgs, {
1071
1076
  name: 'xterm-256color',
1072
1077
  cols: process.stdout.columns || 80,
1073
1078
  rows: process.stdout.rows || 30,
@@ -1080,15 +1085,14 @@ async function main() {
1080
1085
 
1081
1086
  spawnChild();
1082
1087
 
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*$/;
1088
+ // Prompt-ready detection for safe inject delivery.
1089
+ // Known AI CLIs use the centralized geometry-aware registry; generic
1090
+ // commands keep the permissive legacy prompt regex for compatibility.
1091
+ const knownAiCli = readyRegistry.isKnownAiCli(command);
1092
+ const promptPattern = /[❯>$#%]\s*$/;
1091
1093
  let promptReady = false; // wait for CLI prompt before accepting inject
1094
+ let firstReadyObserved = false;
1095
+ let outputTail = '';
1092
1096
  let lastUserInputTime = 0; // timestamp of last user keystroke
1093
1097
  const IDLE_THRESHOLD = 2000; // ms after last user input to consider idle
1094
1098
 
@@ -1128,6 +1132,14 @@ async function main() {
1128
1132
  return promptReady && (Date.now() - lastUserInputTime > IDLE_THRESHOLD);
1129
1133
  }
1130
1134
 
1135
+ function observePromptReady(data) {
1136
+ if (knownAiCli) {
1137
+ outputTail = (outputTail + data).slice(-20000);
1138
+ return !!readyRegistry.detectOutput(command, outputTail).found;
1139
+ }
1140
+ return promptPattern.test(data);
1141
+ }
1142
+
1131
1143
  let queueFlushTimer = null;
1132
1144
  let idleCheckTimer = null;
1133
1145
 
@@ -1164,8 +1176,9 @@ async function main() {
1164
1176
  flushBridgeMailbox();
1165
1177
  }
1166
1178
  }, 500);
1167
- // Safety: flush after 5s regardless (prevent stuck queue when prompt not detected)
1168
- if (!queueFlushTimer) {
1179
+ // Safety fallback is compatibility-only during known AI CLI bootstrap:
1180
+ // first dispatch must wait for a strong ready signal.
1181
+ if ((!knownAiCli || firstReadyObserved) && !queueFlushTimer) {
1169
1182
  queueFlushTimer = setTimeout(() => {
1170
1183
  queueFlushTimer = null;
1171
1184
  if (bridgePendingCount > 0) {
@@ -1238,10 +1251,16 @@ async function main() {
1238
1251
 
1239
1252
  const isCr = chunk === '\r';
1240
1253
  if (isCr && bridgePendingCount > 0) {
1241
- // CR with pending queued text — queue CR too and flush immediately.
1254
+ // CR with pending queued text — queue CR too and wait for the
1255
+ // same readiness gate as the text. This preserves order during
1256
+ // bootstrap and busy-session delivery.
1242
1257
  enqueueBridgeMessage(chunk);
1243
- if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1244
- flushBridgeMailbox();
1258
+ if (isIdle()) {
1259
+ if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1260
+ flushBridgeMailbox();
1261
+ } else {
1262
+ scheduleIdleFlush();
1263
+ }
1245
1264
  } else if (isCr) {
1246
1265
  // CR always written immediately — never idle-gated.
1247
1266
  child.write(chunk);
@@ -1353,8 +1372,9 @@ async function main() {
1353
1372
  daemonWs.send(JSON.stringify({ type: 'output', data }));
1354
1373
  }
1355
1374
  // Detect prompt in output to enable inject delivery
1356
- if (promptPattern.test(data)) {
1375
+ if (observePromptReady(data)) {
1357
1376
  promptReady = true;
1377
+ firstReadyObserved = true;
1358
1378
  flushBridgeMailbox();
1359
1379
  // Notify daemon that CLI is ready for inject
1360
1380
  if (!readyNotified && wsReady && daemonWs.readyState === 1) {
@@ -1383,6 +1403,10 @@ async function main() {
1383
1403
  setTimeout(() => {
1384
1404
  try {
1385
1405
  spawnChild();
1406
+ promptReady = false;
1407
+ firstReadyObserved = false;
1408
+ readyNotified = false;
1409
+ outputTail = '';
1386
1410
  // Re-attach output relay, prompt detection, and exit handler
1387
1411
  child.onData((data) => {
1388
1412
  const rewritten = rewriteTitleSequences(data);
@@ -1390,8 +1414,9 @@ async function main() {
1390
1414
  if (wsReady && daemonWs.readyState === 1) {
1391
1415
  daemonWs.send(JSON.stringify({ type: 'output', data }));
1392
1416
  }
1393
- if (promptPattern.test(data)) {
1417
+ if (observePromptReady(data)) {
1394
1418
  promptReady = true;
1419
+ firstReadyObserved = true;
1395
1420
  flushBridgeMailbox();
1396
1421
  if (wsReady && daemonWs.readyState === 1) {
1397
1422
  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)