diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index 0cac351f..ed034166 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -942,9 +942,7 @@ state was the cause. sessionExited, waited_ms, lastStatus, waitingSeen }`. `lastStatus` is the status at the last sample; `waitingSeen` is true when `waiting` was sampled within the last settle window before the end. -- **Single triggers have the same exposure and it is not addressed here.** - They keep their own `wait` field (`idle` by the level probe, or `none`) and - no descriptor wait; they can still be typed into a busy composer. +- **Single triggers: see "Readiness before a single trigger" below.** - **Busy-fall authority (#360).** `waitForBusyFall` receives the Enter's timestamp. A descriptor `idle` with `statusUpdatedAt >= enterAt`, held for the settle window, ends the wait even when `_cliBusy` is stuck true. An idle @@ -980,6 +978,53 @@ within `windowMs` (the busy-fall settle window, as in `waitForCliIdleAfter`). Tests: `test/trigger-blocked-session.test.js`. +### Readiness before a single trigger (issue #379) + +The two `wait` values keep their documented meaning; only the dialog is new +for `none`. + +- **`wait: "idle"`**: the same `waitForCliIdleAfter` as a chain step (afterMs + `-Infinity`, the trigger's own deadline `timeout_ms`), after `waitForIdle`, + `waitForComposerFree` and the liveness re-check, right before + `submitWithVerify`. No parallel mechanism: the not-ready results map to the + chain reasons (`REASON_DIALOG_OPEN`, `REASON_CLI_BUSY`, `REASON_CLI_NOT_IDLE`) + plus `REASON_IDLE_UNSETTLED` when the last read was `idle` but it never held + long enough to settle (a fresh idle at the deadline, or one that kept + restarting), never "never reported idle" for an idle descriptor. `error` is + `not sent`, `submitted` `no`. A session whose descriptor is held `busy` by + background agents (#360, a CLI limit) fails at the deadline; `none` is the + value for it. +- **Settle.** `waitForCliIdleAfter(…, trustIdleStamp)` is off by default, so + chains behave exactly as before: a stale idle read after a step whose Enter + drew no reaction must still pay the settle (the #407 family). The single path + passes `true`: an `idle` first read counts from its `statusUpdatedAt`, so one + older than the settle window is ready on that read (no flat +300 ms on every + trigger; a later new stamp still counts from when it was seen). The single path caps the settle at the time left to the + deadline, so a `timeout_ms` under the settle on an idle session writes. + Poll granularity is 100 ms, so a fresh idle with a very short deadline can + still end `REASON_IDLE_UNSETTLED`. +- **`wait: "none"`** writes now, the CLI queues a prompt written while busy. + `waitForNoDialog(sessionId, ctx, deadline)` holds only while the descriptor + reads `waiting` (a descriptor lost after it read `waiting` keeps the hold), + with no settle once it stops, and fails `not sent` + `REASON_DIALOG_OPEN` at + the deadline. `busy`, `idle` or no descriptor write at once. This closes the + hole `waitForIdle` leaves: it samples the descriptor only while `_cliBusy` is + true, so a dialog shown while `_cliBusy` reads false was written into. +- **Deadline.** Both paths refuse to write once `Date.now() >= commandDeadline` + (`REASON_DEADLINE_BEFORE_WRITE`), descriptor or not, as chains do. + +Typed input (`sendInput`, IPC `terminal-input`) is deliberately NOT held back. +The channel is fire-and-forget (`ipcMain.on`, no reply to carry an error), is +fed only by the renderer, and carries keystrokes, pastes, drops and the +context-menu paste on the same call with no marker telling a person from a +driver. A driver acting through the renderer (devtools, CDP) is the same call. +Holding it on `waiting` would also block the keystrokes that answer the +dialog. The only programmatic path that can be told apart is the trigger +watcher, which is held above. `handleTerminalInput` has no descriptor access +either (the descriptor is read through the trigger context). + +Tests: `test/trigger-single-readiness.test.js`. + ### Why `composerEmptyAfterWrite` cannot be made to prove submission, even by feeding it our own writes A proposal, considered and rejected 2026-09-04: since `submitToPty` writes diff --git a/CHANGELOG.md b/CHANGELOG.md index fb3278e6..44efe4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - A session's **Touched** tab, next to Changes in the terminal header, lists the files its file tools (Edit, Write, MultiEdit, NotebookEdit) touched, its subagents' included, with what is on disk now (present, gone, unreadable) and the tools and agents behind each. It works outside any git repository. It is not the complete set of files the session changed: files changed through Bash commands or scripts are not listed, and the tab says so. Local sessions only. (#309) - With Debug mode on, the activity trace now records how hard each terminal is being drawn: once a second per session, how many writes reached it, how large they were and how often its glyph atlas was rebuilt, to tell a legitimately busy terminal from a runaway one. (#175) ### Changed +- A single trigger is no longer typed into a dialog such as a permission prompt or a question: with `wait: "none"` (write now, the default) it holds while the CLI shows a dialog, and with `wait: "idle"` until the CLI is at its prompt, up to its `timeout_ms`; then it fails `not sent` with a `reason` that says a dialog is open instead of being written into it. `wait: "none"` still writes at once while the CLI is busy. Without a readable CLI descriptor it is written as before. Input you type yourself in the terminal is never held back. (#379) - Switchboard now checks once per host, at the first successful refresh and then every six hours (every 30 minutes while one is missing), whether `tmux` and `inotifywait` are installed. A host with `tmux` and no session running no longer shows attach as missing; a host without `tmux` no longer offers to attach to a session and opens its transcript, saying why in the tooltip; and the host's tooltip says when live updates are off because `inotifywait` is missing. On a remote host, the new-session button's tooltip now gives the reason, and Send a prompt… is disabled, with the reason, while no live session on the host reports a messaging socket. Stop is never disabled. (#218) - A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379) diff --git a/docs/automation.md b/docs/automation.md index 15a48c79..20d409bd 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -191,11 +191,16 @@ JSON file into `~/.switchboard/triggers/` (or `SWITCHBOARD_TRIGGERS_DIR`): Any open session qualifies, plain terminals included. - `command` — written to the terminal, followed by a separate Enter keypress. At most 4 KB, and no CR, LF, NUL or ESC. -- `wait` — `"none"` (the default) does not wait for the session to stop being - busy; `"idle"` does. Neither writes into a prompt that holds unsubmitted - input — see [Politeness](#politeness-switchboard-never-types-over-you) — so - `"none"` can still wait, up to `timeout_ms`. Use `"idle"` for anything that - must not interrupt a response being written. +- `wait` — `"none"` (the default) writes now: it does not wait for the session + to stop being busy, and a prompt written while the CLI is busy is queued by + the CLI. It holds in two cases only, each up to `timeout_ms`: the prompt holds + unsubmitted input — see + [Politeness](#politeness-switchboard-never-types-over-you) — or the CLI shows + a dialog (a permission prompt, a question), which would swallow the text. At + the deadline it fails `not sent`. `"idle"` waits for the CLI to be at its + prompt first, and is the value for anything that must not interrupt a + response being written; a session held busy by background agents never gets + there, so it fails `not sent` at the deadline. - `timeout_ms` — optional bound on all the waiting: idle **and** politeness. A positive integer up to 600 000; default 300 000. On a `chain` it is the deadline for the **whole chain** — see below. @@ -344,8 +349,9 @@ sends when no turn started — on a half-typed sentence, that Enter would submit it. When politeness never allows a write, the result is `{ "ok": false, "submitted": "no", "error": "not sent", "reason": "…" }`. -**What this costs `wait: "none"`.** It does not mean "write now": against a -non-empty prompt it waits, bounded only by `timeout_ms`. All that time the +**What this costs `wait: "none"`.** It writes now unless the prompt is +non-empty or the CLI shows a dialog; in those cases it waits, bounded only by +`timeout_ms`. All that time the trigger holds one of the watcher's 8 concurrent slots (`MAX_INFLIGHT`), so a few triggers aimed at sessions whose user walked away mid-sentence can stall the queue for everyone. Give triggers that would rather give up a short @@ -447,6 +453,27 @@ The two reserved values mean opposite things: `partial: false` for a `chain`. A session reports itself busy for as long as any subagent runs, so `idle` is often unreachable; `not sent` there tells the caller the payload never left. +- A single `command` with `wait: "idle"` is held like a chain step: after the idle + wait and the politeness wait, it is not written until the CLI's descriptor + reads `idle`, up to `timeout_ms`. An `idle` stamped before the settle window + is ready at once; a more recent one settles for at most the time left. If it + still reads `waiting` then, the result is `not sent` with `reason` *the CLI + reports a dialog open (waiting); nothing was written into it*; `busy` gives + *the CLI still reported a turn running (busy) at the deadline; nothing was + written*, and a session whose background agents keep the parent descriptor + `busy` (#360) always ends so: use `wait: "none"` for it. An `idle` that + never held long enough to settle gives *the CLI was idle only briefly before + the deadline; it never held long enough to settle; nothing was written*. Without a readable + descriptor at the first read nothing is waited for. +- A single `command` with `wait: "none"` keeps its write-now meaning: `busy`, + `idle` or an unreadable descriptor write at once, with no settle. The only + hold is a dialog: while the descriptor reads `waiting`, nothing is written, + and at `timeout_ms` the result is `not sent` with the dialog reason above. A + descriptor lost after it read `waiting` keeps the hold. +- Every single `command` is also never written once its `timeout_ms` has passed + (`not sent`, *the step deadline passed before it could be written; nothing + was written*). Keystrokes typed in the terminal are never held back: they are + how a dialog is answered. - A chain step is held until the CLI's descriptor reads `idle`, up to the step's deadline. If it still reads `busy` or `waiting` (or any status other than `idle`) then, the step is not written: `not sent` for the first step, `chain timeout` for a later one, with the cause in `reason`. A session with delegated agents running keeps the parent descriptor `busy`, so such a chain fails cleanly instead of typing into a busy composer. Without a readable descriptor at the first read nothing is waited for, but a step is never written once its own deadline has passed (it then fails `not sent` or `chain timeout`). - When the wait ends because the session never got there and the CLI's descriptor read `waiting` (a dialog is open: a permission prompt or a @@ -468,6 +495,8 @@ Both count every wait the trigger spent, at different scopes: - A `command` result carries `waited_ms`: the `wait: "idle"` wait (0 with `wait: "none"` or an idle session), plus the politeness wait, plus the + readiness wait (for `wait: "idle"`, the time spent until the descriptor read + idle; for `wait: "none"`, the time a dialog held it, else 0), plus the submission verification and its retry, if any. - A `chain` result carries `total_waited_ms` for the whole chain, and a `waited_ms` in each `steps[]` entry: that step's politeness wait, its diff --git a/test/trigger-single-readiness.test.js b/test/trigger-single-readiness.test.js new file mode 100644 index 00000000..f6e29bdd --- /dev/null +++ b/test/trigger-single-readiness.test.js @@ -0,0 +1,287 @@ +// test/trigger-single-readiness.test.js +// +// A single trigger and the CLI descriptor: `wait: "idle"` holds it until the +// descriptor reads idle, `wait: "none"` (write now) holds it only while a +// dialog is open. See .ai/contexts/trigger-watcher.md, "Readiness before a +// single trigger". +'use strict'; + +process.env.SWITCHBOARD_SUBMIT_ENTER_DELAY_MS = '1'; +process.env.SWITCHBOARD_SUBMIT_VERIFY_MS = '300'; +process.env.SWITCHBOARD_BUSY_FALL_SETTLE_MS = '50'; +process.env.SWITCHBOARD_BUSY_RISE_WAIT_MS = '100'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { start, waitForCliIdleAfter } = require('../trigger-watcher'); + +const silent = { info() {}, warn() {}, error() {}, debug() {} }; + +function session(sessionId, { withDescriptor = true, getCliStatus = null, onComposerRead = null } = {}) { + const written = []; + const exited = { v: false }; + const desc = { status: 'idle', statusUpdatedAt: Date.now() - 10_000 }; + const ptyProcess = { + pid: process.pid, + write(data) { written.push({ data, at: Date.now() }); }, + }; + const ctx = { + log: silent, + getPtyForSession: (id) => (id === sessionId && !exited.v ? { ptyProcess } : null), + isSessionBusy: () => false, + isPtyAlive: () => true, + getComposerState: () => { + if (onComposerRead) onComposerRead(exited); + return { pending: 0, lastInputAt: 0 }; + }, + }; + if (getCliStatus) ctx.getCliStatus = (id) => (id === sessionId ? getCliStatus() : undefined); + else if (withDescriptor) ctx.getCliStatus = (id) => (id === sessionId ? { ...desc } : undefined); + return { ctx, written, desc }; +} + +function set(desc, status) { + desc.status = status; + desc.statusUpdatedAt = Date.now(); +} + +async function run(payload, s) { + const tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sw-trigger-single-ready-'))); + process.env.SWITCHBOARD_TRIGGERS_DIR = tmp; + const watcher = start(s.ctx); + try { + fs.writeFileSync(path.join(tmp, payload.sessionId + '.json'), JSON.stringify(payload), 'utf8'); + const resultPath = path.join(tmp, 'processed', payload.sessionId + '.result.json'); + const limit = Date.now() + 15000; + while (!fs.existsSync(resultPath)) { + if (Date.now() > limit) throw new Error('no result file'); + await new Promise((r) => setTimeout(r, 20)); + } + await new Promise((r) => setTimeout(r, 20)); + return JSON.parse(fs.readFileSync(resultPath, 'utf8')); + } finally { + watcher.close(); + delete process.env.SWITCHBOARD_TRIGGERS_DIR; + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +const trig = (id, extra = {}) => ({ sessionId: id, command: 'hello', timeout_ms: 800, ...extra }); +async function holdingLoop(fn) { + const hold = setInterval(() => {}, 50); + try { return await fn(); } finally { clearInterval(hold); } +} + +const STALE = Date.now() - 5000; +const DIALOG = /dialog open \(waiting\); nothing was written into it/; + +async function withSettle(ms, fn) { + const before = process.env.SWITCHBOARD_BUSY_FALL_SETTLE_MS; + process.env.SWITCHBOARD_BUSY_FALL_SETTLE_MS = String(ms); + try { return await fn(); } finally { process.env.SWITCHBOARD_BUSY_FALL_SETTLE_MS = before; } +} + +test('none, a dialog open: nothing is written and the result says a dialog is open', async () => { + const id = 'sess-single-ready-dialog-' + Date.now(); + const s = session(id); + set(s.desc, 'waiting'); + const r = await run(trig(id), s); + assert.deepEqual(s.written, []); + assert.equal(r.ok, false); + assert.equal(r.error, 'not sent'); + assert.equal(r.submitted, 'no'); + assert.match(r.reason, DIALOG); +}); + +test('none, a dialog that closes: written as soon as the descriptor stops reading waiting, without a settle', async () => { + await withSettle(2000, async () => { + const id = 'sess-single-ready-none-closes-' + Date.now(); + const s = session(id); + set(s.desc, 'waiting'); + let closedAt = null; + setTimeout(() => { set(s.desc, 'busy'); closedAt = Date.now(); }, 300); + const r = await run(trig(id, { timeout_ms: 5000 }), s); + assert.ok(closedAt, 'the dialog never closed'); + assert.equal(s.written[0].data, 'hello'); + assert.ok(s.written[0].at >= closedAt); + assert.ok(s.written[0].at - closedAt < 1000, 'the write waited for a settle'); + assert.equal(r.ok, true); + }); +}); + +test('none, descriptor busy: written at once, the CLI queues it', async () => { + const id = 'sess-single-ready-none-busy-' + Date.now(); + const s = session(id); + set(s.desc, 'busy'); + const started = Date.now(); + const r = await run(trig(id), s); + assert.equal(s.written[0].data, 'hello'); + assert.ok(s.written[0].at - started < 500); + assert.equal(r.ok, true); +}); + +test('none, descriptor idle: written with no settle delay', async () => { + await withSettle(2000, async () => { + const id = 'sess-single-ready-none-idle-' + Date.now(); + const s = session(id); + set(s.desc, 'idle'); + const started = Date.now(); + const r = await run(trig(id, { timeout_ms: 5000 }), s); + assert.ok(s.written[0].at - started < 1000); + assert.equal(r.ok, true); + }); +}); + +test('none, no descriptor: written as before', async () => { + const id = 'sess-single-ready-none-nodesc-' + Date.now(); + const s = session(id, { withDescriptor: false }); + const r = await run(trig(id), s); + assert.equal(s.written[0].data, 'hello'); + assert.equal(r.ok, true); +}); + +test('none, a dialog read once then the descriptor lost: still held, dialog reason at the deadline', async () => { + const id = 'sess-single-ready-none-lost-' + Date.now(); + let reads = 0; + const s = session(id, { getCliStatus: () => (reads++ === 0 ? { status: 'waiting', statusUpdatedAt: Date.now() } : undefined) }); + const r = await run(trig(id, { timeout_ms: 500 }), s); + assert.deepEqual(s.written, []); + assert.match(r.reason, DIALOG); +}); + +test('none, the session exits while a dialog holds the trigger: nothing written, session exited', async () => { + const id = 'sess-single-ready-none-exit-' + Date.now(); + const s = session(id, { onComposerRead: (e) => { e.v = true; } }); + set(s.desc, 'waiting'); + const r = await run(trig(id), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'session exited during wait'); +}); + +test('idle, a dialog open while the session reads not busy: nothing is written, dialog reason', async () => { + const id = 'sess-single-ready-dialog-idle-' + Date.now(); + const s = session(id); + set(s.desc, 'waiting'); + const r = await run(trig(id, { wait: 'idle' }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, DIALOG); +}); + +test('idle, a dialog that closes before the deadline: written once the descriptor reads idle', async () => { + const id = 'sess-single-ready-closes-' + Date.now(); + const s = session(id); + set(s.desc, 'waiting'); + let idleAt = null; + setTimeout(() => { set(s.desc, 'idle'); idleAt = Date.now(); }, 300); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 3000 }), s); + assert.ok(idleAt, 'the descriptor never went idle'); + assert.equal(s.written[0].data, 'hello'); + assert.ok(s.written[0].at >= idleAt, `written ${idleAt - s.written[0].at} ms before the descriptor read idle`); + assert.equal(r.ok, true); +}); + +test('idle, descriptor busy until the deadline: nothing is written, the busy reason', async () => { + const id = 'sess-single-ready-busy-' + Date.now(); + const s = session(id); + set(s.desc, 'busy'); + const r = await run(trig(id, { wait: 'idle' }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /still reported a turn running \(busy\)/); +}); + +test('idle, a status read once then lost: not idle until the deadline, nothing written', async () => { + const id = 'sess-single-ready-idle-lost-' + Date.now(); + let reads = 0; + const s = session(id, { getCliStatus: () => (reads++ === 0 ? { status: 'busy', statusUpdatedAt: Date.now() } : undefined) }); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 500 }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /still reported a turn running \(busy\)/); +}); + +test('idle, the session exits during the readiness wait: nothing written, session exited', async () => { + const id = 'sess-single-ready-idle-exit-' + Date.now(); + const s = session(id, { onComposerRead: (e) => { e.v = true; } }); + const r = await run(trig(id, { wait: 'idle' }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'session exited during wait'); +}); + +test('idle, an idle descriptor read long ago is ready at once, whatever the settle', async () => { + await withSettle(2000, async () => { + const id = 'sess-single-ready-idle-old-' + Date.now(); + const s = session(id); + const started = Date.now(); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 5000 }), s); + assert.ok(s.written[0].at - started < 1000, 'the write waited for a settle'); + assert.equal(r.ok, true); + }); +}); + +test('idle, timeout_ms below the settle on an idle session: written, never "never reported idle"', async () => { + await withSettle(300, async () => { + const id = 'sess-single-ready-short-' + Date.now(); + const s = session(id); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 250 }), s); + assert.equal(s.written[0] && s.written[0].data, 'hello', JSON.stringify(r)); + assert.equal(r.ok, true); + }); +}); + +test('idle, an idle too recent for the settle: the settle is capped at the deadline, so it is written', async () => { + await withSettle(2000, async () => { + const id = 'sess-single-ready-cap-' + Date.now(); + const s = session(id); + s.desc.statusUpdatedAt = Date.now() - 150; + const r = await run(trig(id, { wait: 'idle', timeout_ms: 400 }), s); + assert.equal(s.written[0] && s.written[0].data, 'hello', JSON.stringify(r)); + assert.equal(r.ok, true); + }); +}); + +test('idle, an idle that keeps restarting never settles: the unsettled reason, nothing written', async () => { + const id = 'sess-single-ready-unsettled-' + Date.now(); + const s = session(id, { getCliStatus: () => ({ status: 'idle', statusUpdatedAt: Date.now() }) }); + const r = await run(trig(id, { wait: 'idle', timeout_ms: 500 }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /idle only briefly before the deadline; it never held long enough to settle/); +}); + +test('the deadline passed before the write: nothing is written, with or without a descriptor', async () => { + for (const withDescriptor of [true, false]) { + const id = 'sess-single-ready-deadline-' + withDescriptor + Date.now(); + const spin = () => { const end = Date.now() + 15; while (Date.now() < end); }; + const s = session(id, { withDescriptor, onComposerRead: spin }); + const r = await run(trig(id, { timeout_ms: 1 }), s); + assert.deepEqual(s.written, []); + assert.equal(r.error, 'not sent'); + assert.match(r.reason, /deadline passed before it could be written/); + } +}); + +test('waitForCliIdleAfter: an idle stamped before the settle window is ready on the first read', async () => { + const ctx = { + getPtyForSession: () => ({}), + getCliStatus: () => ({ status: 'idle', statusUpdatedAt: STALE }), + }; + const r = await holdingLoop(() => waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 2000, true)); + assert.equal(r.ready, true); + assert.ok(r.waited_ms < 500); +}); + +test('waitForCliIdleAfter: without trustIdleStamp (chains) an old idle still pays the settle', async () => { + const ctx = { + getPtyForSession: () => ({}), + getCliStatus: () => ({ status: 'idle', statusUpdatedAt: STALE }), + }; + const r = await holdingLoop(() => waitForCliIdleAfter('x', ctx, -Infinity, Date.now() + 5000, 400)); + assert.equal(r.ready, true); + assert.ok(r.waited_ms >= 380, 'ready after ' + r.waited_ms + ' ms, before the settle'); +}); diff --git a/trigger-watcher.js b/trigger-watcher.js index 6eae0277..af9941be 100644 --- a/trigger-watcher.js +++ b/trigger-watcher.js @@ -94,6 +94,7 @@ const REASON_DIALOG_OPEN_AFTER_WRITE = 'the CLI reports a dialog open (waiting) const REASON_DEADLINE_BEFORE_WRITE = 'the step deadline passed before it could be written; nothing was written'; const REASON_CLI_BUSY = 'the CLI still reported a turn running (busy) at the deadline; nothing was written'; const REASON_CLI_NOT_IDLE = 'the CLI never reported idle before the deadline; nothing was written'; +const REASON_IDLE_UNSETTLED = 'the CLI was idle only briefly before the deadline; it never held long enough to settle; nothing was written'; const ACCEPTED_WAITS = ['idle', 'none']; @@ -377,6 +378,25 @@ function createDialogProbe(ctx, sessionId, windowMs) { }; } +// see .ai/contexts/trigger-watcher.md, "Readiness before a single trigger" +function waitForNoDialog(sessionId, ctx, deadlineMs) { + const start = Date.now(); + let held = false; + return pollLoop((resolve, scheduleNext) => { + const now = Date.now(); + const waited_ms = now - start; + if (!ctx.getPtyForSession(sessionId)) { + return resolve({ clear: false, timedOut: false, sessionExited: true, waited_ms }); + } + const s = readCliStatus(ctx, sessionId); + if (s && s.status === 'waiting') held = true; + else if (s) held = false; + if (!held) return resolve({ clear: true, timedOut: false, sessionExited: false, waited_ms }); + if (now >= deadlineMs) return resolve({ clear: false, timedOut: true, sessionExited: false, waited_ms }); + scheduleNext(); + }); +} + const CLI_REACTION_STATUSES = ['busy', 'idle', 'waiting']; function isCompactCommand(command) { @@ -394,13 +414,14 @@ function cliForbidsRecoveryEnter(ctx, sessionId) { } // see .ai/contexts/trigger-watcher.md, "Readiness before every step" (return shape, descriptor loss, deadline) -function waitForCliIdleAfter(sessionId, ctx, afterMs, deadlineMs, settleMs = 0) { +function waitForCliIdleAfter(sessionId, ctx, afterMs, deadlineMs, settleMs = 0, trustIdleStamp = false) { const start = Date.now(); let idleSince = null; let idleStamp = null; let lastStatus = null; const dialog = createDialogProbe(ctx, sessionId, settleMs); let everRead = false; + let firstIdleRead = true; return pollLoop((resolve, scheduleNext) => { const now = Date.now(); const waited_ms = now - start; @@ -420,9 +441,10 @@ function waitForCliIdleAfter(sessionId, ctx, afterMs, deadlineMs, settleMs = 0) lastStatus = s.status; if (s.status === 'idle' && s.statusUpdatedAt > afterMs) { if (idleSince === null || idleStamp !== s.statusUpdatedAt) { - idleSince = now; + idleSince = (trustIdleStamp && firstIdleRead) ? Math.min(now, s.statusUpdatedAt) : now; idleStamp = s.statusUpdatedAt; } + firstIdleRead = false; idleHeld = now - idleSince >= settleMs; } else { idleSince = null; @@ -1124,6 +1146,49 @@ async function processTriggerFile(name, ctx, triggersDir, processedDir, onEntryR return; } + // see .ai/contexts/trigger-watcher.md, "Readiness before a single trigger" + if (wait === 'idle') { + const settleMs = Math.max(0, Math.min(getBusyFallSettleMs(), commandDeadline - Date.now() - 1)); + const ready = await waitForCliIdleAfter(sessionId, ctx, -Infinity, commandDeadline, settleMs, true); + waited_ms += ready.waited_ms; + if (ready.sessionExited) { + ctx.log.warn('[trigger-watcher] Session exited waiting for the CLI to be ready:', sessionId); + await writeResult({ ok: false, error: 'session exited during wait', sessionId, waited_ms }); + return; + } + if (!ready.available) { + ctx.log.info(`[trigger-watcher] No usable CLI descriptor for ${sessionId} at the start of the readiness wait`); + } + if (ready.timedOut) { + const dialog = ready.waitingSeen || ready.lastStatus === 'waiting'; + let reason = REASON_CLI_NOT_IDLE; + if (dialog) reason = REASON_DIALOG_OPEN; + else if (ready.lastStatus === 'busy') reason = REASON_CLI_BUSY; + else if (ready.lastStatus === 'idle') reason = REASON_IDLE_UNSETTLED; + ctx.log.warn(`[trigger-watcher] CLI not ready (${ready.lastStatus}) after ${ready.waited_ms} ms, nothing sent:`, sessionId); + await writeResult({ ok: false, submitted: SUBMITTED_NO, error: ERROR_NOT_SENT, reason, sessionId, waited_ms }); + return; + } + } else { + const clear = await waitForNoDialog(sessionId, ctx, commandDeadline); + waited_ms += clear.waited_ms; + if (clear.sessionExited) { + ctx.log.warn('[trigger-watcher] Session exited waiting for a dialog to close:', sessionId); + await writeResult({ ok: false, error: 'session exited during wait', sessionId, waited_ms }); + return; + } + if (clear.timedOut) { + ctx.log.warn('[trigger-watcher] Dialog still open at the deadline, nothing sent:', sessionId); + await writeResult({ ok: false, submitted: SUBMITTED_NO, error: ERROR_NOT_SENT, reason: REASON_DIALOG_OPEN, sessionId, waited_ms }); + return; + } + } + if (Date.now() >= commandDeadline) { + ctx.log.warn('[trigger-watcher] Deadline passed before the command could be written:', sessionId); + await writeResult({ ok: false, submitted: SUBMITTED_NO, error: ERROR_NOT_SENT, reason: REASON_DEADLINE_BEFORE_WRITE, sessionId, waited_ms }); + return; + } + // Write to PTY: text, then Enter as a discrete keypress (see submitToPty), // then verify the submission actually started a turn — retrying the Enter // once if busy is never observed (the 2026-06-04 "text stuck in