Skip to content

fix: keep the turn open while a command runs in the background - #23

Merged
sleeyax merged 7 commits into
sleeyax:mainfrom
KobeVrancken:fix/claude-tty-background-commands
Sep 9, 2026
Merged

fix: keep the turn open while a command runs in the background#23
sleeyax merged 7 commits into
sleeyax:mainfrom
KobeVrancken:fix/claude-tty-background-commands

Conversation

@KobeVrancken

Copy link
Copy Markdown

A session that backgrounds a command shows in Paseo as idle while it goes on working. Found on a live session of mine this evening, and it is the one launch kind the subagent hold does not cover.

Stacked on #22, which is stacked on #21, #20 and #19, so this branch carries all four and the diff against main includes them. The commit to read here is the last one.

What was happening

A background command is dispatched exactly as an asynchronous agent is — the tool answers the moment it starts, Claude goes idle, and a <task-notification> wakes Claude when it ends — but only agents were counted, so the Stop hook in between ended the turn. Paseo reads a session as busy from its open turn and from nothing else, and nothing can reopen an ended one.

From the session shipping remi-plus#731, all times local:

time event
21:32:02 async agent launched; 21:32:23 Stop → turn held
21:37:42 Bash(run_in_background) "Run codex reviewer" started
21:40:39 the last agent reports → nothing outstanding that the adapter counts
21:41:12 StopfinishTurn(end_turn), Paseo goes idle
21:44:54 the review reports, waking Claude
21:45 → 21:56 Claude launches three more agents and keeps working, with no turn open

transcript-translator.ts said as much in as many words: "A notification for a background command rather than an agent names no card here, and is left alone."

What changed

  • A background command now holds the turn the way an agent does. Claude records the id its report will name in backgroundTaskId beside the launching tool result, and the notification carries it as the <task-id>, so the launch and the report are joined the way an agent's are.
  • It has no card: its tool call is the launch, which finished, and everything it does goes to a file the adapter only learns the name of when the report arrives. All that is kept for one is whether a report is still owed.
  • Its bound is a flat thirty minutes from the moment the turn was held rather than a silence, because a running command shows nothing to measure silence against; starting or reporting another restarts it. Of the 91 background commands that reported across this box's transcripts the median took 4 minutes and the longest genuine wait was a 21-minute code review; the ones that ran for hours were servers and poll loops, which never report at all and are what the bound is there to let go of.
  • Each kind of work waited on is bounded on its own, and the turn ends only once every one of them has run out — a command's thirty minutes are not cut short by the agents beside it having gone quiet first.
  • Giving up abandons the command as it does an agent, so the next turn does not hold for a poll interval and give up on it again.
  • TaskNotification.agentId is now taskId, which is what the tag is called and what it holds: an agent's id or a command's.

Not covered

  • A command Claude kills with KillShell is not distinguished. If no notification follows one, the turn waits its bound out rather than ending at the kill — no transcript on this box has a KillShell in it to read the shape from.
  • Thirty minutes is a bound, not a promise: a command that runs longer than that and then reports lands outside the turn, which is today's behaviour for every background command.

Verification

pnpm typecheck and pnpm test at the root: 127 adapter tests, all green, five of them new — the hold across a command's report, the bound and the abandon after it, a command outliving the agents beside it, and the translator's counting, settling and replay.

🤖 Generated with Claude Code

https://claude.ai/code/session_016CbhLTXNiW7pV8oEhz76gp

A command Claude runs in the background is dispatched exactly as an
asynchronous agent is: the tool answers the moment the command starts, Claude
goes idle, and it is woken by a <task-notification> when the command ends.
Only agents were counted, so the Stop hook in between ended the turn — and
Paseo reads a session as busy from its open turn and from nothing else, so
everything Claude did from the report onwards happened outside any turn, on a
session showing as idle.

Chasing one: the session shipping remi-plus#731 backgrounded a code review at
21:37, its last agent reported at 21:40, and the turn ended at 21:41:12 with
the review still running. The review reported at 21:44:54, woke Claude, and it
worked for another fifteen minutes and three more agents while Paseo showed the
agent idle the whole way.

Claude records the id its report will name in backgroundTaskId beside the
launching tool result, and the notification carries it as the <task-id>, so
the launch and the report are joined the way an agent's are. The command has
no card — its tool call is the launch, which finished — and it shows nothing
at all while it runs, its output going to a file whose name only the report
carries. Its bound is therefore a flat thirty minutes from the moment the turn
was held rather than a silence, restarted whenever another command is started
or reported: of the background commands that have reported across this box's
transcripts the median took four minutes and the longest genuine wait was a
twenty-one minute code review, while the ones that ran for hours were servers
and poll loops, which never report at all and are what the bound is there to
let go of. Each kind of work waited on is bounded on its own and the turn ends
only once every one of them has run out, so a command's thirty minutes are not
cut short by the agents beside it having gone quiet first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016CbhLTXNiW7pV8oEhz76gp
@sleeyax
sleeyax force-pushed the fix/claude-tty-background-commands branch from 885d7a3 to a48cde0 Compare September 9, 2026 18:25
sleeyax and others added 6 commits September 9, 2026 21:52
The wake bound was given only to a turn with nothing outstanding left, and a command's report stamps an activity the agents' bound does not read.
So a turn whose agent had already run its fifteen minutes out ended at the poll after the report Claude had just been woken by, with the answer it was writing landing outside the turn the hold is there to keep it in.
The bound now applies whatever is outstanding: no turn ends while Claude is writing, and every kind of work waited on still has to have run its own bound out before one ends at all.
An agent that has gone quiet therefore no longer ends a turn Claude is busy in, which is what the test around that case now says.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The log a turn writes as it gives up names the agents it was still holding for and nothing else, so a turn ended on commands that never reported said how many there were and never which.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The name of the file a command writes its output to is in the tool result that starts it, not only in the report it ends with, so the comments and the README saying otherwise were wrong about a file they were right about the treatment of: the adapter does not follow it, which is why a running command shows nothing to measure a silence against and its bound is flat.
That reason is now given once, on the constant that holds the bound, and the reason a command is waited on the way an agent is once, at the Stop hook that holds the turn for both; the comments that repeated either say what the thing is instead.
The comments this branch wrapped are unwrapped, and the measurements taken off private transcripts are out of the code and attributed in the README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a report after the give-up

Every command report on this box arrived as a queued command and never as a user turn, which is the shape the translator now has a test for.
The runtime has the reverse of the case it already covered: a command whose bound has run out while an agent beside it is still inside its own keeps the turn open.
A report that lands after a turn abandoned the command it names moves neither the activity a later bound is measured from nor anything an agent's id would.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A `TaskStop` names an agent or a background command through the one `task_id`, and only the agent was read out of it, so a stopped command was waited on for the whole thirty minutes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
It is checked first and unconditionally, so it no longer gates the last agent to report alone.
The test that covers it is given room to breathe under parallel load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sleeyax
sleeyax merged commit 51ea2c1 into sleeyax:main Sep 9, 2026
@KobeVrancken
KobeVrancken deleted the fix/claude-tty-background-commands branch September 9, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants