Waiting detector (part 3)
Part 3 of the Waiting detector page: everything the detector deliberately never does, the optional AI second opinion that double-checks the pattern match, and the first half of the technical reference for anyone working on the code — the single chokepoint every needs-you flip passes through and the pattern library behind it.
What it is
This is part 3 of the Waiting detector page. It covers the limits of the detector — every case it deliberately leaves alone — together with the opt-in AI second opinion that double-checks an ambiguous turn, and the first half of the reference material for anyone with the code open: the single chokepoint every needs-you flip passes through and the pattern library it matches against.
Where to find it
The same surface as the parent page: the session chat, where the orange ⏱ Auto-waited pill sits above the agent reply that fired and the Keep waiting and Check in buttons sit above the message box; the Needs You inbox row and its right-click menu; the controls under Settings → Sessions; and the audit log at Settings → Diagnostics → Waiting Detector activity.
How it behaves
What it does NOT do
- It does not block real questions.
pendingAction === 'question'is checked first; question turns always flip to Needs You no matter what waiting language is in the prose. - It does not suppress waits on YOU. A pattern hit on "Waiting on your call", "awaiting your merge", "Holding until you confirm", "I'll wait until you decide" — anything where the wait is directed at the human — is a real Needs You. The user-directed guard at the gate vetoes the suppression. This is the single most consequential of the three rejection guards (see "Three rejection guards" below).
- It does not suppress long single-paragraph messages. A single block of prose over 2,000 characters is treated as a substantive summary and flips to Needs You even when waiting vocabulary is present. (A long multi-paragraph report can still suppress when it ENDS on a terse handoff line — see the next bullet.)
- It suppresses multi-paragraph messages only if the final paragraph is a clean wait declaration. A genuine "I'm waiting" turn-end is often a terse status line stapled to the bottom of a longer dispatch summary — e.g. "Dispatched the deploy to staging. Watching the build dashboard. Waiting for completion notification." The trailing-wait carve-out (added 2026-05-26) admits this shape: when the message is multi-paragraph, the gate extracts the final paragraph, requires it to be ≤ 400 characters AND not user-directed AND to itself contain a high-confidence pattern match. The bulk of the message is allowed to be a substantive report; only the closing paragraph carries the suppression. For a long (over-2,000-char) multi-paragraph message the closing line must additionally be an explicit handoff — a forward commitment ("I'll report back when the build lands") or a poll-avoidance commitment — not just any wait phrase (long-message forward-commit carve-out, 2026-06-06); this is what lets the screenshot case "…I'll report back the moment the cloud tests finish." suppress while a long answer that merely ends on "…was waiting for the index to initialize" still flips to Needs You. Penultimate carve-out (2026-06-07): the second-to-last paragraph is also scanned when it is a clean hand-off (a forward commitment such as "the harness will auto-resume me when it finishes", or a poll-avoidance commitment) AND the message carries no attention-grab alert anywhere — this catches a wait that closes the prose but is then followed by a trailing status-dashboard paragraph ("Gate so far: typecheck … · lint … · build …"). A wait can also LEAD the turn: when the opening paragraph is itself a clean hand-off (a forward-commitment or poll-avoidance pledge), it suppresses too — and as of 2026-06-08 that holds even when the opening paragraph is long and the wait closes it (the same trailing-sentence extraction the closing paragraph gets). Other interior paragraphs are still never scanned. Single-paragraph behaviour is unchanged.
- It does not suppress policy/rule status reports. A hit on "blocked by branch protection", "blocked by content filtering policy", or "blocked by content policy" describes a permanent rule the agent ran into — not a wait it is actively holding for. The
blocked-on-bypattern carries a negative lookahead (added 2026-05-26) that excludes these so the session flips to Needs You correctly, while genuine waits like "blocked on the deploy" / "blocked until tests pass" / "blocked by CI" still suppress. - It does not hold on a bare "parking" phrase with nothing real behind it (2026-07-23). A poll-avoidance sign-off ("won't poll", "no polling") or a bare self-hold marker ("I'm waiting.", "Sitting tight.", "Awaiting.", "Going quiet.", "The wait is not over.") now auto-waits only when the same message also shows something real to wait on — a named build/test/deploy, a "the harness will notify me" hand-off, a forward commitment, OR plain-English background activity ("Build kicked off", "dispatched to the cloud", "the watcher is armed", "…until it lands"). A bare done-report sign-off ("Typecheck's green. Won't poll further.", a lone "Awaiting.") is treated as finished, not waiting, so it flips to Needs You immediately instead of parking the session for a full waiting window. This closed the dominant real-world false-hold: a read-only backtest measured the always-on poll-avoidance phrases holding an already-done session ~57% of the time (vs ~0.1% for the canonical keep-alive sentinel). The genuine "won't poll — it'll notify me on completion" hand-off still holds, as do the sentinel and every "waiting on you" safeguard (this gate runs after both). See
waiting-detector-contract.mdinvariantan-uncorroborated-operative-does-not-hold. - It does not override explicit tool intent. If the agent invokes
ScheduleWakeup, orAgent/Taskwithrun_in_background: true, those paths populate the samedeferredWorkfield directly, and the prose detector only runs when no tool-based deferral is already in place. (A backgroundBashdoes not create a deferral — that was reverted 2026-05-21 — so a bg-Bashturn relies on the prose sentinel, not a tool deferral.) Two carve-outs. First (2026-06-14): a stale background-task deferral — the agent dispatched a backgroundAgent/Taskearly, then kept working and ended the turn on the canonical waiting sentinel — no longer shadows the detector. Omniscio clears the stale flag and re-runs Gate 6 on the closing prose, so the sentinel still auto-waits instead of a premature Needs You. Second (2026-07-15): the mirror case — the agent dispatched a backgroundAgent/Taskas its final tool AND closed the turn on the canonical sentinel carrying anETA <N> minutes.— no longer drops that ETA. Rather than the flat background-task window, Omniscio honors the ETA (it becomes the wait window, and Omniscio checks in at the ETA unconditionally, exactly like a foreground ETA wait) — the fix for a wait that showed ⏱ Auto-waited with "ETA 20 minutes" yet surfaced to the inbox without checking in. Only an ETA-bearing sentinel diverts; a bare background-task wait is unchanged. Seewaiting-detector-contract.mdinvarianta-background-hold-arms-only-when-it-was-the-last-tool(both carve-outs) +a-declared-eta-sets-the-window. - It honors the canonical sentinel even when a turn never finishes (2026-06-20). Gate 6 runs only on the CLI's turn-end
resultevent. If the agent streams the full canonical waiting sentinel ("Waiting on a background process to finish…") but the CLI never emits that result event — it hung or was CPU-starved on its own background jobs — Gate 6 never runs, and before this fix the stall watchdog marked the session Stalled? once the stall-detection window passed. Omniscio now checks the agent's in-flight text the moment a stall would fire: if it is the canonical sentinel (and not a question), the session auto-waits (the same ⏱ Auto-waited hold + cap) instead of stalling. Only the unambiguous canonical sentinel is honored on this path — not the broad patterns — because a genuinely silent turn that did NOT declare the sentinel should still stall. Seewaiting-detector-contract.md"Stall-path canonical-sentinel honor". - It keeps auto-waiting when the sentinel closes a turn that still has background subagents in flight (2026-07-23). When the agent dispatches background
Agent/Tasksubagents and then closes the turn on the canonical waiting sentinel ("Waiting on a background process to finish… ETA N minutes."), the subagent defense-in-depth gate (Gate 5.5) used to fire first and short-circuit the sentinel's own auto-wait — so it suppressed only that one turn-end and, the moment the subagents reported back, the session fell through to a bare Needs You (live sessiond7e04aa1: it surfaced ~9s after the sentinel turn and self-recovered ~3 min later; the turn 84s earlier, with no subagents, auto-waited correctly). Now Gate 5.5 yields to the canonical sentinel (when the detector is on AND the process is still live) so the sentinel arms its durable ⏱ Auto-waited + ETA hold that survives the subagents finishing. A non-sentinel subagent turn, a dead process, or the detector turned off keep the old defense-in-depth suppress unchanged. Seewaiting-detector-contract.mdinvariant I4b. - It honors the canonical sentinel even after an earlier in-turn question (2026-07-19). A long, multi-step turn can trip Omniscio's "this turn asked a question" flag on an EARLIER step — a rhetorical self-question the agent reasoned through, or a decision it weighed — and that flag is sticky for the whole turn. Before this fix, a sticky earlier-turn question would veto the auto-wait even when the turn's FINAL message was the exact canonical sentinel ("Waiting on a background process to finish…"), so a turn that legitimately ended on the keep-alive line flipped to Needs You and landed in your inbox (live session
95662a3a, 2026-07-18 — the keep-alive line surfaced for ~50s until a dispatched subagent replied). Now the canonical sentinel as the turn's FINAL text is treated as the agent's authoritative "I need nothing from you" close and always auto-waits, on both the normal turn-end path and the stall path. A GENUINE question in the FINAL message still wins (a clean sentinel has no?and no option-list widget), so you're never held back from a real question. Seewaiting-detector-contract.mdinvarianta-question-always-wins(canonical-sentinel carve-out). - It never drops a still-working session into your inbox when its final line is the keep-alive sentence, even if something transient shadowed the detector (2026-08-07). When an agent wakes itself the instant its own background job finishes, reads the result, and closes again on the exact "Waiting on a background process to finish…" line, a rare, split-second internal state could occasionally slip past the main detector and let that turn flip to Needs You — the same keep-alive line that auto-waited a moment earlier and a moment later (live session
b3632f18: three of four byte-identical waits auto-waited, the fourth surfaced and then healed itself ~6 minutes later on its own). A last-resort guard now sits at the exact "about to drop into the inbox" moment: if the agent's final message IS the keep-alive sentence, its process is still alive, and it isn't actually asking you anything, Omniscio keeps it Running (honoring any ETA) instead of surfacing — no matter what shadowed the detector upstream. A real question, a real "approve my plan," and error/rate-limit cases still reach you exactly as before, and Omniscio logs a warning each time the guard has to step in so the underlying cause stays diagnosable. Seewaiting-detector-contract.mdinvariantthe-canonical-sentence-holds-even-when-shadowed(live-child sentinel last-resort dominance). - It does not fire on a quoted or mentioned sentinel (2026-07-14). An agent that writes Omniscio's exact wait phrase inside quotes or backticks — e.g. while explaining the waiting feature, as in 'the agent emits its exact line ("Waiting on a background process to finish…")' — is talking ABOUT the wait, not declaring one, so it is not auto-waited (the same way a phrase quoted inside a
```code block is already ignored). This matters most in the default **high-precision** mode, where the broad corpus is off — a message describing the feature used to trip a spurious auto-wait; the same short-quote strip now also guards the tier's own paraphrase patterns (will-continue-when-once-after,waiting-on-ci-noun). A genuine, _unquoted_ emission still auto-waits, even when the message also contains unrelated quoted text (a quoted file path, a tool echo): only a **short** quoted span — a plausible quote of the ~80-char phrase — is ignored, never a long span that merely happens to bracket a real emission. Validated read-only against the live history (16,722 agent messages): 38 quoted mentions stopped false-waiting, 0 real waits affected. Seewaiting-detector-contract.md"Canonical-sentinel quoted-mention guard". - It does not affect search, export, or audit. The suppression still writes a normal
notable-systemmessage row — the chat just renders it as the ⏱ Auto-waited tag rather than a prose bubble; the underlying message rows are untouched. Tools that read history (Search, Share, Audit Framework) see the same data they did before. - It does not affect rate-limit, auth, or API-error paths. Those have their own dedicated gates (Gates 1–3 in
handleTurnComplete) that run earlier and always pierce. - The auto-wait survives a transient auth-token refresh (2026-07-13). If your login token briefly
401s while a session is auto-waiting, Omniscio's silent auth-retry refreshes the token and re-runs the last turn. If that re-run comes back empty (the agent had nothing new to say), Omniscio now restores the auto-wait instead of dropping the session in your inbox a full window early — the earlier behaviour that surfaced a still-waiting session ~10 minutes into a 13-minute wait during a token blip (live session869d8973). The wait resumes with a fresh window; its overall "waited too long" ceiling is preserved, so it still can't wait forever. A re-run that actually produces an answer, asks a question, or re-declares its own wait ends the hold normally. Currently scoped to the auth-retry path. See waiting-detector-contract.md invarianta-transient-auth-failure-does-not-discard-a-wait. - The auto-wait survives a rate limit hit during an ETA check-in (2026-07-16). When Omniscio auto-checks-in on a waiting session at its ETA and that re-run immediately hits your Claude session/rate limit, the session used to be misfiled as awaiting your reply (its last message is the wait sentinel) — so rate-limit recovery skipped it, stopped tracking it, and the stall watchdog then dropped it into your inbox as a bare Needs You with no ⏱ wait buttons and no rate-limit Continue (live session
89a27b7f). Omniscio now recognises that an Omniscio-managed auto-wait is not a wait on you, so a rate-limited waiting session recovers normally — it moves to a healthy account, or auto-restarts at the limit reset — instead of stranding. A wait directed at you ("waiting on your input") is still a real Needs You. This is the recovery-path sibling of the auth-token-refresh case above. See rate-limit-recovery-contract.md invariant 18 (waiting-yield carve-out). - It does not run on past turns retroactively. A session that flipped to Needs You before this feature shipped stays where it is. Only new turn-completes go through the detector.
AI second opinion (Stage 2 — opt-in, default off)
The pattern library is fast and free but it is a heuristic: it has accreted dozens of patterns and several rejection guards, and it still both fires on the occasional false positive and misses phrasings no pattern covers. Stage 2 adds a cheap model (Haiku) as a second opinion behind the regex — off by default; enable at Settings → Sessions → "AI second-opinion on waiting detection" (needs an AI key, like the other AI features). Kill switch: AMC_DISABLE_WAITING_MODEL=1.
The regex is always the floor. Stage 1 (the patterns above) runs first on every turn, for free. The model is consulted ONLY on the ambiguous band — never on a clear turn-end:
| The regex… | Stage 2 acts | Outcome |
|---|---|---|
| fired (a clean suppressible hit) | model confirms | model says needs-you → flip (kills a false alarm); otherwise stay running |
| near-missed (a pattern matched but a guard rejected it) | model checks | model says it's a real wait → stay running (catches a miss) |
| saw no waiting language at all | not consulted | the regex decision stands (the big majority of turns) |
If the model is off, slow, errors, or you have no AI key, the regex verdict stands unchanged — Stage 2 can only refine, never break, today's behavior. The model call is bounded (a few-second timeout, a concurrency cap, a per-process ceiling) and logged to your cost tracker (source = waiting_detector).
When the model layer goes dark, Omniscio now tells you (2026-06-09). The second opinion is silent when it can't run, so a sustained outage used to go unnoticed: if the AI key it uses stops working — e.g. a saved key is reset by a hardware or Windows change — every check quietly falls back to the regex with nothing surfaced (a live 2026-06-09 incident ran the layer at 100% fallback for a full day before anyone noticed). Omniscio now watches the durable adjudication log and, when the model has been unreachable across a sustained run of recent checks (a full window of fallbacks spanning ≥20 minutes — a single successful check clears it), raises ONE inbox alert — "the model double-check is offline" — with the plain remedy (re-add the key under Settings → Accounts; it clears itself once a usable key is back). It's conservatively thresholded so transient slowness under load never alerts, deduped so it can never stack, only relevant when you've turned the second opinion on, and bulletproof (it can never disturb the detector itself). Env kill switch: AMC_DISABLE_WAITING_MODEL_OUTAGE_ALERT=1. (The Settings → Diagnostics activity panel is not the surface — it's hidden for performance.)
The learning loop. Every Stage-2 consultation is recorded in the waiting_detector_adjudications table with its disposition (confirmed / caught-false-positive / caught-miss / fallback). A scheduled weekly Opus session mines the catches, drafts new regex patterns, back-tests them, and opens a PR — so the cheap regex grows and the model is consulted less over time. Runbook: docs/waiting-detector-weekly-evaluator.md. (The weekly schedule stays off until enough data accrues.)
Technical. The model runs in a fire-and-forget async finalizer (adjudicateProseWait) so it never blocks the synchronous turn-completion path; a transient agent_prose_wait_pending_model deferred-work kind holds the session running (no banner) until the model resolves, guarded by spawnEpoch/status staleness checks. Module: src/main/process/waiting-adjudicate.ts; contract invariants S1–S4 in .claude/memory/contracts/waiting-detector-contract.md.
For agents
How it works (technical)
The chokepoint
Every needs_you transition in Omniscio flows through updateStatus() in src/main/process/process-manager.ts. The primary site is inside handleTurnComplete() in src/main/process/ndjson-event-handlers.ts (the ProcessManager method at the same name is a thin delegate), which fires on every result event the Claude CLI emits at turn-end. The waiting detector is Gate 6 in a stack of seven suppression gates that all sit before that single updateStatus call.
| # | Gate | Outcome on hit |
|---|---|---|
| 1 | Auth error | Silent retry, no status change |
| 2 | Rate-limit recovery | Auto-switch account or defer 30 min |
| 3 | API error retry | Silent re-prompt |
| 4 | deferredWork set |
Arm timer, return |
| 5 | Auto-continue under cap | Inject "Please continue.", return |
| 5.5 | pendingSubagents.size > 0 |
Suppress (defense-in-depth); yields to the canonical sentinel → Gate 6 arms a durable hold (I4b) |
| 6 | Prose-pattern wait | Set deferredWork = agent_prose_wait, fall through to Gate 4 |
Only if all gates clear does handleTurnComplete() fire updateStatus(..., 'needs_you', ...). Gate 6 is a preprocessor: it doesn't return directly. It populates session.deferredWork, and the unified deferred-work block (Gate 4) then arms the 30-minute cap timer. This keeps every kind of deferred work (scheduled wakeup, background task, prose wait) flowing through one timer-arming branch instead of three parallel ones.
What counts as "the agent started background work" is ONE shared rule (isBackgroundDispatchTool, 2026-09-01): any tool whose input carries run_in_background: true (Bash/Agent/Task today, and any future harness tool with no code change), plus a small named set of tools that are inherently background and carry no flag — currently Monitor and Workflow. Before this, three separate recorders each hardcoded one tool NAME, so a background-capable tool stayed invisible until someone edited all three: that is how Workflow — the tool the deep research skill runs on — came to drop research sessions into the inbox with nothing to act on. ScheduleWakeup is deliberately outside the rule (it has its own kind and outranks a background task). One caveat carries over from Monitor: a dispatch the agent then works past in the same turn still surfaces, because the stale-flag gate deliberately drops a hold the agent moved beyond.
The Monitor harness watch tool is folded into the background task kind: a turn that ends by firing Monitor (watching a background job) arms the same cap as a run_in_background dispatch, so it stays quiet while the watch ticks. Because dispatching background work is itself an explicit "I'm waiting" signal (the same keep-alive class as the canonical waiting sentinel), the cap checks in with the agent — regardless of the Auto check-in toggle — rather than surfacing on the first window; it only lands in your inbox once the wait has genuinely gone idle (~2h) or run ~6h total, and then with the honest "Checked in with the agent periodically…" banner. (Changed 2026-08-16: previously a background-task wait surfaced un-nudged at the first cap even while its job was still running.)
The pattern library
Lives at src/main/process/waiting-patterns.ts. 70 high-confidence patterns + 1 medium-confidence pattern. Each entry carries match and noMatch example arrays that the unit suite reads directly — every pattern has at least one positive and one negative case. Categories:
- explicit-hold —
"I'm waiting","awaiting your merge","standing by","on hold until","paused until","blocked on","idling" - future-resume —
"I'll continue once tests pass","I'll continue as each reports"(bare-"as" distributive completion, 2026-06-08),"resuming when the build is green","the harness will auto-resume me"(external system resuming the agent) - proxy-notification —
"let me know when X"(medium-confidence; corroborating only in v1) - state-marker — third-person reports on background work:
"just two tests left"/"only the build remaining"(depletion shape) and"three gates still running"/"two subagents in flight"(count + work-noun + state-verb, added 2026-05-27) - temporal-anchor —
"until X happens","pending X","once X is done"
A negative lookbehind (?<!\b(?:was|were|been|had|have|having|previously)\s+) excludes past-tense forms so "I was waiting" and "had been awaiting" do NOT match.
Adding a new pattern is a four-step process documented at the top of waiting-patterns.ts: pick the smallest category that fits, choose high only if a positive match alone (in a short message) justifies suppression, add match and noMatch examples, re-run the analyzer for false-positive risk.
Related
The overview, the coverage of non-Claude engines and the everyday controls are on the parent page; part 2 covers the cap, the agent-stated ETA window, the Keep waiting and Check in buttons, the automatic check-ins, the inbox right-click menu and the confirmation probe; part 4 covers the ScheduleWakeup check-in mode and the rest of the technical reference.
Last verified 2026-10-05