feat(chat): configurable dialog auto-dismiss window — stop claude's 60s picker timeout from killing manual-run questions #124

Closed
opened 2026-07-10 22:30:42 +02:00 by dominik.polakovics · 2 comments

Problem

Claude Code (2.1.198) self-resolves an undriven AskUserQuestion/ExitPlanMode picker after 60s with answers:{} + afkTimeoutMs:60000 ("No response after 60s — the user may be away from keyboard. Proceed using your best judgment…"). Already pinned in compat.md §5/§7 (live-verified 2026-07-08). Since the needs-input push (#99) landed, the operator's answer path is a phone round-trip that routinely exceeds 60s, so questions in manual sessions are gone before the operator arrives — observed live 2026-07-10: 5 of 15 grill questions self-resolved at exactly +60s (sessions aa8e1ff3, c3c51f00). An answer sent after the timeout 409s on the tool_id re-read.

The timeout is overridable: the CLI reads integer env vars CLAUDE_AFK_TIMEOUT_MS (default 60000, bundle constant lZe) and CLAUDE_AFK_COUNTDOWN_MS (default 20000). Undocumented upstream → fragile coupling, compat discipline applies.

Decisions (grill 2026-07-10)

  1. Scope: manual runs only. AFK runs keep upstream 60s auto-advance — an unattended run blocked on a picker just burns its budget clock; "proceed on best judgment" is a feature there.
  2. Default: effectively no timeout. When the setting is absent/0, manual sessions get CLAUDE_AFK_TIMEOUT_MS=2147483647 (2^31−1 ms ≈ 24.8 days — see hazard below). In a manual session auto-advance has no beneficiary; a picker should wait like a normal terminal.
  3. Surface: DB settings row + Settings UI. Key dialog_timeout_minutes, integer minutes, min 0, not seeded (absent = 0 = never auto-dismiss). 1 restores upstream's 60s. Number field in the Spawn defaults card: label "Dialog auto-dismiss (minutes)", hint: 0 = never; applies to manual sessions at next spawn (running sessions keep their spawn-time value). Follows the typed-int settings precedent (afk_budget_minutes etc., PATCH validation with min 0).
  4. Mechanism: settings-file env block, adapter-owned. Extend provider.LiveSignals.Setup with an opts param (e.g. Setup(runID, dir string, opts SetupOpts)) carrying the resolved dialog timeout for manual runs; the claudecode adapter emits "env": {"CLAUDE_AFK_TIMEOUT_MS": "<ms>"} in the per-run --settings file it already writes (dialogspool.go), next to the hook couplings. internal/instance/launch.go (armDialogHooks) resolves the setting and passes opts. AFK runs pass no timeout → no env entry → upstream default. Codex: opts ignored (no equivalent knob). CLAUDE_AFK_COUNTDOWN_MS stays untouched (TUI display only).
  5. Naming: deliberately not "afk" in the settings key — in lab vocabulary AFK means unattended runs; this knob applies to manual runs.

Step 1 — live spike (do this FIRST)

Two upstream behaviors are unverified; prove both with the tmux dialog rig (compat live-recipes precedent, #64) before wiring anything:

  1. settings.env reaches the picker timer: spawn a real claude session with a --settings file whose env block sets CLAUDE_AFK_TIMEOUT_MS=5000, open an AskUserQuestion picker, verify it self-resolves at ~5s (transcript afkTimeoutMs:5000).
  2. 2^31−1 holds: same rig with 2147483647, verify the picker does NOT fire early (JS setTimeout above 2^31−1 fires immediately — if claude computes a deadline instead, huge values are safe; the cap keeps us under the hazard either way). Hold the picker well past 60s to prove the default is defeated.

Pre-authorized fallback: if settings.env does NOT reach the timer, pivot to spawn env: a small provider env-contribution seam (e.g. optional SpawnEnv(spec) []string applied in instance spawnEnv → tmux new-session -e), keeping the claude-named variable inside the adapter. No round-trip to the operator needed; record the spike result on this issue either way.

Also required

  • Pin the coupling in compat.md §9 (env var names, defaults 60000/20000, the 2^31−1 cap rationale, settings-env delivery path) + add the spike to the Tier-2 live checklist (ADR-0036).
  • Hermetic tests: Setup emits the env block only when a timeout is passed (manual) and omits it otherwise (AFK); settings PATCH validation for dialog_timeout_minutes (min 0, unknown-key rejection untouched); Settings.tsx renders + saves the field.
  • Conformance suite (#80): if the fallback seam is taken, add the Tier-1 check for it; the settings-env route needs no seam conformance change beyond the Setup signature.

Acceptance

  • Manual session spawned with the setting absent: picker still pending after >60s, answer from the web chat lands (no 409).
  • dialog_timeout_minutes=1: picker self-resolves at ~60s (upstream parity via our env).
  • AFK runs: transcript shows upstream 60s auto-advance unchanged.
  • compat.md pinned; spike transcript/results recorded on this issue.
## Problem Claude Code (2.1.198) self-resolves an undriven AskUserQuestion/ExitPlanMode picker after **60s** with `answers:{}` + `afkTimeoutMs:60000` ("No response after 60s — the user may be away from keyboard. Proceed using your best judgment…"). Already pinned in compat.md §5/§7 (live-verified 2026-07-08). Since the needs-input push (#99) landed, the operator's answer path is a phone round-trip that routinely exceeds 60s, so questions in manual sessions are gone before the operator arrives — observed live 2026-07-10: 5 of 15 grill questions self-resolved at exactly +60s (sessions aa8e1ff3, c3c51f00). An answer sent after the timeout 409s on the tool_id re-read. The timeout is overridable: the CLI reads integer env vars `CLAUDE_AFK_TIMEOUT_MS` (default 60000, bundle constant `lZe`) and `CLAUDE_AFK_COUNTDOWN_MS` (default 20000). Undocumented upstream → fragile coupling, compat discipline applies. ## Decisions (grill 2026-07-10) 1. **Scope: manual runs only.** AFK runs keep upstream 60s auto-advance — an unattended run blocked on a picker just burns its budget clock; "proceed on best judgment" is a feature there. 2. **Default: effectively no timeout.** When the setting is absent/0, manual sessions get `CLAUDE_AFK_TIMEOUT_MS=2147483647` (2^31−1 ms ≈ 24.8 days — see hazard below). In a manual session auto-advance has no beneficiary; a picker should wait like a normal terminal. 3. **Surface: DB settings row + Settings UI.** Key `dialog_timeout_minutes`, integer minutes, min 0, **not seeded** (absent = 0 = never auto-dismiss). `1` restores upstream's 60s. Number field in the **Spawn defaults** card: label "Dialog auto-dismiss (minutes)", hint: 0 = never; applies to manual sessions at next spawn (running sessions keep their spawn-time value). Follows the typed-int settings precedent (`afk_budget_minutes` etc., PATCH validation with min 0). 4. **Mechanism: settings-file env block, adapter-owned.** Extend `provider.LiveSignals.Setup` with an opts param (e.g. `Setup(runID, dir string, opts SetupOpts)`) carrying the resolved dialog timeout for manual runs; the claudecode adapter emits `"env": {"CLAUDE_AFK_TIMEOUT_MS": "<ms>"}` in the per-run `--settings` file it already writes (dialogspool.go), next to the hook couplings. `internal/instance/launch.go` (armDialogHooks) resolves the setting and passes opts. AFK runs pass no timeout → no env entry → upstream default. Codex: opts ignored (no equivalent knob). `CLAUDE_AFK_COUNTDOWN_MS` stays untouched (TUI display only). 5. **Naming:** deliberately *not* "afk" in the settings key — in lab vocabulary AFK means unattended runs; this knob applies to manual runs. ## Step 1 — live spike (do this FIRST) Two upstream behaviors are unverified; prove both with the tmux dialog rig (compat live-recipes precedent, #64) before wiring anything: 1. **settings.env reaches the picker timer**: spawn a real claude session with a `--settings` file whose env block sets `CLAUDE_AFK_TIMEOUT_MS=5000`, open an AskUserQuestion picker, verify it self-resolves at ~5s (transcript `afkTimeoutMs:5000`). 2. **2^31−1 holds**: same rig with `2147483647`, verify the picker does NOT fire early (JS setTimeout above 2^31−1 fires immediately — if claude computes a deadline instead, huge values are safe; the cap keeps us under the hazard either way). Hold the picker well past 60s to prove the default is defeated. **Pre-authorized fallback:** if settings.env does NOT reach the timer, pivot to spawn env: a small provider env-contribution seam (e.g. optional `SpawnEnv(spec) []string` applied in instance spawnEnv → `tmux new-session -e`), keeping the claude-named variable inside the adapter. No round-trip to the operator needed; record the spike result on this issue either way. ## Also required - Pin the coupling in compat.md §9 (env var names, defaults 60000/20000, the 2^31−1 cap rationale, settings-env delivery path) + add the spike to the Tier-2 live checklist (ADR-0036). - Hermetic tests: Setup emits the env block only when a timeout is passed (manual) and omits it otherwise (AFK); settings PATCH validation for `dialog_timeout_minutes` (min 0, unknown-key rejection untouched); Settings.tsx renders + saves the field. - Conformance suite (#80): if the fallback seam is taken, add the Tier-1 check for it; the settings-env route needs no seam conformance change beyond the Setup signature. ## Acceptance - Manual session spawned with the setting absent: picker still pending after >60s, answer from the web chat lands (no 409). - `dialog_timeout_minutes=1`: picker self-resolves at ~60s (upstream parity via our env). - AFK runs: transcript shows upstream 60s auto-advance unchanged. - compat.md pinned; spike transcript/results recorded on this issue.
Author
Owner

Step-1 live spike results (2026-07-10, claude-code 2.1.198) — both hypotheses CONFIRMED

Rig: tmux private socket, throwaway scratch dirs, claude --model haiku --settings <file>, trust dialog accepted interactively, picker opened via an AskUserQuestion prompt, transcript = ground truth (~/.claude/projects/<slug>/<sessionId>.jsonl).

H1 — settings.env reaches the picker timer: CONFIRMED. Settings file {"env":{"CLAUDE_AFK_TIMEOUT_MS":"5000"}}; the undriven picker self-resolved +5.033s after the tool_use (tool_use 2026-07-10T20:41:22.287Z → timeout event 20:41:27.320Z). Transcript timeout event verbatim (trimmed): toolUseResult = {"questions":[…],"answers":{},"annotations":{},"afkTimeoutMs":5000}, tool_result content: "No response after 5s — the user may be away from keyboard. Proceed using your best judgment based on the context so far; you can re-ask this question later if it's still relevant."

H2 — 2147483647 holds: CONFIRMED. Settings file {"env":{"CLAUDE_AFK_TIMEOUT_MS":"2147483647"}}; picker still rendered, byte-identical pane captures at +30s/+62s/+92s, transcript contains zero afkTimeoutMs lines. No immediate-fire 2^31 hazard on 2.1.198, and the 60s default is defeated well past a minute.

Extra pins for compat:

  • The timeout message text scales with the value ("No response after 5s — …") — nothing may string-match "after 60s".
  • The picker renders no countdown UI at all; the timeout is invisible until it fires (footer stays the normal "Enter to select · ↑/↓ to navigate · Esc to cancel").
  • Transcript afkTimeoutMs echoes the effective ms value (5000, not 60000). lab's readers (dialogintent.go, dialogoutcome.go) check > 0 / answers:{}, never == 60000 — verified safe, no change needed.

Consequence: the primary mechanism ships — CLAUDE_AFK_TIMEOUT_MS in the per-run --settings file's "env" block (same file as the §9 hooks), emitted by the claudecode adapter behind provider.LiveSignals.Setup opts. The pre-authorized spawn-env fallback is not needed.

One correction to the issue text: compat.md §9 is already occupied (dialog-capture hook contract) and §10 exists too — the new pin lands as §11, with cross-refs from the §5/§7 timeout bullets.

## Step-1 live spike results (2026-07-10, claude-code 2.1.198) — both hypotheses CONFIRMED Rig: tmux private socket, throwaway scratch dirs, `claude --model haiku --settings <file>`, trust dialog accepted interactively, picker opened via an AskUserQuestion prompt, transcript = ground truth (`~/.claude/projects/<slug>/<sessionId>.jsonl`). **H1 — settings.env reaches the picker timer: CONFIRMED.** Settings file `{"env":{"CLAUDE_AFK_TIMEOUT_MS":"5000"}}`; the undriven picker self-resolved **+5.033s** after the tool_use (tool_use `2026-07-10T20:41:22.287Z` → timeout event `20:41:27.320Z`). Transcript timeout event verbatim (trimmed): `toolUseResult = {"questions":[…],"answers":{},"annotations":{},"afkTimeoutMs":5000}`, tool_result content: "No response after 5s — the user may be away from keyboard. Proceed using your best judgment based on the context so far; you can re-ask this question later if it's still relevant." **H2 — 2147483647 holds: CONFIRMED.** Settings file `{"env":{"CLAUDE_AFK_TIMEOUT_MS":"2147483647"}}`; picker still rendered, byte-identical pane captures at +30s/+62s/+92s, transcript contains **zero** `afkTimeoutMs` lines. No immediate-fire 2^31 hazard on 2.1.198, and the 60s default is defeated well past a minute. **Extra pins for compat:** - The timeout message text **scales with the value** ("No response after **5s** — …") — nothing may string-match "after 60s". - The picker renders **no countdown UI** at all; the timeout is invisible until it fires (footer stays the normal "Enter to select · ↑/↓ to navigate · Esc to cancel"). - Transcript `afkTimeoutMs` echoes the *effective* ms value (5000, not 60000). lab's readers (`dialogintent.go`, `dialogoutcome.go`) check `> 0` / `answers:{}`, never `== 60000` — verified safe, no change needed. **Consequence:** the primary mechanism ships — `CLAUDE_AFK_TIMEOUT_MS` in the per-run `--settings` file's `"env"` block (same file as the §9 hooks), emitted by the claudecode adapter behind `provider.LiveSignals.Setup` opts. The pre-authorized spawn-env fallback is **not needed**. One correction to the issue text: compat.md **§9 is already occupied** (dialog-capture hook contract) and §10 exists too — the new pin lands as **§11**, with cross-refs from the §5/§7 timeout bullets.
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #125 → PASS.

  • CI: ci / native (pull_request) success (5m15s, run #129). Relied on; not re-run.
  • Mergeable: main is an ancestor of afk/124 — no conflict, clean merge.
  • Title: Conventional Commits (feat(chat): …).
  • AFK contract: head afk/124, valid Closes #124.
  • Diff review (16 files, +517/−45):
    • Seam SetupOpts.DialogTimeout; AFK passes 0 → settings file byte-identical to hooks-only payload.
    • dialogTimeout(): manual = dialog_timeout_minutes (0/absent = effectively-never 2^31−1 ms), AFK = 0. Never-sentinel matches the adapter clamp maxDialogTimeout; no overflow.
    • Adapter clamps CLAUDE_AFK_TIMEOUT_MS to 2^31−1 ms (JS setTimeout instant-fire hazard), rationale pinned at the clamp + compat §11.
    • Settings floor-0 for the one key where 0 is meaningful; web field-split refactor behavior-preserving.

Verdict: PASS. Awaiting free-text merge go-ahead.

> *This was generated by AI while landing a PR.* **Landing audit — PR #125 → PASS.** - **CI**: `ci / native (pull_request)` success (5m15s, run #129). Relied on; not re-run. - **Mergeable**: `main` is an ancestor of `afk/124` — no conflict, clean merge. - **Title**: Conventional Commits (`feat(chat): …`). - **AFK contract**: head `afk/124`, valid `Closes #124`. - **Diff review** (16 files, +517/−45): - Seam `SetupOpts.DialogTimeout`; AFK passes 0 → settings file byte-identical to hooks-only payload. - `dialogTimeout()`: manual = `dialog_timeout_minutes` (0/absent = effectively-never `2^31−1 ms`), AFK = 0. Never-sentinel matches the adapter clamp `maxDialogTimeout`; no overflow. - Adapter clamps `CLAUDE_AFK_TIMEOUT_MS` to `2^31−1 ms` (JS setTimeout instant-fire hazard), rationale pinned at the clamp + compat §11. - Settings floor-0 for the one key where 0 is meaningful; web field-split refactor behavior-preserving. Verdict: **PASS**. Awaiting free-text merge go-ahead.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#124
No description provided.