Skip to content

Let a Codex chat stop a command it left running in the background - #26780

Merged
brennanb2025 merged 7 commits into
mainfrom
brennanb2025/background-stop-all-agents
Oct 9, 2026
Merged

brennanb2025 merged 7 commits into
mainfrom
brennanb2025/background-stop-all-agents

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​757 $\color{#cf222e}{\Huge{\mathbf{−}}}$​21 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​736
Prod 14 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​526 $\color{#cf222e}{\Huge{\mathbf{−}}}$​74 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​452

ELI5

When Codex starts something that keeps running after its reply (a dev server, a file watcher), native chat shows it in the background-tasks strip above the message box. In a Claude chat that strip has a Stop button. In a Codex chat it had none, so the only way to stop the dev server was to ask Codex to stop it or to close the chat. This PR gives Codex chats the same Stop, as long as the installed Codex can actually do it, and makes a Stop that fails say so instead of silently doing nothing.

What Changed

Before: a Codex chat's background-tasks strip listed the running command with no Stop button and no "Stop all". Anything waiting for that work to finish (for example a /clear queued behind it) waited until the command exited on its own.

Separately, in any chat (Claude's too), when a background Stop failed (the process was still running afterwards) or Orca lost touch with the agent while stopping, nothing told you: the Stop button just became clickable again and the row stayed.

After: on Codex 0.140 or newer, each command Codex left running gets its own Stop button on its row in the strip, the same way Claude chats show it. There is no separate "Stop all" button: the strip shows one only for an agent that can stop everything at once but not one task at a time, and neither Claude nor Codex is that kind. Pressing Stop ends the process, and the row leaves the strip when Codex reports the process ended. From the press until the row leaves, its Stop stays greyed out (desktop and phone), so it never looks clickable on a row that is already stopping. On an older Codex nothing changes: no Stop is shown, because there is nothing honest it could do.

If Codex shows the command still running after its Stop, you now see one line through the chat's usual notice (a toast on desktop, the error line on the phone): "The background task wasn't stopped." The row stays and its Stop works again.

If Orca can't tell what happened, because the agent didn't answer in time or the connection to it dropped (in a Codex or a Claude chat), it never claims the task wasn't stopped, since the stop may well have landed. The desktop says "Orca couldn't confirm what happened. Check the chat.", and the row itself shows the truth: it leaves the strip if the task ended, or keeps its Stop if not. The phone shows no line for this case, as it already does for any Stop whose answer it can't confirm, and its row tells the truth the same way.

Child agents (subagents) that Codex spawns still have no Stop of their own in the strip; only commands do.

Mechanism:

  • Codex 0.140 added two experimental app-server methods: thread/backgroundTerminals/terminate {threadId, processId} ends one running command, and thread/backgroundTerminals/list {threadId} lists what still runs. The processId is the one Codex puts on each command's commandExecution item. Orca already opts into Codex's experimental methods at startup.
  • The command tracker now keeps each command's processId.
  • Every Codex chat has its own app-server. The first time a running command carries a processId, the chat asks its app-server once for a one-row list, off the message path. A real listing means this Codex can stop commands, so its backgrounded commands are marked stoppable and each gets a Stop. An older Codex that refuses the method (it answers "unknown variant" or "method not found"), or a malformed answer, means no Stop, as today, for the life of that app-server. A probe that gets no answer (a timeout, a dropped request) decides nothing: the next update about a running command asks again, one probe at a time. Chats that never leave a command running never send the probe.
  • When the user presses Stop, Orca terminates each named process, then checks the thread's own list to confirm it is gone. A thread Codex says it already unloaded counts as stopped. A dropped connection does not, because losing contact is never proof that a process ended. If a process is still listed after its stop, the Stop reports that it wasn't stopped. A timeout or dropped connection reports only that the outcome is unknown. Each request is capped at 10 seconds, and an app-server that times out is not asked again within the same Stop.
  • The desktop and phone strips and the Stop request were already built for Claude and read only two capability fields (supportsTaskStop / supportsStopAll) plus a per-row stoppable. Codex now fills those in the way Claude does (both true), which gives each stoppable row its own Stop. No wire or protocol change: older phones and older remote clients see the same fields they already understand.
  • A failed background Stop: the host used to swallow the agent's error for a background Stop (it writes no row in the transcript for one) and answer "nothing was stopped". The agent adapter now tells the two cases apart: it answers stillRunning when the agent's own answer shows the task survived its stop (Codex's list after terminate), and throws only when the outcome is unknown (a timeout, or lost contact with the app-server or CLI on the host that runs it). The host turns the first into a refusal with the existing "not done" code (agent_session_operation_invalid), worded "The background task wasn't stopped.", and the second into a refusal with the existing "outcome unknown" code (agent_session_operation_unknown), worded "Orca couldn't confirm what happened. Check the chat." on desktop and shown as nothing on the phone. Both codes and both sentences already exist; no new text and no wire change.
  • The row's Stop button holds its stopping state from the press until the row leaves. It used to come back for the moment between the host confirming the stop and the roster dropping the row (about 60 ms in live QA, longer on a slow host). Each strip now remembers the stops the host confirmed (cancelled: true) and keeps that row's button disabled while the strip still lists the task. The hold is derived from the stop answer plus the row being listed, with no timer. A failed, unconfirmed or nothing-stopped answer re-enables it at once, and so does a row that leaves and comes back, since that task was not ended. Same rule on desktop and phone; no wire change.
  • Claude: the Claude CLI's own refusal of a stop stays quiet, as before. Its refusals mostly mean the task already ended or was never known ("not running", "no task found"), and Orca can't tell those from a real refusal without reading the CLI's English message, so it never reports them as "wasn't stopped". A Claude stop that times out or loses the CLI reads as unconfirmed, like Codex.
  • Older clients: desktop and phone builds from 2026-09-27 on word both refusals from their code. Desktop builds from 2026-09-05 to 2026-09-26 (background Stop shipped, but before refusals were worded by code) show the host's log text instead, for example "a background task still runs after its stop" or "background task stop unconfirmed: codex app-server connection closed". Native chat has no outside users yet, so this only reaches the team's own older builds.
  • SSH: a Codex chat runs under the Orca server on the host that owns the workspace, so the probe and the stop go to that host's own app-server. Losing contact never counts as a stopped command.

Why

The common pattern is one Stop in the background-work banner for every agent, with each agent's adapter stopping that agent's background work its own way. For Codex the common pattern uses exactly these two app-server methods (terminate, then confirm with list, with a 10-second bound). This PR does the same for Codex.

Alternatives considered:

  • Gate on the Codex version number instead of probing. Rejected: Orca has no minimum Codex version for native chat, and a version string doesn't prove the method works on that host's binary. Asking the app-server is the truth.
  • Probe at every chat start. Rejected: it adds a request to every Codex chat, and most chats never leave a command running. Probing on the first command that could be stopped keeps it off the startup path. The probe answers in milliseconds, well before the command's turn ends and it moves to the strip. Probing once at thread open would not remove code either: a probe that gets no answer still has to be asked again later, which is the trigger this PR already has.
  • Kill the process by its OS pid. Rejected: Codex owns the process (and it may be on a remote host or in Codex's sandbox). Its own terminate method is the stop it supports.

Differences from the common pattern

  • Child agents have no Stop. Temporary: interrupting a Codex child thread's turn is a separate "stop the subagent" feature, and this PR is only about commands. Follow-up: Codex subagent Stop.
  • Stop is per command, not "interrupt the turn and stop everything". Intended: Orca's strip already gives each stoppable row its own Stop, and a background Stop leaves the foreground turn alone, which is how Claude chats already behave in Orca.
  • A dropped app-server connection or a timeout during a Stop is not counted as stopped, and not as failed either. Intended: Orca never treats lost contact as proof a process ended, or as proof it survived. The Stop reports it couldn't confirm, and the row's own state (and the chat's exit handling) settles it.
  • A confirmed Grok sub-agent Stop whose finish never arrives keeps that row's Stop greyed out. Intended: the hold is derived from the confirmed stop plus the row still being listed, with no timer. If Grok confirms a sub-agent Stop but its subagent_finished event never reaches Orca, the row stays with its Stop disabled until the strip remounts (switching sessions or reopening the chat). Before, a second press could settle it through Grok's already_finished reply. This needs Grok to drop its own finish event; a bounded release timer would cover it at the cost of the timer this design avoids.
  • One probe per app-server, after the first command that could be stopped. Intended: the common pattern assumes a Codex new enough to have these methods. Orca supports older Codex, so it checks first and shows no Stop where it can't work.

Other agents: what this PR leaves out, and why

The ask was a Stop for every agent. Research found:

  • Grok, OpenCode, OMP (Agent Client Protocol agents): their background commands never reach the strip. Orca shows them only as rows in the transcript, so there is no strip row for a Stop to sit on. Grok's sub-agents are the one exception (next bullet).
  • Grok sub-agents — already have a Stop. Since feat(grok): show Grok's subagents as subagent rows #26508 on main, Grok's sub-agents are strip rows with their own Stop, sent through Grok's _x.ai/subagent/cancel. This PR only makes a failed sub-agent Stop follow the same rule as Codex: Grok answers an ended or unknown sub-agent with a normal reply (already_finished / not_found), so Grok's own refusal of the stop means it did nothing and the sub-agent still runs, which now reads "The background task wasn't stopped." A reply about a different sub-agent, a timeout or a lost connection reads as unconfirmed, never as "wasn't stopped". (The start-up probe already checks that Grok accepts this request, so a refusal should not happen in practice.)
  • Grok background commands — follow-up. This is all that remains for Grok. Grok has an honest per-task stop: the extension method x.ai/task/kill {sessionId, taskId, source} (answers killed / already_exited / not_found) and x.ai/task/list {sessionId}. I read both in Grok's source and found them in the installed grok 1.0.50 binary. Shipping it needs a new producer that turns Grok's background commands into the strip's records, then routes Stop to x.ai/task/kill. Which Grok version added it, and the exact reply envelope, are unverified without a signed-in Grok run. (The common pattern instead kills Grok's whole process and restarts it on Stop. The per-task kill is finer-grained and keeps the session.) Temporary.
  • OpenCode — no honest stop over the protocol Orca uses. Its Agent Client Protocol server has no extension methods, and its own background-subagent stop is on OpenCode's HTTP API, which Orca can't reach while OpenCode runs over stdio. Intended for now: no fake Stop.
  • OMP 17.0.5 — no stop. Its protocol extensions list projects, sessions, usage and extensions, and none of them stops a job. Intended: no fake Stop.
  • Pi — nothing to stop. Pi's shell tool runs to completion and has no background mode. Intended.

Linked Issue

No issue. Maintainer request: native-chat background Stop for agents other than Claude.

Visual Proof

Live run with a real, signed-in Codex (codex-cli 0.159.0, model gpt-6.1-sol), driven by scripted clicks on a hidden Orca window. The "after" shots were taken at this PR's head 8949f93. The commits since then only keep a pressed Stop greyed out until its row leaves, report Grok's own refusal of a sub-agent Stop as "wasn't stopped", and merge main; none of them changes what these shots show. The "before" shot was taken on this PR's main base (d7e985c) with the same prompt.

Not part of this change: the red "Codex isn't signed in. Run codex login." line above the message box in every shot is a test-setup artifact. Orca's sign-in check ran against the test rig's private, empty Codex folder, while the chat itself used the real sign-in (Codex answered every prompt). The "before" shot from main shows the same line. The "Orca now runs Codex without its shared server" toast is an existing first-run notice in a fresh profile.

Before (main): a Codex command left running in the background has no Stop.

Before: main, the strip lists /bin/zsh -lc 'sleep 900' with no Stop button

After: the same command's row has its own Stop.

After: the strip lists /bin/zsh -lc 'sleep 900' with a Stop button

After pressing Stop: the process ends and the row leaves the strip. The strip was gone about 0.1 s after the press, and ps showed the sleep 900 process gone. The first shot was taken right after the press and the second a moment later; both already show the strip gone, because on a healthy Codex the stopping state lasts only milliseconds.

Right after pressing Stop: the strip is gone

A moment later: still gone, the transcript gained no extra row

Two commands, Stop pressed on one: only that one ends. Codex left sleep 901 and sleep 902 running ("2 shells — 2 working"). After Stop on 901, the strip lists only 902, still with its Stop, and ps showed 902 still running.

Two commands, one stopped: the strip lists only sleep 902 with its Stop

A Stop Orca can't confirm says so once, and the row tells the truth. Staged by pausing the chat's own Codex app-server before pressing Stop on sleep 903. After the 10-second limit, one toast reads "Orca couldn't confirm what happened. Check the chat.", and the row keeps its Stop. Once the app-server was resumed it finished the stop and the row left the strip.

Unconfirmed Stop: one toast, the sleep 903 row still listed with its Stop

Testing

  • Real Codex 0.159.0, run in an isolated HOME/CODEX_HOME with no sign-in (the real ~/.codex config and auth hashes were unchanged): thread/backgroundTerminals/list answers {"data":[],"nextCursor":null}, a terminate of an unknown process answers {"terminated":false}, an unknown thread answers -32600 thread not found: <id>, and an unknown method answers -32600 Invalid request: unknown variant …. The code reads each of these answers.

  • Live run with a real, signed-in Codex (codex-cli 0.159.0) at head 8949f93, on hidden Orca windows driven by scripted clicks, with an isolated Orca profile and home (only the chat's own Codex app-server used the real sign-in). Passed: a command Codex left running shows its own Stop; Stop ends the process and the row leaves the strip (about 0.1 s); with two commands, stopping one leaves the other running with its Stop; stopping the turn leaves the background command and its Stop in place, and a background Stop during a running turn leaves that turn and its typed follow-up running. The "couldn't confirm" case was staged by pausing the chat's Codex app-server: one toast after 10 s, the row kept its Stop, and the row left once the app-server resumed and finished the stop. Not staged live: the "The background task wasn't stopped." case, because a real Codex's terminate really ends the process; it is covered by the unit and end-to-end tests below with a fake app-server. Also not run live: the phone strip, an SSH host, and a Codex older than 0.140.

  • Version boundary: read common.rs at the Codex release tags. list and terminate first appear in rust-v0.140.0 and are absent in 0.139.0.

  • New tests:

    • src/main/codex/codex-background-terminals.test.ts: Stop offered after the probe, terminate by process id plus list confirm, older Codex gets no Stop, a late probe answer restates already-backgrounded rows as stoppable, one probe per app-server, stale fence stops nothing, list pagination, a listed survivor is answered as still running (even when another request in the same Stop timed out), unloaded thread counts as stopped, a dropped connection (during terminate or during the confirming list) never counts as stopped, timeout stops asking, and a probe that timed out or lost its request is asked again on the next update instead of hiding Stop for good.
    • src/main/runtime/structured-foreground-command-strip.test.ts: end to end through the real host and agent-status store. The roster offers Stop on the command, cancel with the background scope reaches terminate, and the item's end clears the strip. A command still listed after its stop comes back to the client as a "not done" refusal, a Stop that loses the connection comes back as "outcome unknown", and in both the row keeps its Stop.
    • src/main/acp/acp-structured-child-work.test.ts (from main's Grok sub-agent Stop): Grok's own refusal of a sub-agent Stop is refused as "wasn't stopped"; a reply about a different sub-agent is refused as "outcome unknown". Neither settles the parent, its sibling or the connection.
    • structured-agent-session-turns.test.ts: a background Stop the agent answers as still running is refused as "not done"; one that timed out or lost the agent is refused as "outcome unknown" (the path Claude's background Stop shares). Neither writes a transcript row.
    • Desktop and phone strip: a Codex-shaped roster shows Stop on the command only, and the click sends the command's id.
    • Desktop (NativeChatStructuredSession.test.tsx) and phone (MobileNativeChatBackgroundTasks.test.tsx) strip: a Stop the host confirms keeps that row's button disabled until the row leaves, while a failed one beside it re-enables; the same task listed again offers Stop again; an unconfirmed or nothing-stopped answer re-enables at once. Both fail on the previous head.
  • All src/main/codex tests (193 files), background-task channel/capability/admission/turns tests, Claude and Codex structured integration suites, mobile strip tests, typecheck (node, web), and orca-ci-checks.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Review

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security: the stop goes only to the chat's own app-server, for processes that app-server reported for this chat.
  • Cross-platform: no platform-specific code. Codex's terminate kills on each OS itself.
  • SSH: the host that runs the chat owns the probe and the stop. No wire change. Older clients read existing fields.
  • Mobile: the phone strip already draws a per-row Stop from the same fields, already shows a "not done" Stop on its error line, and already stays quiet on a Stop whose outcome is unknown.
  • Performance: at most one extra request per Codex app-server, and only once a command could be stopped.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

A command Codex started and left running (a dev server, a watcher) showed in
the background-tasks strip above the composer with no Stop, because Codex
reported no stop it could honour. Codex 0.140+ has one: the app-server's
experimental thread/backgroundTerminals/terminate names the process by the
processId on the command's item, and thread/backgroundTerminals/list shows
what still runs.

The command tracker now keeps each command's processId. The first time a
running command carries one, the session probes its own app-server once with
a one-row list; a listing marks the app-server's backgrounded commands
stoppable and the session as offering per-row Stop and Stop all, through the
fields the strip already reads. Any refusal (an older Codex answers "unknown
variant"), timeout or malformed answer leaves it as today: no Stop.

A Stop terminates each named process, then confirms against the thread's own
list. A thread Codex says it unloaded counts as stopped; a dropped connection
does not, since lost contact is no proof a process ended. An app-server that
times out is not asked again. The killed process's own item/completed removes
its row, as before.
…nd Stop fails

A background-terminal probe that timed out or lost its request used to hide
Stop for the rest of the app-server's life. Only a refusal by name (unknown
variant, method not found) or a malformed listing now settles it as
unsupported; anything else is asked again on the next update.

A background Stop whose agent failed (a process still listed after its stop,
a timeout) used to answer "nothing stopped" and the button simply came back.
The host now refuses it, so both clients show their usual notice: "The
background task wasn't stopped." This covers Claude's background Stop too.
…ll running

A timeout or lost contact with the agent leaves the outcome unknown, so it now answers
with the existing outcome-unknown refusal instead of "The background task wasn't stopped."
A confirmed Stop re-enabled its button for the moment between the host's answer
and the roster dropping the row. The button now holds while the host says it
stopped the task and the strip still lists it, on desktop and phone; a failed,
unconfirmed or nothing-stopped answer, or the row coming back, offers Stop again.
Grok answers an ended or unknown subagent with a normal reply, so a JSON-RPC refusal means it did nothing and the subagent still runs.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary

The problem. In a native chat with Codex, a command the agent left running in the background (a dev server, a watcher) showed in the background-tasks card above the message box with no Stop button. Only Claude chats had one, so a Codex user could not stop it from Orca, and anything waiting on it (for example a /clear queued behind background work) could wait forever.

What changes for you. In Codex chats on Codex 0.140 or later, each command Codex left running gets its own Stop in that card, on desktop and phone. Pressing it ends the command; the row goes away only after Codex confirms it is gone. If Codex confirms the command is still running, you see "The background task wasn't stopped." If Orca can't tell (a timeout, or contact with Codex is lost), you see "Orca couldn't confirm what happened. Check the chat." on desktop instead of a false "stopped" or "wasn't stopped". Older Codex versions keep today's card with no Stop. Claude's background Stop gets the same honest feedback when it fails or can't be confirmed (it used to be silent).

How. Orca asks Codex's own server to end the command and then checks Codex's list of running commands to confirm it is gone. Whether a Codex version supports this is asked once per Codex process, on the machine that runs Codex (so SSH hosts answer for themselves); only a definite "not supported" answer hides Stop, and a timeout is asked again later.

Fixed during review (three review rounds, QA, readiness checklist): the support check no longer gives up for good after one timeout; a dropped connection never counts as stopped; a failed Stop now says so; an unconfirmed Stop says it couldn't confirm instead of claiming failure; the Stop button stays disabled until the row leaves (it briefly re-enabled before); main's new Grok sub-agent Stop now reports Grok's own refusal honestly as "wasn't stopped".

Deferred / known limits:

  • Grok background commands still have no Stop (Grok has a way to end them; adding it needs Orca to show Grok's background commands first) — follow-up. Grok sub-agents already have a Stop from main.
  • OpenCode, OMP and Pi offer no honest way to stop a background task through how Orca connects to them, so they get no Stop (intended).
  • If Codex's support check times out, a quiet background command may show no Stop until that chat's next Codex activity (falls back to today's behaviour, never a wrong action).
  • A confirmed Grok sub-agent Stop whose "finished" report never arrives keeps that row's Stop disabled until the card is redrawn.
  • On the phone, a Stop that can't be confirmed shows nothing (the phone's existing behaviour for any unconfirmed Stop).

Verified: live with a real signed-in Codex 0.159.0 in a hidden test app: per-command Stop ends the process (checked by its process id) and removes the row; stopping one of two leaves the other; the turn's Stop and background Stop don't affect each other; a paused Codex server gives one "couldn't confirm" toast and the row clears correctly once Codex resumes; main shows no Stop for the same command. Screenshots are in the PR body. CI on the final head passed (16 passed, 16 skipped by path, none failed); the readiness checklist passed with no P0/P1.

Not verified: a live "wasn't stopped" case (not stageable honestly), the phone and SSH live, and Codex versions below 0.140 live.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 9, 2026 10:49
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Walkthrough

Walkthrough

The changes add Codex background-terminal support, including capability probing, process-ID tracking, and termination confirmation. Structured cancellation now distinguishes confirmed surviving tasks from unknown stop outcomes. Mobile and renderer task interfaces retain confirmed stops while task rows remain listed and use typed stop results.


Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 896fe

A malformed Codex response could make Stop appear successful while the command keeps running. Validate the listing response before confirming a stop.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 43.24% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 23 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly identifies the primary user-facing change: allowing Codex chats to stop commands left running in the background.
Description check Passed The description is detailed and covers the user impact, implementation, rationale, testing, visual proof, compatibility, and known limitations. It does not provide a linked issue, but it states that t…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.


  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9a78da34-0d79-40e8-94d7-f3694ee39be7
📥 Commits

Reviewing files that changed from the base of the PR and between 39a01f9 and 896fece.

📒 Files selected for processing (23)
  • mobile/src/session/MobileNativeChatBackgroundTasks.test.tsx
  • mobile/src/session/MobileNativeChatBackgroundTasks.tsx
  • mobile/src/session/use-mobile-background-task-stops.ts
  • mobile/src/session/use-mobile-native-chat-composer-tray.test.tsx
  • mobile/src/session/use-mobile-structured-background-tasks.ts
  • src/main/acp/acp-structured-child-stop.ts
  • src/main/acp/acp-structured-child-work.test.ts
  • src/main/codex/codex-background-command-tracker.ts
  • src/main/codex/codex-background-task-tracker.ts
  • src/main/codex/codex-background-terminals.test.ts
  • src/main/codex/codex-background-terminals.ts
  • src/main/codex/codex-child-work-evidence.ts
  • src/main/codex/codex-structured-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts
  • src/main/runtime/structured-foreground-command-strip.test.ts
  • src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx
  • src/renderer/src/components/native-chat/structured-child-records-switch.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session.ts
  • src/shared/structured-session-background-tasks-view.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Comment on lines +78 to +105
async function stillRunning(
rpc: CodexTerminalRpc,
threadId: string,
timeoutMs: number
): Promise<Set<string>> {
const running = new Set<string>()
let cursor: string | null = null
for (let page = 0; page < MAX_LIST_PAGES; page++) {
const reply = readRecord(
await rpc.request(
'thread/backgroundTerminals/list',
{ threadId, ...(cursor === null ? {} : { cursor }) },
{ timeoutMs }
)
)
for (const terminal of Array.isArray(reply.data) ? reply.data : []) {
const processId = readRecord(terminal).processId
if (typeof processId === 'string') {
running.add(processId)
}
}
cursor = typeof reply.nextCursor === 'string' ? reply.nextCursor : null
if (cursor === null) {
return running
}
}
throw new Error('codex app-server listed more background terminals than Orca reads')
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '25,175p' src/main/codex/codex-background-terminals.ts
rg -n 'backgroundTerminals/list|nextCursor|cursor' src/main/codex

Repository: stablyai/orca

Length of output: 22778


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- background terminals file ---'
nl -ba src/main/codex/codex-background-terminals.ts | sed -n '1,240p'
printf '%s\n' '--- bindings ---'
rg -n -F --glob '*.ts' -- 'type CodexTerminalRpc' src
rg -n -F --glob '*.ts' -- 'interface CodexTerminalRpc' src
rg -n -F --glob '*.ts' -- 'function readRecord' src
rg -n -F --glob '*.ts' -- 'const readRecord' src
rg -n -F -- 'readRecord' src/main/codex src/main | head -80
printf '%s\n' '--- request implementations and callers ---'
rg -n -F --glob '*.ts' -- 'request(method' src/main/codex src/main
rg -n -F --glob '*.ts' -- 'terminateCodexBackgroundTerminals' src
printf '%s\n' '--- tests around malformed/list behavior ---'
nl -ba src/main/codex/codex-background-terminals.test.ts | sed -n '1,460p'

Repository: stablyai/orca

Length of output: 44168


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- record reader ---'
nl -ba src/main/codex/codex-item-field-readers.ts | sed -n '1,100p'
printf '%s\n' '--- connection declaration and implementation references ---'
rg -n -F --glob '*.ts' -- 'export interface CodexAppServerConnection' src/main/codex
rg -n -F --glob '*.ts' -- 'export type CodexAppServerConnection' src/main/codex
rg -n -F --glob '*.ts' -- 'class CodexAppServerConnection' src/main/codex
rg -n -F --glob '*.ts' -- 'CodexAppServerConnection' src/main/codex/codex-app-server-connection.ts src/main/codex
printf '%s\n' '--- request result parsing ---'
rg -n -F --glob '*.ts' -- 'JSON.parse' src/main/codex
rg -n -F --glob '*.ts' -- 'response.result' src/main/codex
rg -n -F --glob '*.ts' -- 'result:' src/main/codex/codex-app-server*
printf '%s\n' '--- app-server connection files ---'
rg --files src/main/codex | rg 'app-server|rpc|protocol'

Repository: stablyai/orca

Length of output: 41455


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- connection types ---'
nl -ba src/main/codex/codex-app-server-connection-types.ts | sed -n '1,100p'
printf '%s\n' '--- session request implementation ---'
nl -ba src/main/codex/codex-app-server-session.ts | sed -n '220,315p'
printf '%s\n' '--- record dispatch ---'
nl -ba src/main/codex/codex-app-server-record-dispatch.ts | sed -n '1,120p'
printf '%s\n' '--- connection request construction ---'
nl -ba src/main/codex/codex-app-server-connection.ts | sed -n '180,250p'
printf '%s\n' '--- PR diff for target file ---'
git diff 39a01f9c0be291695168d3437ba79a28b04736e7 896fece4d335544b64c00d5f73d426a5509a176d -- src/main/codex/codex-background-terminals.ts

Repository: stablyai/orca

Length of output: 23172


🌐 Web query:

Codex app-server thread/backgroundTerminals/list response nextCursor data terminals schema

💡 Result:

For `thread/backgroundTerminals/list`, the response `result` is paginated:

```json
{
  "data": [
    {
      "itemId": "item_456",
      "processId": "42",
      "command": "python3 -m http.server",
      "cwd": "/workspace",
      "osPid": null,
      "cpuPercent": null,
      "rssKb": null
    }
  ],
  "nextCursor": null
}
```

`data` is an array of terminal records. `itemId`, `processId`, `command`, and `cwd` are strings; `osPid`, `cpuPercent`, and `rssKb` may be `null`. `nextCursor` is a cursor for the next page, or `null` when there are no more pages. Pass a non-null cursor back as `cursor` in the next request. The method is experimental. ([upd.dev](https://upd.dev/openai/codex/src/commit/11faf9af94fa345ba090701f071c8d3107fbca45/codex-rs/app-server/README.md?utm_source=openai))

Request params include `threadId` and optional pagination fields `cursor` and `limit`. ([github.com](https://github.com/openai/codex/blob/main/codex-rs/app-server-protocol/src/protocol/common.rs?utm_source=openai))

Citations:

- 1: https://upd.dev/openai/codex/src/commit/11faf9af94fa345ba090701f071c8d3107fbca45/codex-rs/app-server/README.md?utm_source=openai
- 2: https://github.com/openai/codex/blob/main/codex-rs/app-server-protocol/src/protocol/common.rs?utm_source=openai

Reject malformed terminal-list pages instead of treating them as empty.

CodexAppServerConnection.request returns unvalidated unknown results. A successful response without data, with an invalid nextCursor, or with a terminal lacking processId therefore produces an empty or incomplete set. The confirmation loop then counts the target as stopped even when it may still run.

Suggested fix
     const reply = readRecord(
       await rpc.request(
         'thread/backgroundTerminals/list',
         { threadId, ...(cursor === null ? {} : { cursor }) },
         { timeoutMs }
       )
     )
-    for (const terminal of Array.isArray(reply.data) ? reply.data : []) {
+    if (!Array.isArray(reply.data)) {
+      throw new Error('codex app-server returned an invalid background-terminal list')
+    }
+    const nextCursor = reply.nextCursor
+    if (nextCursor !== null && typeof nextCursor !== 'string') {
+      throw new Error('codex app-server returned an invalid background-terminal cursor')
+    }
+    for (const terminal of reply.data) {
       const processId = readRecord(terminal).processId
-      if (typeof processId === 'string') {
-        running.add(processId)
+      if (typeof processId !== 'string') {
+        throw new Error('codex app-server returned an invalid background-terminal record')
       }
+      running.add(processId)
     }
-    cursor = typeof reply.nextCursor === 'string' ? reply.nextCursor : null
+    cursor = nextCursor
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function stillRunning(
rpc: CodexTerminalRpc,
threadId: string,
timeoutMs: number
): Promise<Set<string>> {
const running = new Set<string>()
let cursor: string | null = null
for (let page = 0; page < MAX_LIST_PAGES; page++) {
const reply = readRecord(
await rpc.request(
'thread/backgroundTerminals/list',
{ threadId, ...(cursor === null ? {} : { cursor }) },
{ timeoutMs }
)
)
for (const terminal of Array.isArray(reply.data) ? reply.data : []) {
const processId = readRecord(terminal).processId
if (typeof processId === 'string') {
running.add(processId)
}
}
cursor = typeof reply.nextCursor === 'string' ? reply.nextCursor : null
if (cursor === null) {
return running
}
}
throw new Error('codex app-server listed more background terminals than Orca reads')
}
async function stillRunning(
rpc: CodexTerminalRpc,
threadId: string,
timeoutMs: number
): Promise<Set<string>> {
const running = new Set<string>()
let cursor: string | null = null
for (let page = 0; page < MAX_LIST_PAGES; page++) {
const reply = readRecord(
await rpc.request(
'thread/backgroundTerminals/list',
{ threadId, ...(cursor === null ? {} : { cursor }) },
{ timeoutMs }
)
)
if (!Array.isArray(reply.data)) {
throw new Error('codex app-server returned an invalid background-terminal list')
}
const nextCursor = reply.nextCursor
if (nextCursor !== null && typeof nextCursor !== 'string') {
throw new Error('codex app-server returned an invalid background-terminal cursor')
}
for (const terminal of reply.data) {
const processId = readRecord(terminal).processId
if (typeof processId !== 'string') {
throw new Error('codex app-server returned an invalid background-terminal record')
}
running.add(processId)
}
cursor = nextCursor
if (cursor === null) {
return running
}
}
throw new Error('codex app-server listed more background terminals than Orca reads')
}

@brennanb2025
brennanb2025 merged commit e2234a3 into main Oct 9, 2026
56 checks passed
@brennanb2025
brennanb2025 deleted the brennanb2025/background-stop-all-agents branch October 9, 2026 22:51
brennanb2025 added a commit that referenced this pull request Oct 15, 2026
…6780)

* Let a Codex chat stop a command it left running in the background

A command Codex started and left running (a dev server, a watcher) showed in
the background-tasks strip above the composer with no Stop, because Codex
reported no stop it could honour. Codex 0.140+ has one: the app-server's
experimental thread/backgroundTerminals/terminate names the process by the
processId on the command's item, and thread/backgroundTerminals/list shows
what still runs.

The command tracker now keeps each command's processId. The first time a
running command carries one, the session probes its own app-server once with
a one-row list; a listing marks the app-server's backgrounded commands
stoppable and the session as offering per-row Stop and Stop all, through the
fields the strip already reads. Any refusal (an older Codex answers "unknown
variant"), timeout or malformed answer leaves it as today: no Stop.

A Stop terminates each named process, then confirms against the thread's own
list. A thread Codex says it unloaded counts as stopped; a dropped connection
does not, since lost contact is no proof a process ended. An app-server that
times out is not asked again. The killed process's own item/completed removes
its row, as before.

* Ask Codex again after a probe gets no answer, and say when a background Stop fails

A background-terminal probe that timed out or lost its request used to hide
Stop for the rest of the app-server's life. Only a refusal by name (unknown
variant, method not found) or a malformed listing now settles it as
unsupported; anything else is asked again on the next update.

A background Stop whose agent failed (a process still listed after its stop,
a timeout) used to answer "nothing stopped" and the button simply came back.
The host now refuses it, so both clients show their usual notice: "The
background task wasn't stopped." This covers Claude's background Stop too.

* Say a background Stop wasn't stopped only when the agent shows it still running

A timeout or lost contact with the agent leaves the outcome unknown, so it now answers
with the existing outcome-unknown refusal instead of "The background task wasn't stopped."

* Keep a background task's Stop in its stopping state until the row leaves

A confirmed Stop re-enabled its button for the moment between the host's answer
and the roster dropping the row. The button now holds while the host says it
stopped the task and the strip still lists it, on desktop and phone; a failed,
unconfirmed or nothing-stopped answer, or the row coming back, offers Stop again.

* Keep Grok's own subagent Stop refusal quiet, and a reply about another subagent unconfirmed

* Report Grok's own refusal of a subagent Stop as not stopped

Grok answers an ended or unknown subagent with a normal reply, so a JSON-RPC refusal means it did nothing and the subagent still runs.

(cherry picked from commit e2234a3)
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.

1 participant