Skip to content
C.W.K.
Stream
Lesson 05 of 07 · published

A Chat Waiting on a Person

~16 min · human-in-the-loop, observability, permissions, coordination

Level 0Loose Note
0 XP0/41 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Nine and a Half Hours on a Prompt Nobody Saw

On 2026-10-15 Coop started a Claude Code worker in the background for todo #460, a change to Dad's browser app. Its first Write met a guard that Claude Code 2.1.301 had just added for background sessions: in a checkout other sessions may share, a background chat must isolate itself before its first edit. The worker did what the guard suggested. It made a git worktree under its own job folder and called EnterWorktree on it.

That call needs a person's permission. A background chat asks for it the only way it can, by stopping at a prompt and waiting. Remote Control, the door Dad uses to reach such a chat from his phone, never showed the prompt. So the chat sat there for nine and a half hours. Coop knew the whole time that something was wrong: its activity reading said needs_input. That was all it knew. The chief's escalation could only tell Dad to go and look, and "go and look" from a phone in another country is not an instruction anyone can follow.

Two different failures hid in that one incident. Coop could have known the answer to the question in advance, and did not give it. And when a chat truly does need a person, Coop could see that it waited, but not on what. This lesson is the repair of both, plus a third failure the repair itself caused the next day.

Answer the Question You Already Know

Should a Coop worker work in a separate worktree? Coop answered that long before the guard existed. A worker works on the checkout it was given, the one the assignment names, and when another session is in the same checkout the work envelope says so (Track 3's shared_checkout disclosure) and tells it to stage only its own files. A worktree under a job folder would put the work where no other session, no office pull and no review would look for it.

So since 2026-10-16 (todo #463) every Claude worker Coop starts carries two more flags: --settings '{"worktree":{"bgIsolation":"none"}}', which turns the background isolation guard off for that session, and --disallowedTools EnterWorktree, which takes the tool away entirely. Two details are worth stealing:

  • Order matters. --disallowedTools takes every word up to the next option as a tool name, and the setup prompt is the last word on the command line. So the flags sit before --effort, whose option ends the list. Put them last, right ahead of the prompt, and the whole setup prompt would be read as one more tool to forbid.
  • The answer has to survive a restart. Claude's background daemon saves a session's launch options in its job record (respawnFlags), so a session that is later woken (the next lesson) keeps both flags. An answer given once at launch and lost at the first wake would bring the same prompt back.

Notice what Coop did not do. It did not answer the permission prompt. It removed the question before it could be asked. Coop never answers a native permission prompt, and neither can the chief: a peer cannot grant Dad's permission. The flags are not a bypass of a decision Dad would make; they are a decision Coop's design already made, written where the CLI can read it.

What Is It Waiting On?

Some questions cannot be answered in advance: a worker asks which of two layouts Dad wants, or needs permission for a command nobody predicted. For those, the useful fact is not needs_input but the question itself. Claude's session registry never says what a chat waits on. Its transcript does.

Since #463 a needs_input reading carries a waiting_on line read from the last 256 KB of the chat's transcript:

  • a tool call that never got a result is the question: a permission prompt for that call, quoted with its input;
  • an AskUserQuestion call is quoted as its question with its choices;
  • an ExitPlanMode call is an approval of its plan;
  • with no call pending, the chat's last words are what it asked.

The engine stores it as activity_waiting on the session and shows it everywhere the reading goes, as runtime.waiting_on. Now an escalation can say this chat wants permission to run this exact command, and where Dad answers it.

A Status That Outlived Its Cause

The repair shipped in the morning. By the evening it had raised a false alarm. On 2026-10-16 the worker for todo #467 went waiting in Claude's registry at 09:25:16, on a call to an MCP tool. Then it kept working: 21 more minutes, through a commit and a long git push, and ended its turn normally. The registry entry never moved again. The new detector trusted it and told Dad the chat was waiting on him, while Remote Control showed it running.

This is Track 4's oldest lesson in a new costume. Codex's observer called a running turn interrupted; Claude's registry called a working chat waiting. Both were honest reports, by another process, of a moment that had passed. So since todo #468 the registry's waiting is a hypothesis, and the transcript decides it:

  1. The wait is real while a call made no later than ten seconds after the status was written (WAIT_SLACK; one reply's calls land seconds apart) still has no result. That call is quoted as waiting_on.
  2. A running command is not a question. Claude asks before it runs a command, so a Bash call whose command is running right now as a child shell of the chat's own process is working, not waiting. The host helper gained a children primitive so this reads the same on any fleet Mac.
  3. A transcript that moved on after the status, with no such call, means the status is stale: idle if the newest event ended the turn, otherwise working.
  4. With neither, the chat waits on its last words, as before.

The One Reading That Wakes the Chief

Track 6's lesson on waking says activity readings only record and never wake the chief, with one exception. Until #463 there was no exception; this is it. A chat that holds work and has read needs_input for fifteen minutes (NEEDS_INPUT_SECONDS; readings repeat on a heartbeat, so the wake comes at most a heartbeat later) makes every pending obligation on its bindings due at once, and unparks the parked ones: the chief parked them on what it knew, and a person-shaped wait is news. It happens once per waiting episode. The session remembers which episode it escalated (activity_escalated holds that episode's start), so a chat waiting for an hour wakes the chief once, not four times.

The chief's contract tells it what to do with the wake: escalate to Dad once, quoting waiting_on, and say where he answers it (the assignment's Remote Control link, or claude attach on the Mac that runs the chat). A new question after an earlier escalation is new evidence, so the chief's repeat guard lets it through. A chat stuck on a person is the one case where waiting quietly is the wrong default, because the only party who can unstick it is the one who does not know.

Answer in advance every question your design has already decided; surface the rest word for word. A worker stuck on a prompt your system could have answered is a configuration bug. A worker stuck on a real question is news, and the news is the question, not the fact that it is waiting.

Code

From Coop's activity.py: a 'waiting' status checked against the transcript (trimmed excerpt)·python
WAIT_SLACK = 10   # one reply's calls land seconds apart

def waiting_status(host, data, pid, since):
    """(activity, source, waiting) for a chat the registry calls 'waiting', checked against its transcript."""
    read = transcript_calls(data)
    cutoff = since + WAIT_SLACK if since is not None else None
    held = [call for call, when in read['pending'] if cutoff is None or when is None or when <= cutoff]
    if held:
        for call in reversed(held):
            if not bash_running(host, pid, call):      # Claude asks before it runs a command
                return 'needs_input', 'claude: registry status waiting', describe_call(call)
        return 'working', 'claude: its Bash call is running (registry status waiting)', None
    if cutoff is not None and read['newest'] is not None and read['newest'] > cutoff:
        if read['ended']:
            return 'idle', 'claude: transcript turn ended after registry status waiting', None
        return 'working', 'claude: transcript moved on after registry status waiting', None
    waiting = _compact('its last message: ' + read['last_text'][-500:], 600) if read['last_text'] else None
    return 'needs_input', 'claude: registry status waiting', waiting
A minimal sketch you can run: is a 'waiting' chat really waiting on a person?·python
"""A minimal sketch (not Coop's code): is a chat the registry calls 'waiting'
really waiting on a person? Decide from the transcript, not the status."""
WAIT_SLACK = 10  # calls from one reply land seconds apart


def reading(events, status_at, running=()):
    """events: (time, kind, id, detail) with kind 'call', 'result', 'text' or 'end'."""
    pending, newest, ended, last_words = {}, None, False, None
    for when, kind, ident, detail in events:
        newest = when if newest is None else max(newest, when)
        ended = kind == 'end'
        if kind == 'call':
            pending[ident] = (when, detail)
        elif kind == 'result':
            pending.pop(ident, None)
        elif kind == 'text':
            last_words = detail
    held = [(i, d) for i, (w, d) in pending.items() if w <= status_at + WAIT_SLACK]
    for ident, detail in reversed(held):
        if ident not in running:            # a running command asked nobody
            return 'needs_input', detail
    if held:
        return 'working', None
    if newest is not None and newest > status_at + WAIT_SLACK:
        return ('idle' if ended else 'working'), None
    return 'needs_input', last_words


cases = {
    'prompt nobody saw': ([(0, 'call', 'c1', 'permission for EnterWorktree')], 1, ()),
    'status outlived it': ([(0, 'call', 'c1', 'an MCP call'), (3, 'result', 'c1', ''),
                            (900, 'call', 'c2', 'git push'), (1260, 'result', 'c2', ''),
                            (1300, 'end', None, '')], 1, ()),
    'long command': ([(0, 'call', 'c1', 'permission for Bash: pytest')], 1, ('c1',)),
    'asked in words': ([(0, 'text', None, 'Which layout do you want?')], 1, ()),
}
for name, (events, status_at, running) in cases.items():
    print(f'{name:20}', reading(events, status_at, running))

# Expected output:
# prompt nobody saw    ('needs_input', 'permission for EnterWorktree')
# status outlived it   ('idle', None)
# long command         ('working', None)
# asked in words       ('needs_input', 'Which layout do you want?')
The two flags every Claude worker Coop starts now carries, before --effort (illustrative; title and prompt elided)·bash
claude --bg --remote-control "<title>" --name "<title>" \
  --settings '{"worktree":{"bgIsolation":"none"}}' \
  --disallowedTools EnterWorktree \
  --effort high "<setup prompt>"
# --disallowedTools takes every word up to the next option, so it never sits right before the prompt.

External links

Exercise

Pick an automated agent or job you run that can stop and wait for a person: a CI job with a manual approval, an agent that asks before running a command, an installer that prompts. List the questions it can stop on. For each, decide whether your own design already knows the answer (then write the flag, setting or policy that gives it at launch) or whether only a person can answer (then write the one line your alert should carry: the exact question and where to answer it). Finally, run the sketch above and add a case where the registry says waiting but the transcript shows a later commit; make sure it reads working, not waiting.
Hint
Most stuck agents are stuck on questions somebody already decided once, in a doc or a habit, and never wrote where the tool reads. If an answer only lives in prose, the agent will keep asking it. For the sketch, a call made after the status time plus the slack cannot be what the chat stopped on.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Pippa— warm

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.