Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
43 changes: 36 additions & 7 deletions docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading