@dmsdc-ai/aigentry-telepty 0.1.98 → 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,7 +35,23 @@ 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 + fire auto-report on idle
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;
@@ -41,19 +59,60 @@ sessionStateManager.onTransition((sessionId, from, to, detail) => {
41
59
  extra: { auto_state: to, auto_state_from: from, auto_detail: detail }
42
60
  });
43
61
 
44
- // Auto-report: fire when session transitions to idle after inject
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).
45
65
  if (to === 'idle' && pendingReports[sessionId]) {
46
66
  const pendingReport = pendingReports[sessionId];
47
- delete 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
+
48
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)
49
87
  const reportMsg = `TASK_COMPLETE: ${sessionId} is now idle after processing inject (${elapsed}s)`;
50
88
  const srcId = resolveSessionAlias(pendingReport.source) || pendingReport.source;
51
89
  const srcSession = sessions[srcId];
52
90
  if (srcSession) {
53
91
  deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
54
- console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (via state machine)`);
92
+ console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (legacy text-inject)`);
55
93
  }
56
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
+ }
57
116
  });
58
117
 
59
118
  function persistSessions() {
@@ -1413,7 +1472,28 @@ function submitViaCmux(sessionId) {
1413
1472
  }
1414
1473
  }
1415
1474
 
1416
- // 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
1417
1497
  app.post('/api/sessions/:id/submit', async (req, res) => {
1418
1498
  const requestedId = req.params.id;
1419
1499
  const resolvedId = resolveSessionAlias(requestedId);
@@ -1424,41 +1504,206 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
1424
1504
  const retries = Math.min(Math.max(Number(req.body?.retries) || 0, 0), 3);
1425
1505
  const retryDelayMs = Math.min(Math.max(Number(req.body?.retry_delay_ms) || 500, 100), 2000);
1426
1506
  const preDelayMs = Math.min(Math.max(Number(req.body?.pre_delay_ms) || 0, 0), 1000);
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
+ });
1535
+ }
1427
1536
 
1428
- // Terminal-level submit: kitty cmux PTY fallback
1429
- console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}`);
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
+ });
1554
+ }
1430
1555
 
1431
- // Pre-delay: wait for paste rendering to complete before sending CR
1432
- if (preDelayMs > 0) {
1433
- await new Promise(resolve => setTimeout(resolve, preDelayMs));
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
+ });
1434
1578
  }
1435
1579
 
1436
- let strategy = terminalLevelSubmit(id, session);
1437
- let attempts = 1;
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
+ }
1610
+ }
1438
1611
 
1439
- // Retry: resend CR if paste may have absorbed the first one
1440
- for (let i = 0; i < retries && strategy; i++) {
1441
- await new Promise(resolve => setTimeout(resolve, retryDelayMs));
1442
- terminalLevelSubmit(id, session);
1443
- attempts++;
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 } : {}),
1632
+ });
1633
+ }
1634
+ if (gatedDispatchAfterTimeout) {
1635
+ console.log(`[SUBMIT] gate timeout ${id}: dispatching anyway (last_state=${gateResult.last_state})`);
1444
1636
  }
1445
1637
 
1446
- if (strategy) {
1447
- const busMsg = JSON.stringify({
1448
- type: 'submit',
1449
- sender: 'daemon',
1450
- session_id: id,
1451
- strategy,
1452
- attempts,
1453
- timestamp: new Date().toISOString()
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 } : {}),
1454
1649
  });
1455
- busClients.forEach(client => {
1456
- if (client.readyState === 1) client.send(busMsg);
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,
1457
1661
  });
1458
- res.json({ success: true, strategy, attempts });
1459
- } else {
1460
- res.status(503).json({ error: 'Submit failed via all strategies (kitty/cmux/pty)', strategy: 'none', attempts });
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
+ }
1461
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);
1462
1707
  });
1463
1708
 
1464
1709
  // POST /api/sessions/submit-all — Submit all active sessions
@@ -1544,9 +1789,57 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
1544
1789
  }
1545
1790
  });
1546
1791
 
1547
- // 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.
1796
+ if (from) {
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.
1548
1832
  if (from) {
1549
- pendingReports[id] = { source: from, injectedAt: injectTimestamp, injectId: inject_id };
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
+ };
1550
1843
  }
1551
1844
 
1552
1845
  // Notify all attached viewers (telepty attach clients) about the inject
@@ -1595,6 +1888,50 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
1595
1888
  }
1596
1889
  });
1597
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
+
1598
1935
  // GET /api/sessions/:id/screen — read current screen buffer
1599
1936
  app.get('/api/sessions/:id/screen', (req, res) => {
1600
1937
  const requestedId = req.params.id;
@@ -2130,11 +2467,22 @@ setInterval(() => {
2130
2467
  });
2131
2468
  console.log(`[IDLE] Session ${id} idle for ${idleSeconds}s`);
2132
2469
  }
2133
- // 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.
2134
2472
  const pendingRpt = pendingReports[id];
2135
- if (pendingRpt && session.type !== 'wrapped' && idleSeconds !== null && idleSeconds >= AUTO_REPORT_IDLE_SECONDS) {
2136
- 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();
2137
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
+ });
2138
2486
  const reportMsg = `TASK_COMPLETE: ${id} is now idle after processing inject (${elapsed}s)`;
2139
2487
  const srcId = resolveSessionAlias(pendingRpt.source) || pendingRpt.source;
2140
2488
  const srcSession = sessions[srcId];
@@ -2330,16 +2678,28 @@ wss.on('connection', (ws, req) => {
2330
2678
  if (client.readyState === 1) client.send(readyMsg);
2331
2679
  });
2332
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).
2333
2683
  const pendingReport = pendingReports[sessionId];
2334
- if (pendingReport) {
2335
- delete pendingReports[sessionId];
2684
+ if (pendingReport && !pendingReport.idleNotified) {
2685
+ pendingReport.idleNotified = true;
2686
+ pendingReport.idleAt = new Date().toISOString();
2336
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
+ });
2337
2697
  const reportMsg = `TASK_COMPLETE: ${sessionId} is now idle after processing inject (${elapsed}s)`;
2338
2698
  const srcId = resolveSessionAlias(pendingReport.source) || pendingReport.source;
2339
2699
  const srcSession = sessions[srcId];
2340
2700
  if (srcSession) {
2341
2701
  deliverInjectionToSession(srcId, srcSession, reportMsg, { noEnter: false, source: 'auto_report' });
2342
- console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s`);
2702
+ console.log(`[AUTO-REPORT] ${sessionId} → ${srcId}: idle after ${elapsed}s (ready signal)`);
2343
2703
  }
2344
2704
  }
2345
2705
  }