Skip to content

feat(subagents): Codex-aligned in-process runner with Rust control plane - #2205

Merged
flora131 merged 58 commits into
mainfrom
spec/issue-2188-subagent-inprocess
Aug 5, 2026
Merged

feat(subagents): Codex-aligned in-process runner with Rust control plane#2205
flora131 merged 58 commits into
mainfrom
spec/issue-2188-subagent-inprocess

Conversation

@flora131

@flora131 flora131 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #2188. Fixes #2191.

What this is

Replaces the process-spawn subagent runtime with a Codex-aligned in-process runner: every child is an in-process AgentSession supervised by a Rust control plane in crates/atomic-natives (NAPI-RS), a structural port of codex-rs's multi-agent architecture. The design, decision record, and clean-break inventory are committed on this branch: specs/2026-08-04-subagents-inprocess-runner.md, backed by the cited research/ docs (including a 112-citation codex-rs analysis and a live 3-for-3 idle-kill reproduction that occurred during this spec's own research).

Design (spec §4.5, decision-for-decision)

  • Zero OS child processes. The CLI-child spawn and the detached async runner are deleted. async: true now means don't-wait on the same runtime; runs no longer survive parent exit (breaking, documented) — cold identities persist as session files and reload on demand.
  • Rust control plane (subagent_control: registry.rs, execution.rs, residency.rs, status.rs, control.rs): persistent canonical child identities, RAII spawn reservations, turn-scoped execution guards (4/parent), LRU residency with transparent cold reload, watch-channel statuses, literal 100 ms interrupt grace with flush-before-force.
  • No kill-capable timers. The idle watchdog that SIGTERM'd productive children after 5 quiet minutes is deleted, along with the wall cap; TerminationCause has no timer variant. Bounds are capacity, depth (≤ 5), and provider timeouts.
  • Typed outcomes. status: ok|error|skipped|interrupted|continued + SessionStats replace the 0/1/-1/-2/143 exit-code protocol; terminal envelopes are token-bounded on both branches.
  • Shared model fallback. Children pass their pre-filtered ladder to createAgentSession's fallbackModels — the exact classification and candidate behavior main chat and workflows converged on in Converge main-chat and workflow model fallback: share the failure classifier, scope the switch to the failing turn, and retry before advancing #2170/fix(fallback): converge main-chat and workflow model fallback #2201.
  • Detach → background continuation on the async jobs widget with live tracking (fixes Show async subagent widget when foreground runs detach for intercom coordination #2191).
  • Kept Atomic: per-agent tool allowlists, depth ≤ 5, model-fallback ladders, request/response tool + intercom (no mailbox protocol).

Clean break (spec §10)

~50 modules deleted: spawn machinery (pi-args, pi-spawn, spawn-env), attempt-watchdog, the detached jiti runner (12 files), the file-claim result pipeline (watcher/claims/dedupe/quarantine/stale-PID reconciler), nested-events file/env routing, the ATOMIC_SUBAGENT_* env bridge (~20 vars), with CI grep guards against reappearance.

Verification

  • cargo fmt / clippy -D warnings / cargo test (control-plane unit tests: reservations, guards, residency, watch reduction, no-timer-cause exhaustiveness)
  • npm run check, test:unit, test:integration, test:ci-contracts — all green locally (enforced by pre-push)
  • Live E2E against the built CLI under tmux — single, foreground parallel (3), chain, async, with ps proof of zero child processes. Captures posted as comments below.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

flora131 and others added 30 commits August 5, 2026 01:53
…ust control plane (#2188)

Full Codex alignment resolved with the requester: zero OS processes (async
runner deleted, survival traded for cold-identity reload), turn-scoped
execution guards, LRU residency with transparent cold reload, persistent
canonical child identities, 100ms interrupt grace, no kill-capable timers.
Control plane implemented in Rust (crates/atomic-natives, NAPI-RS). Atomic
keeps per-agent tool allowlists, depth<=5, model fallback, and its
request/response tool surface. Clean break: env bridge, watchdog, exit
codes, and the file-claim delivery pipeline are deleted. Fixes #2191 via
unified background continuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…detach in process

Adds an explicit test-session seam to the in-process runner so the foreground
path can be exercised without an OS child, moves pre-admission model-candidate
filtering and cwd validation onto the in-process door, runs the structured-output
corrective-retry loop inside the runner, and routes intercom detach through
continue_in_background instead of the process-era placeholder.
…rogress errors

The in-process foreground path stopped writing _input.md, which spec 5.5 keeps as
user-facing artifact naming, and never populated AgentProgress.error on a failed
attempt. Rewrites the acceptance and structured-output suites against the typed
status contract instead of a spawned fake CLI.
Replaces the detached-runner single path with the in-process
continuation seam. async-execution-single.ts is deleted per spec
section 10 and its behavior moves to runs/inprocess/background-single.ts,
which admits the child, continues it in the background, and returns the
canonical child path immediately.

Refs #2188
…d executor (#2188)

async: true is now a don't-wait request over the same in-process foreground
executor. runAsyncPath admits the child, settles the call with a typed
continued status and canonical child path, then runs the work on the existing
runChainPath / runParallelPath un-awaited. Chain substitution, dynamic fanout,
worktrees, structured output, skills, progress files, fail-fast, and per-step
model candidates stay on the one code path that already implements and tests
them, rather than a second serialized runner.

This removes the last two OS spawn sites, satisfying spec G1 (zero OS child
processes): async-execution-common.ts spawnRunner and the detached runner in
subagent-runner-streaming.ts.

Deletes per spec section 10 "Modules removed": async-execution-{chain,common,types}.ts
and the async-execution.ts barrel, the 12-file subagent-runner*.ts family,
async-event-journal.ts, and top-level-async.ts. Their exclusively-process-era
tests (subagents-async-config, subagents-async-event-journal) are removed with
their subjects; tests covering kept behavior are re-pointed at the in-process
path with typed-status assertions.
#2188)

With the last OS spawn site gone, these spec section 10 "Modules removed"
entries have no importers left:

- runs/shared/attempt-watchdog.ts   - the idle/wall watchdog; zero importers
- runs/shared/final-drain.ts        - the stdout drain grace
- runs/shared/pi-spawn.ts           - CLI-child spawn resolution
- runs/shared/spawn-env.ts          - the env bridge builder
- shared/post-exit-stdio-guard.ts   - process-only; sole importer was the watchdog

subagents-final-drain.test.ts and subagents-pi-spawn.test.ts are removed with
their subjects. interactive-engine-env-scrub.test.ts keeps its three
scrubInteractiveEngineEnv tests, which cover kept coding-agent behavior; only
the two buildSubagentSpawnEnv cases are removed with spawn-env.ts.
hydrateActiveJobsDeferred captured the extension ctx into a timer; after
session replacement or reload the ctx.cwd getter throws, crashing the host
(caught by the installed-package Node smoke test). Capture cwd while the
ctx is live and never touch ctx from the timer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown

Too many files changed for review (228 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

@flora131

flora131 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

tmux evidence 1/2 — live runs on the built CLI (in-process runtime)

Built packages/coding-agent/dist/cli.js from this branch (fresh Rust natives), driven in a real logged-in session under tmux in a scratch project. Captures are tmux capture-pane -p output, trimmed to the relevant region.

Foreground parallel (3 workers) — live rows, per-agent activity, and the ctrl+o hint (#2191's sibling fix):

 subagent parallel (3)
 • parallel [fork] · 3 agents running · 0/3 done · 6 tool uses · 3.4k token · 6s
   • Agent 1/3: worker · 2 tool uses · 1.5k token · 6s
     ⎿  bash: {"command":"echo final-1 && sleep 30 && echo final-done-1","cwd":"/private/tmp/subagent-tour","t... | …
     Press ctrl+o for live detail
   • Agent 2/3: worker · 2 tool uses · 951 token · 6s
     ⎿  active 2s ago
     Press ctrl+o for live detail
   • Agent 3/3: worker · 2 tool uses · 932 token · 6s
     ⎿  active 2s ago
     Press ctrl+o for live detail
 ∀ Shattering...

Chain (2 steps) — running step with activity + hint, next step correctly pending:

 subagent chain (2)
 • chain [fork] · step 1/2 · 1 tool use · 1.3k token · 6s
   • Step 1: worker · 1 tool use · 1.3k token · 6s
     ⎿  active now
     Press ctrl+o for live detail
     output: bash
   ◦ Step 2: worker · pending

Zero child OS processes during the parallel run (the only CLI process is the host itself):

CLI processes total: 1
process-era child processes spawned by this runtime: 0

@flora131

flora131 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

tmux evidence 2/2 — async widget, typed results, and the live-progress fix

Async run (async: true) — immediate return, background jobs widget tracking all 3 children live with the group hint:

 async subagent parallel (3) · background
 • parallel · 3 agents running · 0/3 done · 0s
   • Agent 1/3: worker · running · thinking…
   • Agent 2/3: worker · running · thinking…
   • Agent 3/3: worker · running · thinking…
   Press ctrl+o for live detail

Single child, typed terminal result — session file + artifacts, no exit codes anywhere:

 subagent worker
 ✓ worker [fork] · ⟳ 4 · 3 tool uses · 37k token · 11s
   ⎿  Done
      Implemented the requested bash run and captured its output.
   session: ~/.atomic/agent/sessions/--private-tmp-subagent-tour--/2026-08-05T08-32-20-890Z_019fd10d-599a-76f2-b44e-…
   output: ~/.atomic/agent/sessions/--private-tmp-subagent-tour--/subagent-artifacts/f8f06b05_worker_0_output.md
 hello-single
 done-single

Before the live-progress fix (the bug this branch also repairs: bare header, no rows, no hint, while 3 children were actively running):

 subagent parallel (3)
--
 async subagent parallel (3) · background

Root cause: the in-process runner emitted onUpdate only at terminal moments, and the multi renderer bailed to plain text when results was empty mid-run. Fixed by publishing throttled AgentProgress from session events and rendering progress-only runs; regression-tested in test/unit/subagents-live-detail-hint.test.ts.

@mintlify

mintlify Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bastani 🟢 Ready View Preview Aug 5, 2026, 9:16 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

flora131 and others added 3 commits August 5, 2026 02:35
The Rust in-process subagent control plane added five NAPI exports
(AdmissionRefusalKind, AgentStatus, NapiSubagentControl, SubagentControl,
TerminationCause) that the export contract test never learned about, so
the coding-agent suite failed on both linux-x64 and windows-x64.

Add the five exports to EXPECTED_NATIVE_EXPORTS. The assertion stays an
exact whole-surface equality check.
The bundled subagent extension now reaches the Rust control plane in
crates/atomic-natives through a static import, so the extension throws at
import time when no binding is present. The suites job carried no Rust
toolchain, so on a clean Windows checkout 21 unit suites died during
collection and 6 more tests failed on the empty tool list that followed.

Build the binding before the unit and integration steps, as agent-suite
already does. Measured at 42s Linux and 86s Windows, both inside the
existing 8/12 minute caps.

The topology contract asserted that suites needed no Rust. Replace that
stale claim with the ordering assertions the other native-consuming jobs
carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…robe

The PowerShell one-liner in subagents-zero-process joined the $parent
assignment and Get-CimInstance without a ';', producing a parser error
(Unexpected token 'Get-CimInstance') and a nonzero probe exit code on
the Windows suites job.
@flora131
flora131 merged commit e4aa7ec into main Aug 5, 2026
17 checks passed
flora131 added a commit that referenced this pull request Aug 6, 2026
Copilot review on #2220: the entries linked only to #2205, the regression
source, so release-note readers could not trace the fixes to the PR that
implemented them. All seven Unreleased bullets now lead with #2220 and
keep #2205 as the named regression source. No released section is touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
Copilot review on #2213: the entries linked only to #2205, the regression
source, so a reader of the release notes could not trace the fix to the PR
that implemented it. Both entries now lead with #2213 and keep #2205 as
the named regression source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
Separate pre-existing defect, not a #2205 regression: chat-transcript.ts
is byte-identical between 0.9.12 and 0.9.13-alpha.1. Found while
falsifying the initial hypothesis for the Ctrl+O autoscroll bug, which
the layer below this one actually fixes.

ScrollableComponentViewport stores its offset as a distance from the
bottom and compensated it only when content grew:

    if (this.scrollFromBottom > 0 && this.lastWidth === width
        && lineCount > this.lastLineCount) {
        this.scrollFromBottom += lineCount - this.lastLineCount;
    }

A component that loses rows -- a live subagent widget dropping its
current-tool row at the end of a tool call -- left scrollFromBottom
unchanged, and clampScroll() then pulled the view toward the bottom
against a smaller maxScroll. Compensation is now symmetric in both
directions, so a user scrolled up stays anchored whether content grows
or shrinks, while a user already at the bottom still sticks to it.

Scope note: ScrollableComponentViewport is used by ChatSessionHost, not
by the normal interactive chat transcript, so this does not by itself
change the reported Ctrl+O behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
Two coupled defects from e4aa7ec (#2205), neither present in 0.9.12.

WORKFLOW STAGES COULD NOT DELEGATE. WORKFLOW_STAGE_SUBAGENT_POLICY set
`managementActions: "full"` and `fanoutAuthorized: false` in the same
object. The two contradict, and the fanout gate discarded the "full"
entirely. docs/workflows.md already promised stages the bundled subagent
tool with the normal depth guard. Stages now carry fanoutAuthorized: true.

THE TS DEPTH GUARD WAS DEAD CODE. checkSubagentDepth() read
ATOMIC_SUBAGENT_DEPTH, which no production code ever wrote: #2205 deleted
the env bridge and every OS child process, orphaning getSubagentDepthEnv()
with zero production callers. So `blocked` was always false and
checkDepthForExecution() could never block, at any depth, in any context.
Its test stayed green only by writing the depth into process.env itself
and asserting the reader read it back -- it simulated the propagation
production no longer performed.

The guard is now derived from the live admitted policy rather than an
environment variable, via getCurrentSubagentDepth(ctx.subagentPolicy) and
getInheritedMaxSubagentDepth(). The env machinery is deleted:
SUBAGENT_DEPTH_ENV, getSubagentDepthEnv, hasWorkflowStageSubagentGuard,
workflowSessionEnv, workflowSessionEnvFromContext, and the env-reading
checkSubagentDepth. The Rust admission door in crates/atomic-natives
remains the outer bound; the TS guard is now a real inner check rather
than decoration.

The depth tests no longer write process.env and instead assert against
admitted policy depth, including the five-level limit, a stricter
configured limit, the ceiling clamp, and inherited maximums in both
directions.

docs/subagents.md and docs/workflows.md are corrected to describe the
mechanism that actually enforces the limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
Fourth defect from #2205, observed live: a nested child inside a workflow
stage came back with `status ok`, `toolCalls 0`, and an envelope of
literally `undefined`, and appeared to lack a working subagent tool even
though its stage granted the budget.

In-process children resolved resources against the parent rather than
their own admitted identity, so a nested child could be constructed
without the tool set its policy allowed. Children now resolve their own
resources and inherit the depth budget through the admitted policy,
matching the guard rewritten in the previous commit.

Covered by subagents-inprocess-child-resources.test.ts, plus updates to
the runner, child-policy gate, guard-propagation, and workflow-stage
bundled-resource suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
Changelog entries under [Unreleased] for the four #2205 regressions, plus
README and skill updates describing the real depth mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
Copilot review on #2220: the entries linked only to #2205, the regression
source, so release-note readers could not trace the fixes to the PR that
implemented them. All seven Unreleased bullets now lead with #2220 and
keep #2205 as the named regression source. No released section is touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
…guard real (#2220)

* fix(subagents): gate fanout on delegation, not management actions

The fanout authorization check ran before the management branch, so a
child with `fanoutAuthorized: false` was refused every `subagent` action
with "Subagent fanout is not authorized for this child." — including
read-only management (list/get/status/doctor/interrupt/resume), which
performs no delegation. That also made the narrower mutating-management
gate unreachable dead code.

Move the check to guard only the delegation path, and report the actual
execution mode rather than a hardcoded "single".

Workflow stages additionally shipped `managementActions: "full"` with
`fanoutAuthorized: false`, which contradict. Stages are top-level
sessions rather than subagent children, so the stage policy now sets
`fanoutAuthorized: true`, restoring the delegation the workflow docs
already describe. Nesting stays bounded by the unchanged five-level
depth guard.

Regression coverage in test/unit/subagents-child-policy-gate.test.ts;
7 of its 10 tests fail on the unfixed source.

* test(subagents): cover the stage-policy path through parent tool registration

Adds the production door a workflow stage actually traverses: the full
subagents extension resolving an executor from `ctx.subagentPolicy`.
The existing case only covered the fanout-child registration door.

* fix(subagents): replace the dead env depth guard and let stages delegate

Two coupled defects from e4aa7ec (#2205), neither present in 0.9.12.

WORKFLOW STAGES COULD NOT DELEGATE. WORKFLOW_STAGE_SUBAGENT_POLICY set
`managementActions: "full"` and `fanoutAuthorized: false` in the same
object. The two contradict, and the fanout gate discarded the "full"
entirely. docs/workflows.md already promised stages the bundled subagent
tool with the normal depth guard. Stages now carry fanoutAuthorized: true.

THE TS DEPTH GUARD WAS DEAD CODE. checkSubagentDepth() read
ATOMIC_SUBAGENT_DEPTH, which no production code ever wrote: #2205 deleted
the env bridge and every OS child process, orphaning getSubagentDepthEnv()
with zero production callers. So `blocked` was always false and
checkDepthForExecution() could never block, at any depth, in any context.
Its test stayed green only by writing the depth into process.env itself
and asserting the reader read it back -- it simulated the propagation
production no longer performed.

The guard is now derived from the live admitted policy rather than an
environment variable, via getCurrentSubagentDepth(ctx.subagentPolicy) and
getInheritedMaxSubagentDepth(). The env machinery is deleted:
SUBAGENT_DEPTH_ENV, getSubagentDepthEnv, hasWorkflowStageSubagentGuard,
workflowSessionEnv, workflowSessionEnvFromContext, and the env-reading
checkSubagentDepth. The Rust admission door in crates/atomic-natives
remains the outer bound; the TS guard is now a real inner check rather
than decoration.

The depth tests no longer write process.env and instead assert against
admitted policy depth, including the five-level limit, a stricter
configured limit, the ceiling clamp, and inherited maximums in both
directions.

docs/subagents.md and docs/workflows.md are corrected to describe the
mechanism that actually enforces the limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(subagents): give in-process children their own resource resolution

Fourth defect from #2205, observed live: a nested child inside a workflow
stage came back with `status ok`, `toolCalls 0`, and an envelope of
literally `undefined`, and appeared to lack a working subagent tool even
though its stage granted the budget.

In-process children resolved resources against the parent rather than
their own admitted identity, so a nested child could be constructed
without the tool set its policy allowed. Children now resolve their own
resources and inherit the depth budget through the admitted policy,
matching the guard rewritten in the previous commit.

Covered by subagents-inprocess-child-resources.test.ts, plus updates to
the runner, child-policy gate, guard-propagation, and workflow-stage
bundled-resource suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(subagents): record the child-policy, depth-guard, and nesting fixes

Changelog entries under [Unreleased] for the four #2205 regressions, plus
README and skill updates describing the real depth mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): attribute the policy fixes to their own PR

Copilot review on #2220: the entries linked only to #2205, the regression
source, so release-note readers could not trace the fixes to the PR that
implemented them. All seven Unreleased bullets now lead with #2220 and
keep #2205 as the named regression source. No released section is touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Norin Lavaee <nlavaee@umich.edu>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
A Ctrl+O-expanded subagent scrolled the chat to the bottom and wiped
terminal scrollback throughout a run. Not present in 0.9.12; introduced
by e4aa7ec (#2205).

Measured mechanism: pi-tui's TUI.doRender() compares the whole rendered
line array and, when the earliest changed row is above previousViewportTop,
falls back to fullRender(true), which writes \x1b[2J\x1b[H\x1b[3J --
clear screen, home, clear scrollback. #2205's in-process runner published
AgentProgress from a catch-all over every child session event, including
message_update streaming deltas, and every publish rewrites the elapsed
fields the widget renders. The live widget therefore presented pi-tui with
a changed above-fold row continuously rather than when its contents changed.

Progress now publishes only for events that change what the widget shows,
via an exported progressEmissionFor() table: agent_start,
tool_execution_start and tool_execution_end forced, message_end throttled,
everything else silent. That is the emission profile foreground subagents
had before the in-process runner. The 400 ms throttle, the forced-emit
milestones, the depth guard, and the typed status contract are unchanged,
and no renderer is touched.

Paired live capture against the built CLI under tmux at 20 rows, same task
and script, differing only by this change: 73 destructive full redraws
(66 of which repainted content the user could not see) drops to 20 (10).
Restricted to the subagent's own run, 66 drops to 12, all on genuine
content changes.

Residual: a genuine above-fold change still costs one pi-tui redraw when
rowsBelowWidget + widgetRows > terminalRows. pi-tui 0.83.0 is the newest
release and still has this branch; upstream earendil-works/pi#4785 and
 #7194 track it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
Copilot review on #2213: the entries linked only to #2205, the regression
source, so a reader of the release notes could not trace the fix to the PR
that implemented it. Both entries now lead with #2213 and keep #2205 as
the named regression source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 6, 2026
…es (#2213)

* fix(subagents): publish live progress only at widget-visible milestones

A Ctrl+O-expanded subagent scrolled the chat to the bottom and wiped
terminal scrollback throughout a run. Not present in 0.9.12; introduced
by e4aa7ec (#2205).

Measured mechanism: pi-tui's TUI.doRender() compares the whole rendered
line array and, when the earliest changed row is above previousViewportTop,
falls back to fullRender(true), which writes \x1b[2J\x1b[H\x1b[3J --
clear screen, home, clear scrollback. #2205's in-process runner published
AgentProgress from a catch-all over every child session event, including
message_update streaming deltas, and every publish rewrites the elapsed
fields the widget renders. The live widget therefore presented pi-tui with
a changed above-fold row continuously rather than when its contents changed.

Progress now publishes only for events that change what the widget shows,
via an exported progressEmissionFor() table: agent_start,
tool_execution_start and tool_execution_end forced, message_end throttled,
everything else silent. That is the emission profile foreground subagents
had before the in-process runner. The 400 ms throttle, the forced-emit
milestones, the depth guard, and the typed status contract are unchanged,
and no renderer is touched.

Paired live capture against the built CLI under tmux at 20 rows, same task
and script, differing only by this change: 73 destructive full redraws
(66 of which repainted content the user could not see) drops to 20 (10).
Restricted to the subagent's own run, 66 drops to 12, all on genuine
content changes.

Residual: a genuine above-fold change still costs one pi-tui redraw when
rowsBelowWidget + widgetRows > terminalRows. pi-tui 0.83.0 is the newest
release and still has this branch; upstream earendil-works/pi#4785 and
 #7194 track it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): attribute the emission fix to its own PR

Copilot review on #2213: the entries linked only to #2205, the regression
source, so a reader of the release notes could not trace the fix to the PR
that implemented it. Both entries now lead with #2213 and keep #2205 as
the named regression source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Norin Lavaee <nlavaee@umich.edu>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 7, 2026
Separate pre-existing defect, not a #2205 regression: chat-transcript.ts
is byte-identical between 0.9.12 and 0.9.13-alpha.1. Found while
falsifying the initial hypothesis for the Ctrl+O autoscroll bug, which
the layer below this one actually fixes.

ScrollableComponentViewport stores its offset as a distance from the
bottom and compensated it only when content grew:

    if (this.scrollFromBottom > 0 && this.lastWidth === width
        && lineCount > this.lastLineCount) {
        this.scrollFromBottom += lineCount - this.lastLineCount;
    }

A component that loses rows -- a live subagent widget dropping its
current-tool row at the end of a tool call -- left scrollFromBottom
unchanged, and clampScroll() then pulled the view toward the bottom
against a smaller maxScroll. Compensation is now symmetric in both
directions, so a user scrolled up stays anchored whether content grows
or shrinks, while a user already at the bottom still sticks to it.

Scope note: ScrollableComponentViewport is used by ChatSessionHost, not
by the normal interactive chat transcript, so this does not by itself
change the reported Ctrl+O behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 7, 2026
The natives globalSetup I added in #2224 turned every Linux `suites` run
red, including on main. Each run passes all ~5960 tests on both retry
attempts and then dies with `exit code 139` -- SIGSEGV during exit.

main was green at ad21551 and red at the very next run, 6546900,
which is #2224. The only other commit in that range is #2225, which
changes timeout numbers, a contract test and docs, and cannot segfault.
The sole new executable code is test/global-setup-natives.ts.

Cause: `bindingLoads()` called `createRequire(...)(NATIVE_ENTRY)`, and
globalSetup runs in vitest's ORCHESTRATOR -- the process owning the
worker pool. That dlopened the NAPI addon into a process that otherwise
never touches it; workers load it on demand in their own processes. The
addon carries #2205's Rust control plane and its Tokio runtime, so on
glibc Linux its destructors run at exit alongside pool teardown and the
process dies after the suite has already succeeded.

The probe now runs in a child: `node -e "require(<entry>)"`, exit 0 means
loadable. That keeps every property the check was chosen for -- a real
load attempt rather than a filename scan, so it cannot drift from
napi-rs's ~700 lines of platform-arch-libc resolution, and a
foreign-platform binding still triggers a rebuild -- while the
orchestrator never loads the addon. It costs one short spawn on a path
that already spawns a Rust build when the binding is missing.

Verified locally on all three paths: warm (2.9s, silent), foreign binding
present (reports "present but not loadable here" and rebuilds), and full
unit suite 625 files / 5957 tests, exit 0.

I could not reproduce the segfault locally because I only ever ran this
suite on macOS, which is also why #2224 shipped with it. The proof is
this PR's own Linux `suites` job.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 7, 2026
Separate pre-existing defect, not a #2205 regression: chat-transcript.ts
is byte-identical between 0.9.12 and 0.9.13-alpha.1. Found while
falsifying the initial hypothesis for the Ctrl+O autoscroll bug, which
the layer below this one actually fixes.

ScrollableComponentViewport stores its offset as a distance from the
bottom and compensated it only when content grew:

    if (this.scrollFromBottom > 0 && this.lastWidth === width
        && lineCount > this.lastLineCount) {
        this.scrollFromBottom += lineCount - this.lastLineCount;
    }

A component that loses rows -- a live subagent widget dropping its
current-tool row at the end of a tool call -- left scrollFromBottom
unchanged, and clampScroll() then pulled the view toward the bottom
against a smaller maxScroll. Compensation is now symmetric in both
directions, so a user scrolled up stays anchored whether content grows
or shrinks, while a user already at the bottom still sticks to it.

Scope note: ScrollableComponentViewport is used by ChatSessionHost, not
by the normal interactive chat transcript, so this does not by itself
change the reported Ctrl+O behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
flora131 added a commit that referenced this pull request Aug 7, 2026
…#2214)

* fix(coding-agent): keep the scroll anchor stable when content shrinks

Separate pre-existing defect, not a #2205 regression: chat-transcript.ts
is byte-identical between 0.9.12 and 0.9.13-alpha.1. Found while
falsifying the initial hypothesis for the Ctrl+O autoscroll bug, which
the layer below this one actually fixes.

ScrollableComponentViewport stores its offset as a distance from the
bottom and compensated it only when content grew:

    if (this.scrollFromBottom > 0 && this.lastWidth === width
        && lineCount > this.lastLineCount) {
        this.scrollFromBottom += lineCount - this.lastLineCount;
    }

A component that loses rows -- a live subagent widget dropping its
current-tool row at the end of a tool call -- left scrollFromBottom
unchanged, and clampScroll() then pulled the view toward the bottom
against a smaller maxScroll. Compensation is now symmetric in both
directions, so a user scrolled up stays anchored whether content grows
or shrinks, while a user already at the bottom still sticks to it.

Scope note: ScrollableComponentViewport is used by ChatSessionHost, not
by the normal interactive chat transcript, so this does not by itself
change the reported Ctrl+O behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(coding-agent): keep the anchor when a component shrinks in its interior

greptile on #2214 reproduced a viewport jumping from entry-10..19 to
entry-13..22 after three entries above the reader were removed. The
finding is correct, and the defect is larger than the report suggests.

rowsShiftedAboveAnchor compared one scalar row count per component and
broke at the first component whose end passed anchorRow, on the stated
assumption that a spanning component's changes happen at or below the
anchor. In production that assumption never holds: the chat host renders
the ENTIRE transcript as a single component in slot 0
(chat-session-host-rendering.ts:23-25), so every scrolled-up reader's
anchor is inside it, the loop broke immediately, and the compensation
this PR added returned 0 for all of them. It engaged only when the anchor
sat past the transcript, parked on the trailing spacer.

A production path deletes from that component's interior: compaction_end
-> refreshCompactedTranscript -> replaceMessages, which splices the entry
array in place (chat-message-renderer.ts:160-164).

Windowed components may now report a row map, `rowSegments(width)`,
identifying each run of rows by the entry that produced it. For a
spanning component the shift is the distance the anchored segment itself
moved, rather than a height delta.

Segments are matched by object identity, not by cache key. Keys embed the
entry index (chat-session-host-rendering.ts:167-173), so a splice
renumbers every survivor; the entry objects move through it unchanged.

Known limits, deliberate:
- A real compaction_end rebuilds entry objects via
  chatEntriesFromAgentMessages, so only extraEntries keep identity. A
  viewer anchored in the rebuilt region falls back to the nearest
  surviving segment above, then below, and lands close rather than exact.
- No cross-component identity; duplicate ids resolve to first occurrence.
- One small object per entry per measured frame, unmemoized.
- Components without rowSegments keep the previous break semantics.

Two tests added, both verified to fail with only the consuming branch
reverted to `break;` -- entry-10 -> entry-13 and entry-10 -> entry-16 --
and to pass once restored byte-identically. All six existing anchor tests
are unchanged and still pass; the only pre-existing line touched in that
file is an import.

Also repairs the [Unreleased] changelog structure in this file. My
conflict resolutions while rebasing #2213/#2218 concatenated bullets
without respecting headings, leaving `### Removed` with no blank line
before it and two Fixed bullets underneath it. That malformed section is
already on main; all six bullets are preserved and no released section is
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(coding-agent): let a static transcript report its rows to the anchor

greptile raised the same drift a third time after the previous push, this
time naming ScrollableChatTranscriptComponent. The thread arrived marked
resolved; the code disagreed, and the reviewer was right.

That class builds its transcript WITHOUT a cache key (:505), so
supportsRowWindow is false, rowSegments returned [] for anything not
windowed, and isRowWindowComponent gated the viewport away from asking.
The previous commit therefore fixed the ChatSessionHost path -- which
does pass a cache key -- and left the exported class exactly as broken.

Passing it a cache key would have been the obvious fix and is wrong:
chat-message-renderer.test.ts:188-198 pins that a transcript without one
reflects entries mutated in place, which is precisely what caching by key
would miss. The absent key is the feature.

So identifying rows is decoupled from windowing. renderAllRows already
walks every entry and knows each block's height, so it records them as it
goes -- no extra render, and it cannot go stale against an in-place
mutation the way a key can. rowSegments returns those on the static path,
guarded by the width they were measured at. The viewport reads segments
from static components too, after rendering them, since the recording is
a by-product of that render.

Two tests added against the exported class: entries removed above the
viewer keep the anchored entry, and an entry mutated in place is still
reflected -- the second guards the regression the cache-key approach
would have caused. Reverting only the static-segment plumbing fails the
first with entry-4 -> entry-7 while the other nine pass, so it is
specific to this gap.

npm run check clean; 626 files / 5968 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Norin Lavaee <nlavaee@umich.edu>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@flora131
flora131 deleted the spec/issue-2188-subagent-inprocess branch August 14, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant