@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/CHANGELOG.md +326 -0
- package/CLAUDE.md +5 -1
- package/README.md +3 -0
- package/cli.js +109 -16
- package/daemon.js +431 -42
- package/docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md +447 -0
- package/docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md +571 -0
- package/docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md +608 -0
- package/docs/superpowers/specs/2026-05-02-submit-force-and-retry.md +139 -0
- package/package.json +4 -4
- package/specs/codex-inject-spec.md +201 -0
- package/specs/enforce-report-spec.md +237 -0
- package/src/prompt-symbol-registry.js +97 -0
- package/src/report-enforcement.js +86 -0
- package/src/submit-gate.js +269 -0
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
|
-
//
|
|
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 —
|
|
1304
|
-
|
|
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
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
-
|
|
1404
|
-
|
|
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
|
-
|
|
1408
|
-
|
|
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
|
-
//
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
|
|
1427
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|