Waiting detector (part 4)
Part 4 of the Waiting detector page: the ScheduleWakeup check-in mode an agent can opt into, then the rest of the technical reference — the three rejection guards, the live-corpus validation, every setting and kill switch, the subagent defense-in-depth gate and the idempotency and race protections.
What it is
This is part 4 of the Waiting detector page. It covers the timer side of the same wait decision — the ScheduleWakeup tool's opt-in check-in mode — and then the rest of the reference material for anyone with the code open: the three rejection guards that qualify a pattern match, the corpus validation behind the patterns, every setting and kill switch, the subagent defense-in-depth gate, and the guards against duplicate or racing timers.
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
ScheduleWakeup check-in mode (agent opt-in, sister feature)
A sibling of Gate 6, on the tool side of the wait classifier. The ScheduleWakeup tool (the explicit deferral path Gate 6 falls back from) accepts an opt-in checkIn: boolean field in its input. When the agent passes checkIn: true, the timer-fire reframes the re-injection so the agent treats it as a self-scheduled check-in — should I keep waiting? — rather than a fresh user-style instruction. The session stays running either way; the difference is in how the agent perceives the re-injection and in how the banner reads to you.
| Mode | Tool call | Banner | Re-injection at timer fire | Operator-row metadata.kind |
|---|---|---|---|---|
| Default (today's behavior) | ScheduleWakeup({delaySeconds, prompt}) or ScheduleWakeup({…, checkIn:false}) |
"Agent scheduled wake-up in N minutes — staying running until then." |
Agent's prompt injected verbatim as an operator message |
'schedule-wakeup' |
| Check-in | ScheduleWakeup({delaySeconds, prompt, checkIn:true}) |
"Agent scheduled check-in in N minutes — staying running, will not surface to inbox." |
Agent's prompt wrapped with the contract-locked [Check-in] framing prefix (decide: keep waiting, proceed, or end the turn to surface) |
'schedule-check-in' |
Live minutes (both modes). The banner shows human minutes, not raw seconds, and counts down live while the agent sleeps: the minutes-remaining is recomputed about every 30 seconds (so it reads "in 20 minutes", then "in 19 minutes", … down to "less than a minute"), letting you glance and see how long is left. Once the wake time passes the banner freezes to the original scheduled duration — it deliberately does NOT flip to "any moment now", so a banner that was superseded by the agent re-scheduling can't overclaim an imminent wake. Mechanics: the wording is produced by the shared formatScheduledWakeBanner helper (src/shared/scheduled-wake-format.ts); the backend stamps the absolute wake time (scheduledWakeAt) and mode into the message metadata; the renderer's ScheduledWakeBanner reads them and re-renders on a shared 30 s tick. Locked by contract invariant I10.
Scope. Check-in mode is exclusive to ScheduleWakeup — it is not auto-applied to Bash-background deferrals or Gate 6 prose-pattern waits. Those paths keep their pre-feature behavior. The checkIn field only lives on the schedule_wakeup arm of Session.deferredWork; no other deferred-work kind even has the field. (Contract invariant I7.)
When to use it (agent-side guidance). The default mode is right when the agent has scheduled work that should return to the user when the delay elapses (the prompt at fire-time is essentially the next user-style instruction). Check-in mode is right when the agent wants to re-evaluate an external condition silently — poll a queue, watch a CI run, or pace a /loop iteration — without surfacing to the inbox. Calling ScheduleWakeup again with checkIn: true from the check-in turn extends the wait by another delay window.
What the user sees. Two things: (1) the inline banner uses different wording so check-ins are visually distinct from wake-ups in the conversation log, and (2) the re-injection appears as a normal operator message whose first line starts with [Check-in] — that's the framing prefix the agent reads on re-entry. The session never flips to Needs You during a check-in cycle; only an end-of-turn that doesn't re-arm a check-in will surface.
Kill switch. There isn't one. The flag is opt-in per ScheduleWakeup call; the agent simply not setting checkIn: true is the off state. If the framing prefix ever needs to change, it's the single constant CHECK_IN_FRAMING_PREFIX in ndjson-handler-constants.ts, contract-locked by I7.
For agents
How it works (technical)
Three rejection guards (added 2026-05-24)
The pattern regex match alone is not enough to suppress an inbox flip. detectSuppressibleWait in src/main/process/waiting-patterns.ts — Gate 6's only entry point — composes three independent rejection guards on top of the pattern match. A suppressible wait must clear ALL three; any single failure short-circuits and the turn falls through to Needs You. The "fail on multiple fronts" composition is intentional: the regexes are noisy on long substantive prose, the user wanted matches to clear more than one independent bar.
Length — for a single-paragraph message, raw
lastAssistantText.length ≤ 2,000characters (MAX_WAITING_MESSAGE_LENGTH, raised from 500 on 2026-05-26 after a live-DB back-test). Measured against the RAW assistant text, before fence-stripping, so a long message bulked out by a quoted code block stays long. Genuine wait declarations are terse — a status line or two, sometimes with a one-sentence summary of what was dispatched, hence the 2,000-char headroom. As of 2026-06-06 this cap is single-paragraph-only — a multi-paragraph message is bounded by the per-boundary 400-char cap in the Shape guard instead, so a long status report that ENDS on a terse handoff still reaches the carve-out rather than being hard-rejected on total length.Shape — single paragraph after
stripCodeFences+trim, OR multi-paragraph with a clean trailing-wait paragraph.isMultiParagraph(prose)looks for a blank-line break (\n[whitespace]*\n). When the message is multi-paragraph, the trailing-wait carve-out (added 2026-05-26) extracts the final paragraph viaextractFinalParagraph(stripped)and runs the pattern match against the final paragraph alone — admitting the message iff that final paragraph is ≤ 400 chars (MAX_TRAILING_PARAGRAPH_LENGTH) AND carries a high-confidence pattern hit. Long-paragraph-tail carve-out (2026-06-04): when that closing paragraph is itself longer than 400 chars, its trailing whole sentence(s) up to 400 chars are scanned (viaextractTrailingSentences) instead of dropping the whole paragraph — so a wait that closes a long status paragraph ("…stamp the SHA-bound tag. Holding for the build result.") still suppresses, while a wait buried mid-paragraph does not. Long-leading-paragraph-tail carve-out (2026-06-08): the same tail-extraction is applied to the FIRST paragraph too — when a multi-paragraph turn's opening paragraph is itself longer than 400 chars, its trailing sentence(s) up to 400 chars are scanned (the sametrailingCandidatehelper the closing paragraph uses) rather than dropping it wholesale, still gated by the leading-paragraph hand-off check. So a wait that closes a long opening status paragraph ("…40 voice tests green. I'll wait for the lint result; I'll be notified when it completes." — a 489-char opener) suppresses, while an opening status headline with no hand-off still flips to Needs You and a wait buried early in the opening paragraph (wait-free ending) is not scanned. Read-only back-test over 14,450 live agent rows: +6 net-new, 0 regressions, 0 false positives. Long-message forward-commit carve-out (2026-06-06): when the WHOLE message is over the 2,000-char cap (the Length guard above no longer hard-rejects a multi-paragraph message), the final paragraph must ALSO be an explicit handoff —hasPollAvoidanceCommitmentOR a non-attention-grabhasForwardCommitment, the SAME gate the leading paragraph uses — not merely any high-confidence wait, so a long answer ending on a broad-pattern phrase ("…was primarily waiting for the index") does not suppress while "I'll report back when the build lands" does; a≤ 2,000-char multi-paragraph message keeps the unconditional trailing carve-out. Penultimate carve-out (2026-06-07): the candidate set also includes the second-to-last paragraph (extractPenultimateParagraph) when itcommitsToHandoff(poll-avoidance OR non-attention-grab forward commitment) AND is ≤ 400 chars AND the FULL message carries noLEADING_ATTENTION_GRABalert — recovering the "wait, then trailing status-dashboard paragraph" shape (the newwill-resume-me-activepattern is the forward commitment that admits it; the matcher underfindFirstMatchstayswaiting-for-on-any). Other interior paragraphs are still never scanned; the full-message attention-grab gate is stricter than the per-paragraph one because an alert can sit in a different paragraph than the penultimate. Read-only back-test 2026-06-07 (13,709 agentsummary_proserows): +30 net-new / 0 regressions, with 1 false positive found & fixed by that full-message gate. The Target (user-directed) veto, however, runs against the FULL message, not the final paragraph (veto-scope fix 2026-05-27): a wait directed at you can sit in an earlier paragraph ("…a gated discard I won't run without your explicit OK.") under a terse, generic close ("Standing by.") that matches a hold pattern, and scoping the veto to the final paragraph alone wrongly auto-waited exactly that shape. This carve-out is what handles the common shape "<summary paragraphs> ... Waiting for X."Target — not directed at the user.
isUserDirectedWait(prose)is a six-regexOR: it matches any wait/await/hold/blocked/until/once phrasing whose object is the user (USER_DIRECTED_WAIT), imperatives of the form "Tell me when X" / "let me know when X" / "ping me when X" (IMPERATIVE_TO_USER_WAIT— also fires after hyphen list-leaders), explicit waits from the user / operator / human (WAIT_X_FROM_USER), user-decision idioms like "Your call" / "your move" / "your nod" / "your sign-off" / "your go-ahead" (USER_DECISION_PHRASE), mid-paragraph agent-asking-user questions like "Want me to … or …?" / "Should I … ?" (AGENT_ASKING_USER— a bandaid for the end-of-textisTextAskingQuestiondetector's blind spot), and noun-object input-waits like "awaiting your sign-off" (WAIT_ON_USER_INPUT_NOUN). Any one regex matching vetoes the suppression. A wait on a human action IS a real Needs You (it's exactly what the inbox is for). Auto-notify exemption (2026-06-01): before those six regexes run, the gate blanks any declarative auto-notification clause whose "when you …" names an ambient availability action — "It'll auto-notify me when you open the window", "I'll be notified when you log in", "the watcher will ping me when you're back". The agent armed an automated re-wake (a watcher / hook will re-engage it once you become reachable), so that "when you" is the trigger of automation, not a wait on a human decision — the inbox shouldn't nag you. It's fenced both ways so a real Needs You is never hidden: a bare imperative "Notify me when you…" (you must ping the agent) still vetoes, and a decision/input verb "once you approve / confirm / decide / reply" stays a real Needs You — only ambient becomes-available actions are exempt. Release-of-control exemption (2026-06-07): the gate ALSO blanks a release clause where the agent hands a FUTURE git action to you — "merge stays your call", "Nothing touches master until you merge" — while it keeps waiting on an external gate ("I'll report the moment CI lands"). The eventual merge being your call is not a wait on a human decision now, so the inbox shouldn't flip. Fenced the same way: the handed-off action must be a git/release verb (merge/push/deploy/land/ship/…, never approve/confirm/decide/review), Arm A needs the "stays/remains your call" idiom (so "it's your call now" still vetoes), Arm B needs the "nothing … until you …" reassurance (so the BLOCK form "I won't continue until you merge" still vetoes), only the matched span is blanked (a real ask beside it still vetoes), and — belt-and-suspenders — a high-confidence external-wait pattern must STILL match for the turn to suppress at all (RELEASE_OF_CONTROL; live screenshot 2026-06-07; read-only back-test +1 net-new / 0 regressions across ~57K agent rows). Approve-a-list exemption (2026-06-07): the gate ALSO blanks a "nothing … until you approve … a list" reassurance — the agent promising it won't act destructively until you approve a SET it will present later (the worktree-cleanup screenshot "nothing is touched until you approve a specific list", whose turn also said "I'll be notified when it completes") — while it keeps waiting on a background job. That eventual approval is a future safety gate, not a wait on you now. The sibling release-of-control exemption deliberately leaves "approve" out of its verb list (because bare "until you approve" is a real Needs You), so this gets its own tightly-fenced clause: it requires BOTH the "nothing …" lead AND a list-noun after "approve" (list/selection/plan/set/batch/item/candidate), so a bare "until you approve", a no-"nothing" wait ("Waiting until you approve the list"), or "approve the deploy/this" still vetoes; only the matched span is blanked (a real ask like "your call" beside it still vetoes); and — belt-and-suspenders — a high-confidence external-wait pattern must STILL match to suppress at all (APPROVE_LIST_RELEASE; live screenshot 2026-06-07; read-only back-test +2 net-new / 0 regressions, 0 false positives across ~58K agent rows).
The 2026-05-24 trigger was a long handoff summary the agent posted: "Not done (waiting on your call): — Push branch / open PR — needs explicit approval — …". Before the guards, the waiting-for-on-any pattern matched "waiting on your call" and Gate 6 suppressed the inbox flip. With the guards, the same message is vetoed because the target guard fires on "your call" (now matched by USER_DECISION_PHRASE) — the user-directed phrasing is enough by itself, regardless of paragraph count. (At the time it also tripped the length guard at the old 500-char cap; after the 2026-05-26 raise to 2,000 it no longer does, and the trailing-wait carve-out means the shape guard alone is no longer sufficient — but the target guard still catches it cleanly, which is exactly why the target veto was broadened from 3 regexes to 6 on 2026-05-26 rather than relaxed.)
The pattern regexes themselves were left untouched (the regex still matches "Waiting on your pick" and "awaiting your merge" — the veto lives at the gate, not in the regex), but the match arrays in waiting-patterns.ts no longer carry user-directed examples since the gate now vetoes them. Pattern unit tests cover regex behavior; gate tests cover composed behavior. If you see a suppression (an ⏱ Auto-waited tag) where the matched phrase IS user-directed, that's a bug — file it with the session id, rule id, and matched substring.
Corpus validation
The pattern library was validated against the user's mission-control.db on 2026-05-23:
- 36,903 agent messages scanned (alive sessions only)
- 2,335 total pattern hits (2,170 high-confidence)
- 1,277 distinct sessions hit (1,193 high-confidence) — roughly 25.5% of alive sessions historically hit at least one pattern
- Top pattern:
waiting-for-on-anywith 1,036 hits - Live bug repro (session
bd64ec4e,"Three checks running in parallel — waiting for completion notifications"): hitswaiting-for-on-anyon"waiting for completion"✓
Follow-up delta-validation (2026-05-27) for the stop-polling + reinvoke-me-automatically additions: baseline-vs-current diff over 24,160 agent prose rows, baseline 908 → current 912 suppressed, +4 net-new (+2 each, 0 FP). Harness: scripts/waiting-detector-backtest-stop-polling-reinvoke.mjs.
Follow-up delta-validation (2026-06-08) for the will-continue-as-distributive-completes addition (the bare-"as" distributive-completion resume, e.g. "I'll continue as each reports" — a first-person future resume joined by a bare "as" to a job-completion clause, the connector will-continue-when-once-after excludes): baseline-vs-current diff over 14,459 agent prose rows, +8 net-new / 0 FP — every net-new row a genuine background-job wait, including the originating screenshot. Harness: scripts/waiting-detector-backtest-as-each-completes.mjs.
Settings + kill switches
- Setting (Zod-validated):
AppSettings.waitingDetectorEnabled(defaulttruesince 2026-05-27 — flipped on after the rejection-guard + pattern tuning work through 2026-05-26 closed the false-positive classes seen in live-DB back-tests; history: shippedtrue2026-05-23 →false2026-05-23 →true2026-05-27) — defined in src/shared/types.ts and src/shared/ipc-schemas.ts. UI toggle at Settings → Sessions,data-setting-id="waiting-detector". - Env override:
AMC_DISABLE_WAITING_DETECTOR=1— read once at startup, short-circuits Gate 6. - Flag service: src/main/services/waiting/waiting-detector-flag.ts —
isWaitingDetectorEnabled(settings)combines both;isWaitingDetectorModelEnabled(settings)gates the Stage-2 model layer (below). - Stage-2 model setting:
AppSettings.waitingDetectorModelEnabled(defaultfalse, opt-in) — UI toggle at Settings → Sessions,data-setting-id="waiting-detector-model"; env overrideAMC_DISABLE_WAITING_MODEL=1. See AI second opinion (Stage 2). - Match-mode setting (high-precision tier vs full corpus):
AppSettings.waitingDetectorCorpusEnabled(defaultfalsesince 2026-07-21 = the high-precision tier) — UI toggle at Settings → Sessions,data-setting-id="waiting-detector-match-mode"("Recognize all waiting phrasings"); defined in chat-ui-settings.ts + its Zod slice; read viaisWaitingDetectorCorpusEnabled(settings)(?? false, NO env override — it's a precision/recall preference, not a rollback). At the defaultfalse,classifyWaitruns the curatedHIGH_PRECISION_PATTERN_IDStier — the canonical sentinel PLUS its paraphrases and a ~99%-precision subset (will-continue-when-once-after,waiting-on-ci-noun, and the audit-clean families likewont-poll/standing-by/ci-still-running) — through the same length/shape/veto guards, quote-stripped, but NO broad pattern. Attrueit ALSO matches the full ~40-pattern corpus + the Stage-2 model near-miss path (higher recall, more misfires). The canonical-sentinel hard override, the stall-path honor, and the Plain Speak overlay-skip fire in both modes; the default flip needs no migration (getSettings()is raw, so?? falsereaches every unset user). See waiting-detector-contract.md invariantmatch-mode-selects-the-tier-and-the-sentinel-always-holds. - Hide-repeated-auto-waits setting (render-layer):
AppSettings.hideAutoWaitedUnlessFinal(defaultfalse, opt-in) — UI toggle at Settings → Sessions,data-setting-id="hide-auto-waited-unless-final"; defined in chat-ui-settings.ts + its Zod slice. PURE render-layer collapse of the repeated ⏱ turns inTurnGroup(keyed off the already-liftedturn.waitingDetectorMetadata; gatedhideAutoWaitedUnlessFinal && nextTurnIsContinuation && !isInFlight, keeping the NEWEST wait of EACH run visible, where only a message the USER owns (isRealUserReply) ends a run — another agent's message or a scheduled wake does not (2026-09-22) — a self-resume wait folds whole, a wait fused to a user message folds ONLY the agent reply and keeps the user's own bubble) — no detector/banner/lift change, NOTURNS_CACHE_BUILDER_VERSIONbump. Read LIVE; no env override. See waiting-detector-contract.md "Collapsing repeated auto-waits". - Auto-wait period (Zod-validated, user-configurable):
AppSettings.autoWaitMinutes(default30, range5–240) — the user-set cap that BOTH the prose-wait and the background-task timers arm. Defined in chat-ui-settings.ts + its Zod update slice; UI toggle at Settings → Sessions,data-setting-id="auto-wait-minutes". Every arm site reads it through the single safe chokepointresolveAutoWaitMinutes(raw)insrc/main/process/ndjson-handler-constants.ts— falls back toAUTO_WAIT_DEFAULT_MINUTES = 30and clamps to[5, 240], so a missing/non-finite value can never passNaNtosetTimeout(which would fire immediately). The resolved minutes is captured at arm time and reused for the banner text, so the displayed window always matches the armed timer. - Keep-waiting button duration (Zod-validated, user-configurable):
AppSettings.keepWaitingMinutes(default60, range5–240) — the SEPARATE window the inbox "Keep waiting" button re-arms, distinct fromautoWaitMinutes. Defined in chat-ui-settings.ts + its Zod update slice; UI at Settings → Sessions,data-setting-id="keep-waiting-minutes". Read throughresolveKeepWaitingMinutes(raw)(a sibling wrapper over the sharedclampWaitMinutes, floors to 60). OnlymanualResetWaitTimer+ the button label use it; everything else stays onautoWaitMinutes. - Auto check-in (Zod-validated, on by default):
AppSettings.autoNudgeEnabled(defaulttrue; UI label "Auto check-in on waiting sessions" — the persisted key, flag,data-setting-id, and env switch all stay*auto-nudge*for back-compat, only the visible label is "Auto check-in", à la the KMS/Nothari split) — when on, the auto-wait cap auto-nudges the agent (up toAUTO_NUDGE_MAX_CONSECUTIVE = 3times) before surfacing. UI at Settings → Sessions,data-setting-id="auto-nudge"; gateisAutoNudgeEnabled(settings)in waiting-detector-flag.ts; env overrideAMC_DISABLE_AUTO_NUDGE=1. - Inbox auto-check-in (Zod-validated, on by default):
AppSettings.inboxAutoCheckInEnabled(defaulttrue; UI label "Also check in after it's in your inbox") +AppSettings.inboxAutoCheckInMultiple(default2, range1–10; UI label "Check in after (multiple of the waiting period)") — the POST-surface mirror of auto-nudge: when on, a session that surfaced towait_timeoutand sat untouched in the inbox forinboxAutoCheckInMultiple × autoWaitMinutesminutes is auto-checked-in (resume-capable send viahost.sendInboxCheckIn→sessionService.sendResponse), bounded byAUTO_INBOX_CHECKIN_MAX = 3. Defined in chat-ui-settings.ts + its Zod update slice; UI at Settings → Sessions,data-setting-id="inbox-auto-check-in"/inbox-auto-check-in-multiple; gateisAutoInboxCheckInEnabled(settings)in waiting-detector-flag.ts; the multiple resolves throughresolveInboxCheckInMultiple(raw)(ndjson-handler-constants.ts, clamps[1,10], NaN-defends thesetTimeoutmath); env overrideAMC_DISABLE_AUTO_INBOX_CHECKIN=1. Distinct fromautoNudgeEnabled(the pre-inbox auto-nudge). See contract invariant I17. - At the cap:
surfaceWaitTimeout()inndjson-event-handlers.ts(via the prose-wait cluster) flips the session to Needs You with thewait_timeoutpendingAction. With auto-nudge off it no longer re-injects a nudge prompt or re-arms the timer — that unbounded re-arm was the bug fixed 2026-06-09. With auto-nudge ON it instead sends the bounded check-in (≤3×) and holds, surfacing only once the budget is spent. (The same applies to the background-task cap path.) WheninboxAutoCheckInEnabledis ON, AFTER the surface it also arms the post-surface inbox auto-check-in timer (see above).
Gate 5.5 — the pendingSubagents defense-in-depth
Sits just before Gate 6. When a Task or Agent tool dispatches a subagent, Omniscio records the entry in session.pendingSubagents. The matching tool_result event drains it. Per the contract invariant I4, the set should always be empty at turn-complete because Task-tool subagents are synchronous — they block the parent's turn until they return their result. If the set is non-empty at turn-complete, that signals an accounting bug (missed tool_result, drift), not a routine wait.
Gate 5.5 suppresses the flip when this happens: pendingSubagents.size > 0 AND pendingAction === null → suppress, no timer (the stall watchdog is the escape hatch). Independent of waitingDetectorEnabled — this guards an accounting-bug class, not a prose-detection heuristic, so the kill switch doesn't disable it.
Idempotency + race protection
- Wakeup-timer guard:
if (work.wakeupTimer) returnat the top of the deferred-work block. Duplicateresultevents (Liveness Phase 1a) cannot re-arm a timer or re-emit the banner. - RACE-05 protection: the cap-timer callback short-circuits if
current.spawnEpoch !== capturedEpoch(session was rotated/respawned) or ifcurrent.status !== 'running'(status already changed by some other path). - Code-fence stripping:
stripWaitingCodeFences(lastText)runs before the pattern scan so a fenced code block containing the word"waiting"can't trigger the detector. Real waiting language outside the fence still matches.
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 3 covers what the detector deliberately never does, the optional AI second opinion, and the first half of the technical reference.
Last verified 2026-10-05