@dmsdc-ai/aigentry-telepty 0.1.97 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/daemon.js CHANGED
@@ -12,7 +12,9 @@ const terminalBackend = require('./terminal-backend');
12
12
  const { FileMailbox } = require('./src/mailbox/index');
13
13
  const { DeliveryEngine } = require('./src/mailbox/delivery');
14
14
  const { UnixSocketNotifier } = require('./src/mailbox/notifier');
15
- const { SessionStateManager, STATE_DISPLAY } = require('./session-state');
15
+ const { SessionStateManager, STATE_DISPLAY, stripAnsi: stripAnsiState } = require('./session-state');
16
+ const { classifyReportPrompt, buildAutoSummary } = require('./src/report-enforcement');
17
+ const submitGate = require('./src/submit-gate');
16
18
 
17
19
  const config = getConfig();
18
20
  const EXPECTED_TOKEN = config.authToken;
@@ -33,13 +35,84 @@ const sessionStateManager = new SessionStateManager({
33
35
  thinking_timeout_ms: Number(process.env.TELEPTY_STATE_THINKING_TIMEOUT_MS || 300000),
34
36
  });
35
37
 
36
- // Broadcast state transitions to the bus
38
+ // Report enforcement config (0.2.0) see specs/enforce-report-spec.md
39
+ const REPORT_AUTO_SUMMARY_ON_QUERY = (process.env.DELIBERATION_REPORT_AUTO_SUMMARY_ON_QUERY || 'true').toLowerCase() !== 'false';
40
+ const REPORT_AUTO_SUMMARY_LINES = Math.max(1, Number(process.env.DELIBERATION_REPORT_AUTO_SUMMARY_LINES || 40));
41
+ const REPORT_AUTO_SUMMARY_MAX_BYTES = Math.max(256, Number(process.env.DELIBERATION_REPORT_AUTO_SUMMARY_MAX_BYTES || 4096));
42
+ if (process.env.reportTimeoutSecs) {
43
+ console.warn('[CONFIG] reportTimeoutSecs is deprecated (removed in 0.2.0) — ignored');
44
+ }
45
+
46
+ // Wrap buildAutoSummary with daemon config defaults
47
+ function buildAutoSummaryWithDefaults(session) {
48
+ return buildAutoSummary(session, {
49
+ maxLines: REPORT_AUTO_SUMMARY_LINES,
50
+ maxBytes: REPORT_AUTO_SUMMARY_MAX_BYTES
51
+ });
52
+ }
53
+
54
+ // Broadcast state transitions to the bus + fire enforcement events on idle/dead
37
55
  sessionStateManager.onTransition((sessionId, from, to, detail) => {
38
56
  const session = sessions[sessionId];
39
57
  if (!session) return;
40
58
  broadcastSessionEvent('session_auto_state', sessionId, session, {
41
59
  extra: { auto_state: to, auto_state_from: from, auto_detail: detail }
42
60
  });
61
+
62
+ // Fire TASK_IDLE_NO_REPORT on idle transition (for sessions with pendingReports).
63
+ // Session still needs to self-inject a content REPORT — this event only observes.
64
+ // Legacy TASK_COMPLETE text-inject is also fired for back-compat (0.2.x grandfather).
65
+ if (to === 'idle' && pendingReports[sessionId]) {
66
+ const pendingReport = pendingReports[sessionId];
67
+ // Mark as idle-notified (but keep the entry — REPORT is still pending).
68
+ // Entry is cleared when REPORT arrives (via inject endpoint) OR session dies.
69
+ if (pendingReport.idleNotified) return; // only fire once
70
+ pendingReport.idleNotified = true;
71
+ pendingReport.idleAt = new Date().toISOString();
72
+
73
+ const elapsed = ((Date.now() - new Date(pendingReport.injectedAt).getTime()) / 1000).toFixed(1);
74
+
75
+ // New bus event: TASK_IDLE_NO_REPORT (richer observability)
76
+ broadcastSessionEvent('TASK_IDLE_NO_REPORT', sessionId, session, {
77
+ extra: {
78
+ source: pendingReport.source,
79
+ inject_id: pendingReport.injectId,
80
+ elapsed_secs: Number(elapsed),
81
+ injected_at: pendingReport.injectedAt
82
+ }
83
+ });
84
+ console.log(`[ENFORCE-REPORT] ${sessionId} idle after ${elapsed}s — awaiting REPORT from ${pendingReport.source}`);
85
+
86
+ // Legacy text-inject for back-compat (grandfather period 0.2.x)
87
+ const reportMsg = `TASK_COMPLETE: ${sessionId} is now idle after processing inject (${elapsed}s)`;
88
+ const srcId = resolveSessionAlias(pendingReport.source) || pendingReport.source;
89
+ const srcSession = sessions[srcId];
90
+ if (srcSession) {
91
+ deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
92
+ console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (legacy text-inject)`);
93
+ }
94
+ }
95
+
96
+ // Fire TASK_DEAD_NO_REPORT when session dies with a pending report
97
+ if (to === 'dead' && pendingReports[sessionId]) {
98
+ const pendingReport = pendingReports[sessionId];
99
+ delete pendingReports[sessionId];
100
+
101
+ const autoSummary = buildAutoSummaryWithDefaults(session);
102
+ const elapsed = ((Date.now() - new Date(pendingReport.injectedAt).getTime()) / 1000).toFixed(1);
103
+
104
+ broadcastSessionEvent('TASK_DEAD_NO_REPORT', sessionId, session, {
105
+ extra: {
106
+ source: pendingReport.source,
107
+ inject_id: pendingReport.injectId,
108
+ elapsed_secs: Number(elapsed),
109
+ injected_at: pendingReport.injectedAt,
110
+ auto_summary: autoSummary,
111
+ exit_detail: detail
112
+ }
113
+ });
114
+ console.log(`[ENFORCE-REPORT] ${sessionId} died before REPORT after ${elapsed}s — auto_summary attached`);
115
+ }
43
116
  });
44
117
 
45
118
  function persistSessions() {
@@ -554,6 +627,22 @@ async function writeDataToSession(id, session, data) {
554
627
  return { success: true };
555
628
  }
556
629
 
630
+ /**
631
+ * Submit Enter to a session using terminal-level methods.
632
+ * Used by POST /submit endpoint for explicit terminal-level submit.
633
+ * Priority: kitty send-text → cmux send-key → PTY \r fallback.
634
+ * Returns the strategy name or null on failure.
635
+ */
636
+ function terminalLevelSubmit(id, session) {
637
+ // Priority 1: kitty send-text (terminal-level, bypasses PTY raw mode quirks)
638
+ if (session.type === 'wrapped' && sendViaKitty(id, '\r')) return 'kitty';
639
+ // Priority 2: cmux send-key
640
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId && submitViaCmux(id)) return 'cmux';
641
+ // Priority 3: PTY \r
642
+ if (submitViaPty(session)) return 'pty_cr';
643
+ return null;
644
+ }
645
+
557
646
  async function deliverInjectionToSession(id, session, prompt, options = {}) {
558
647
  const now = Date.now();
559
648
  const injectFailure = getInjectFailure(session, { nowMs: now });
@@ -1300,8 +1389,11 @@ function sendViaKitty(sessionId, text) {
1300
1389
  });
1301
1390
  }
1302
1391
  if (hasCr) {
1303
- // Delay before sending Return — CLI needs time to process text input
1304
- execSync('sleep 0.5', { timeout: 2000 });
1392
+ // Delay before sending Return — only when text was sent in the same call
1393
+ // (when CR-only, text was already delivered via a different path)
1394
+ if (textOnly.length > 0) {
1395
+ execSync('sleep 0.5', { timeout: 2000 });
1396
+ }
1305
1397
  execSync(`kitty @ --to unix:${socket} send-text --match id:${windowId} $'\\r'`, {
1306
1398
  timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
1307
1399
  });
@@ -1380,7 +1472,28 @@ function submitViaCmux(sessionId) {
1380
1472
  }
1381
1473
  }
1382
1474
 
1383
- // POST /api/sessions/:id/submit — CLI-aware submit
1475
+ // POST /api/sessions/:id/submit — render-gated CLI-aware submit
1476
+ //
1477
+ // Default behavior (0.3.0+): wait for the target REPL to be ready (sessionStateManager
1478
+ // reports `idle`/`waiting` with confidence ≥ 0.85) before firing Enter. When the
1479
+ // caller passes `injected_body`, also verify the body has been consumed (i.e.
1480
+ // disappeared from the input box) by polling the session output ring; if still
1481
+ // visible, perform one bounded retry.
1482
+ //
1483
+ // Why HTTP 504 (not 503 or 408)?
1484
+ // - 503 already used by this endpoint to mean "all dispatch strategies failed"
1485
+ // (kitty/cmux/PTY couldn't even fire Enter). Reusing 503 would conflate
1486
+ // "we never attempted" with "we attempted and failed".
1487
+ // - 408 (Request Timeout) describes a timeout on the *request itself*; here
1488
+ // the request was processed in time, but the *upstream* (target REPL) did
1489
+ // not become ready. 504 (Gateway Timeout) precisely describes "we acted as
1490
+ // a gateway/proxy to the REPL, and the upstream did not respond in time".
1491
+ // - This is an additive change to existing endpoint semantics — minor bump.
1492
+ //
1493
+ // Legacy (blind retry) path is preserved as an escape hatch via the
1494
+ // TELEPTY_SUBMIT_GATE=off env var, for parity testing and rollback.
1495
+ //
1496
+ // See: docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md
1384
1497
  app.post('/api/sessions/:id/submit', async (req, res) => {
1385
1498
  const requestedId = req.params.id;
1386
1499
  const resolvedId = resolveSessionAlias(requestedId);
@@ -1391,45 +1504,206 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
1391
1504
  const retries = Math.min(Math.max(Number(req.body?.retries) || 0, 0), 3);
1392
1505
  const retryDelayMs = Math.min(Math.max(Number(req.body?.retry_delay_ms) || 500, 100), 2000);
1393
1506
  const preDelayMs = Math.min(Math.max(Number(req.body?.pre_delay_ms) || 0, 0), 1000);
1394
-
1395
- const strategy = 'pty_cr';
1396
- console.log(`[SUBMIT] Session ${id} (${session.command}) strategy: ${strategy}${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}`);
1397
-
1398
- // Pre-delay: wait for paste rendering to complete before sending CR
1399
- if (preDelayMs > 0) {
1400
- await new Promise(resolve => setTimeout(resolve, preDelayMs));
1507
+ // Default raised 5000 → 10000 (0.3.1) to cover empirical claude REPL
1508
+ // ready window (3-6s on fresh spawn) with margin. Upper clamp raised
1509
+ // 15000 30000 for the rare extreme-cold case.
1510
+ const gateTimeoutMs = Math.min(Math.max(Number(req.body?.gate_timeout_ms) || 10000, 500), 30000);
1511
+ const verifyTimeoutMs = Math.min(Math.max(Number(req.body?.verify_timeout_ms) || 1500, 200), 5000);
1512
+ const injectedBody = typeof req.body?.injected_body === 'string' ? req.body.injected_body : null;
1513
+ const minConfidence = req.body?.min_confidence != null
1514
+ ? Math.min(Math.max(Number(req.body.min_confidence), 0), 1)
1515
+ : undefined;
1516
+ // Per-request bypass for manual overrides (`telepty send-key`). Skips gate +
1517
+ // verify and dispatches once via the existing terminal-level chain.
1518
+ const force = req.body?.force === true;
1519
+
1520
+ const gateOff = String(process.env.TELEPTY_SUBMIT_GATE || '').toLowerCase() === 'off';
1521
+
1522
+ console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}${gateOff ? ' [gate=off]' : ''}`);
1523
+
1524
+ function emitSubmitBus(payload) {
1525
+ const busMsg = JSON.stringify({
1526
+ type: 'submit',
1527
+ sender: 'daemon',
1528
+ session_id: id,
1529
+ timestamp: new Date().toISOString(),
1530
+ ...payload,
1531
+ });
1532
+ busClients.forEach(client => {
1533
+ if (client.readyState === 1) client.send(busMsg);
1534
+ });
1401
1535
  }
1402
1536
 
1403
- function executeSubmit() {
1404
- return submitViaPty(session);
1537
+ // ── Per-request bypass: { force: true } skips gate + verify (0.3.1+) ──
1538
+ // Used by `telepty send-key` (manual override). Mirrors the env-var
1539
+ // escape-hatch but at request scope.
1540
+ // See: docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md §3.1
1541
+ if (force) {
1542
+ const strategy = terminalLevelSubmit(id, session);
1543
+ if (strategy) {
1544
+ emitSubmitBus({ strategy, attempts: 1, gated: false, forced: true });
1545
+ return res.json({ success: true, strategy, attempts: 1, gated: false, forced: true });
1546
+ }
1547
+ return res.status(503).json({
1548
+ error: 'Submit failed via all strategies (kitty/cmux/pty)',
1549
+ strategy: 'none',
1550
+ attempts: 0,
1551
+ gated: false,
1552
+ forced: true,
1553
+ });
1405
1554
  }
1406
1555
 
1407
- let success = executeSubmit();
1408
- let attempts = 1;
1556
+ // ── Legacy escape-hatch path: blind pre-delay + retries (0.2.x behavior) ──
1557
+ if (gateOff) {
1558
+ if (preDelayMs > 0) {
1559
+ await new Promise(resolve => setTimeout(resolve, preDelayMs));
1560
+ }
1561
+ let legacyStrategy = terminalLevelSubmit(id, session);
1562
+ let legacyAttempts = legacyStrategy ? 1 : 0;
1563
+ for (let i = 0; i < retries && legacyStrategy; i++) {
1564
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
1565
+ terminalLevelSubmit(id, session);
1566
+ legacyAttempts++;
1567
+ }
1568
+ if (legacyStrategy) {
1569
+ emitSubmitBus({ strategy: legacyStrategy, attempts: legacyAttempts, gated: false });
1570
+ return res.json({ success: true, strategy: legacyStrategy, attempts: legacyAttempts, gated: false });
1571
+ }
1572
+ return res.status(503).json({
1573
+ error: 'Submit failed via all strategies (kitty/cmux/pty)',
1574
+ strategy: 'none',
1575
+ attempts: legacyAttempts,
1576
+ gated: false,
1577
+ });
1578
+ }
1409
1579
 
1410
- // Retry: resend CR if paste may have absorbed the first one
1411
- for (let i = 0; i < retries && success; i++) {
1412
- await new Promise(resolve => setTimeout(resolve, retryDelayMs));
1413
- executeSubmit();
1414
- attempts++;
1580
+ // ── Gated path (default, 0.3.0+; best-effort dispatch on timeout in 0.3.1+) ──
1581
+
1582
+ // Step 0 (Layer 3, 0.3.2+): prompt-symbol render gate — strictly additive.
1583
+ // Polls `cmux read-screen` for the per-CLI prompt symbol and resolves only
1584
+ // when the symbol is stably rendered. Skips cleanly on non-cmux backends
1585
+ // (`no_screen_primitive`) and unknown CLIs (`unknown_cli`); on
1586
+ // `no_prompt_symbol_seen` (timeout) falls through to Layer 1 — never emits
1587
+ // its own 504. Per-request opt-out via `prompt_symbol_gate: false`.
1588
+ // See: docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md
1589
+ const promptSymbolGate = req.body?.prompt_symbol_gate !== false;
1590
+ const promptSymbolTimeoutMs = Math.min(
1591
+ Math.max(Number(req.body?.prompt_symbol_timeout_ms) || 8000, 500),
1592
+ 30000
1593
+ );
1594
+ let promptSymbol = null;
1595
+ if (promptSymbolGate) {
1596
+ const psResult = await submitGate.awaitPromptSymbol(session, {
1597
+ timeoutMs: promptSymbolTimeoutMs,
1598
+ });
1599
+ promptSymbol = {
1600
+ found: !!psResult.ready,
1601
+ waited_ms: psResult.waited_ms || 0,
1602
+ ...(psResult.reason ? { reason: psResult.reason } : {}),
1603
+ ...(psResult.last_seen_at != null ? { last_seen_at: psResult.last_seen_at } : {}),
1604
+ };
1605
+ if (psResult.reason === 'no_prompt_symbol_seen') {
1606
+ console.log(`[SUBMIT] Layer 3 timeout for ${id} after ${psResult.waited_ms}ms — falling through to Layer 1`);
1607
+ } else if (psResult.ready) {
1608
+ console.log(`[SUBMIT] Layer 3 ready for ${id} after ${psResult.waited_ms}ms`);
1609
+ }
1415
1610
  }
1416
1611
 
1417
- if (success) {
1418
- const busMsg = JSON.stringify({
1419
- type: 'submit',
1420
- sender: 'daemon',
1421
- session_id: id,
1422
- strategy,
1423
- attempts,
1424
- timestamp: new Date().toISOString()
1612
+ // Step 1: wait for REPL readiness — best-effort, proceed on plain `timeout`.
1613
+ // Hard-fail reasons (session_dead/error/restarting/no_state/no_state_manager)
1614
+ // still short-circuit to 504 because dispatching to a dead/missing PTY is
1615
+ // pointless. See spec §1.3 / §3.3.
1616
+ const gateResult = await submitGate.awaitReplReady(id, sessionStateManager, {
1617
+ timeoutMs: gateTimeoutMs,
1618
+ ...(minConfidence !== undefined ? { minConfidence } : {}),
1619
+ });
1620
+ const gatedDispatchAfterTimeout = !gateResult.ready;
1621
+ if (gatedDispatchAfterTimeout && gateResult.reason && gateResult.reason !== 'timeout') {
1622
+ console.log(`[SUBMIT] gate hard-fail ${id}: ${gateResult.reason} (last_state=${gateResult.last_state})`);
1623
+ return res.status(504).json({
1624
+ error: 'Submit gated-timeout — target REPL not in a dispatchable state',
1625
+ reason: gateResult.reason,
1626
+ last_state: gateResult.last_state,
1627
+ strategy: 'none',
1628
+ attempts: 0,
1629
+ gated: true,
1630
+ gate_wait_ms: gateResult.waited_ms,
1631
+ ...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
1425
1632
  });
1426
- busClients.forEach(client => {
1427
- if (client.readyState === 1) client.send(busMsg);
1633
+ }
1634
+ if (gatedDispatchAfterTimeout) {
1635
+ console.log(`[SUBMIT] gate timeout ${id}: dispatching anyway (last_state=${gateResult.last_state})`);
1636
+ }
1637
+
1638
+ // Step 2: dispatch Enter via existing kitty → cmux → PTY chain.
1639
+ let strategy = terminalLevelSubmit(id, session);
1640
+ let attempts = strategy ? 1 : 0;
1641
+ if (!strategy) {
1642
+ return res.status(503).json({
1643
+ error: 'Submit failed via all strategies (kitty/cmux/pty)',
1644
+ strategy: 'none',
1645
+ attempts: 0,
1646
+ gated: true,
1647
+ gate_wait_ms: gateResult.waited_ms,
1648
+ ...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
1428
1649
  });
1429
- res.json({ success: true, strategy, attempts });
1430
- } else {
1431
- res.status(503).json({ error: `Submit failed via ${strategy}`, strategy, attempts });
1432
1650
  }
1651
+
1652
+ // Step 3: verify body consumption (only when the caller provided the body).
1653
+ // Without `injected_body`, this is a bare Enter press (`telepty enter` or
1654
+ // `telepty send-key` without force) — there is nothing to verify and one
1655
+ // shot is enough.
1656
+ let verify = null;
1657
+ if (injectedBody && injectedBody.length > 0) {
1658
+ verify = await submitGate.verifyBodyConsumed(session, injectedBody, {
1659
+ timeoutMs: verifyTimeoutMs,
1660
+ stripAnsi: stripAnsiState,
1661
+ });
1662
+ if (!verify.consumed) {
1663
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
1664
+ const retryStrategy = terminalLevelSubmit(id, session);
1665
+ if (retryStrategy) {
1666
+ strategy = retryStrategy;
1667
+ attempts++;
1668
+ verify = await submitGate.verifyBodyConsumed(session, injectedBody, {
1669
+ timeoutMs: verifyTimeoutMs,
1670
+ stripAnsi: stripAnsiState,
1671
+ });
1672
+ }
1673
+ }
1674
+ // Honest 504: gate timed out AND the body never left the input box even
1675
+ // after the best-effort dispatch + verify. Distinguishable from the legacy
1676
+ // `gate_timeout` reason (which dropped dispatch entirely).
1677
+ if (gatedDispatchAfterTimeout && !verify.consumed) {
1678
+ const failBody = {
1679
+ error: 'Submit gated-timeout and body not consumed after best-effort dispatch',
1680
+ reason: 'gated_dispatch_unconsumed',
1681
+ last_state: gateResult.last_state,
1682
+ strategy,
1683
+ attempts,
1684
+ gated: true,
1685
+ gate_wait_ms: gateResult.waited_ms,
1686
+ verify,
1687
+ gated_dispatch_after_timeout: true,
1688
+ ...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
1689
+ };
1690
+ emitSubmitBus(failBody);
1691
+ return res.status(504).json(failBody);
1692
+ }
1693
+ }
1694
+
1695
+ const responseBody = {
1696
+ success: true,
1697
+ strategy,
1698
+ attempts,
1699
+ gated: true,
1700
+ gate_wait_ms: gateResult.waited_ms,
1701
+ verify,
1702
+ ...(gatedDispatchAfterTimeout ? { gated_dispatch_after_timeout: true } : {}),
1703
+ ...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
1704
+ };
1705
+ emitSubmitBus(responseBody);
1706
+ return res.json(responseBody);
1433
1707
  });
1434
1708
 
1435
1709
  // POST /api/sessions/submit-all — Submit all active sessions
@@ -1515,9 +1789,57 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
1515
1789
  }
1516
1790
  });
1517
1791
 
1518
- // Auto-report: track pending inject for idle notification back to source
1792
+ // Reverse-match for REPORT detection:
1793
+ // If this inject is FROM a session with a pending report whose source is
1794
+ // the current recipient, and the prompt matches a REPORT prefix, then
1795
+ // this is a content REPORT satisfying enforcement for the sender.
1519
1796
  if (from) {
1520
- pendingReports[id] = { source: from, injectedAt: injectTimestamp, injectId: inject_id };
1797
+ const senderAlias = resolveSessionAlias(from) || from;
1798
+ const senderPending = pendingReports[senderAlias];
1799
+ const recipientAlias = resolveSessionAlias(id) || id;
1800
+ if (senderPending) {
1801
+ const pendingSourceAlias = resolveSessionAlias(senderPending.source) || senderPending.source;
1802
+ if (pendingSourceAlias === recipientAlias) {
1803
+ const classification = classifyReportPrompt(prompt);
1804
+ if (classification) {
1805
+ delete pendingReports[senderAlias];
1806
+ const elapsedSecs = Number(((Date.now() - new Date(senderPending.injectedAt).getTime()) / 1000).toFixed(1));
1807
+ const senderSession = sessions[senderAlias];
1808
+ const eventType =
1809
+ classification === 'report_blocked' ? 'TASK_BLOCKED_WITH_REASON' :
1810
+ classification === 'report_dismissed' ? 'TASK_DISMISSED' :
1811
+ classification === 'report_error' ? 'TASK_COMPLETE_WITH_REPORT' :
1812
+ 'TASK_COMPLETE_WITH_REPORT';
1813
+ broadcastSessionEvent(eventType, senderAlias, senderSession, {
1814
+ extra: {
1815
+ source: senderPending.source,
1816
+ inject_id: senderPending.injectId,
1817
+ report_inject_id: inject_id,
1818
+ elapsed_secs: elapsedSecs,
1819
+ injected_at: senderPending.injectedAt,
1820
+ report_status: classification,
1821
+ report_summary: prompt.slice(0, 500)
1822
+ }
1823
+ });
1824
+ console.log(`[ENFORCE-REPORT] ${eventType} from ${senderAlias} → ${recipientAlias} (${classification}, ${elapsedSecs}s)`);
1825
+ }
1826
+ }
1827
+ }
1828
+ }
1829
+
1830
+ // Auto-report: track pending inject for idle notification back to source.
1831
+ // Overwrite warning: if an entry already exists, log for observability.
1832
+ if (from) {
1833
+ if (pendingReports[id]) {
1834
+ console.warn(`[AUTO-REPORT] overwritten pending report for ${id} (previous source: ${pendingReports[id].source}, new source: ${from})`);
1835
+ }
1836
+ pendingReports[id] = {
1837
+ source: from,
1838
+ injectedAt: injectTimestamp,
1839
+ injectId: inject_id,
1840
+ awaitingReport: true,
1841
+ idleNotified: false
1842
+ };
1521
1843
  }
1522
1844
 
1523
1845
  // Notify all attached viewers (telepty attach clients) about the inject
@@ -1566,6 +1888,50 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
1566
1888
  }
1567
1889
  });
1568
1890
 
1891
+ // GET /api/pendingReports/:id — inspect pending report entry + optional auto_summary
1892
+ app.get('/api/pendingReports/:id', (req, res) => {
1893
+ const requestedId = req.params.id;
1894
+ const resolvedId = resolveSessionAlias(requestedId) || requestedId;
1895
+ const entry = pendingReports[resolvedId];
1896
+ if (!entry) {
1897
+ return res.status(404).json({ error: 'No pending report', requested: requestedId });
1898
+ }
1899
+ const session = sessions[resolvedId];
1900
+ const autoSummary = REPORT_AUTO_SUMMARY_ON_QUERY && session ? buildAutoSummaryWithDefaults(session) : null;
1901
+ res.json({
1902
+ session_id: resolvedId,
1903
+ source: entry.source,
1904
+ inject_id: entry.injectId,
1905
+ injected_at: entry.injectedAt,
1906
+ idle_notified: !!entry.idleNotified,
1907
+ idle_at: entry.idleAt || null,
1908
+ awaiting_report: !!entry.awaitingReport,
1909
+ auto_summary: autoSummary
1910
+ });
1911
+ });
1912
+
1913
+ // DELETE /api/pendingReports/:id — orchestrator-side dismissal
1914
+ app.delete('/api/pendingReports/:id', (req, res) => {
1915
+ const requestedId = req.params.id;
1916
+ const resolvedId = resolveSessionAlias(requestedId) || requestedId;
1917
+ const entry = pendingReports[resolvedId];
1918
+ if (!entry) {
1919
+ return res.status(404).json({ error: 'No pending report', requested: requestedId });
1920
+ }
1921
+ delete pendingReports[resolvedId];
1922
+ const session = sessions[resolvedId];
1923
+ broadcastSessionEvent('TASK_DISMISSED', resolvedId, session, {
1924
+ extra: {
1925
+ source: entry.source,
1926
+ inject_id: entry.injectId,
1927
+ dismissed_by: 'orchestrator',
1928
+ injected_at: entry.injectedAt
1929
+ }
1930
+ });
1931
+ console.log(`[ENFORCE-REPORT] ${resolvedId} pending report dismissed by orchestrator`);
1932
+ res.json({ success: true, session_id: resolvedId });
1933
+ });
1934
+
1569
1935
  // GET /api/sessions/:id/screen — read current screen buffer
1570
1936
  app.get('/api/sessions/:id/screen', (req, res) => {
1571
1937
  const requestedId = req.params.id;
@@ -2101,11 +2467,22 @@ setInterval(() => {
2101
2467
  });
2102
2468
  console.log(`[IDLE] Session ${id} idle for ${idleSeconds}s`);
2103
2469
  }
2104
- // Auto-report for non-wrapped sessions: use idle threshold
2470
+ // Auto-report fallback for non-wrapped sessions (legacy threshold path).
2471
+ // Skip if onTransition already fired the idle notification.
2105
2472
  const pendingRpt = pendingReports[id];
2106
- if (pendingRpt && session.type !== 'wrapped' && idleSeconds !== null && idleSeconds >= AUTO_REPORT_IDLE_SECONDS) {
2107
- delete pendingReports[id];
2473
+ if (pendingRpt && !pendingRpt.idleNotified && session.type !== 'wrapped' && idleSeconds !== null && idleSeconds >= AUTO_REPORT_IDLE_SECONDS) {
2474
+ pendingRpt.idleNotified = true;
2475
+ pendingRpt.idleAt = new Date().toISOString();
2108
2476
  const elapsed = ((Date.now() - new Date(pendingRpt.injectedAt).getTime()) / 1000).toFixed(1);
2477
+ // Fire new bus event + legacy text-inject
2478
+ broadcastSessionEvent('TASK_IDLE_NO_REPORT', id, session, {
2479
+ extra: {
2480
+ source: pendingRpt.source,
2481
+ inject_id: pendingRpt.injectId,
2482
+ elapsed_secs: Number(elapsed),
2483
+ injected_at: pendingRpt.injectedAt
2484
+ }
2485
+ });
2109
2486
  const reportMsg = `TASK_COMPLETE: ${id} is now idle after processing inject (${elapsed}s)`;
2110
2487
  const srcId = resolveSessionAlias(pendingRpt.source) || pendingRpt.source;
2111
2488
  const srcSession = sessions[srcId];
@@ -2301,16 +2678,28 @@ wss.on('connection', (ws, req) => {
2301
2678
  if (client.readyState === 1) client.send(readyMsg);
2302
2679
  });
2303
2680
  // Auto-report: notify source that target completed inject task
2681
+ // Legacy ready-signal auto-report path. Skip if onTransition already
2682
+ // fired (pendingReports[sessionId].idleNotified === true).
2304
2683
  const pendingReport = pendingReports[sessionId];
2305
- if (pendingReport) {
2306
- delete pendingReports[sessionId];
2684
+ if (pendingReport && !pendingReport.idleNotified) {
2685
+ pendingReport.idleNotified = true;
2686
+ pendingReport.idleAt = new Date().toISOString();
2307
2687
  const elapsed = ((Date.now() - new Date(pendingReport.injectedAt).getTime()) / 1000).toFixed(1);
2688
+ // Fire new bus event + legacy text-inject
2689
+ broadcastSessionEvent('TASK_IDLE_NO_REPORT', sessionId, activeSession, {
2690
+ extra: {
2691
+ source: pendingReport.source,
2692
+ inject_id: pendingReport.injectId,
2693
+ elapsed_secs: Number(elapsed),
2694
+ injected_at: pendingReport.injectedAt
2695
+ }
2696
+ });
2308
2697
  const reportMsg = `TASK_COMPLETE: ${sessionId} is now idle after processing inject (${elapsed}s)`;
2309
2698
  const srcId = resolveSessionAlias(pendingReport.source) || pendingReport.source;
2310
2699
  const srcSession = sessions[srcId];
2311
2700
  if (srcSession) {
2312
2701
  deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
2313
- console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s`);
2702
+ console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (ready signal)`);
2314
2703
  }
2315
2704
  }
2316
2705
  }