✨ Launch native Agent sessions in independent terminal panes (#731) - #741

Draft
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch
Draft

✨ Launch native Agent sessions in independent terminal panes (#731)#741
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch

Conversation

@taras

@tarastaras commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes#731. Third implementation Story under Quest #717, stacked on #738.

Base is agent/issue-730-terminal-execution, not main, as the Story's verification stack directs. Branched from b2f826ac002eca28813526e18acbd6861b3f8c89, the accepted head of #738.

Why

A <Session.Launch> at the root takes the run's one foreground-terminal lease, so native UIs are sequential by construction — the second waits for the first to close. Inside a <Terminal> that defeats the point of a grid, where every pane stays interactive at the same time. This Story lets an Implementor, a Planner and a reviewer sit in three panes at once, without weakening either of the two ownerships involved.

What changes

Before: a <Session.Launch> written inside a paired <Terminal> asked for the run's foreground lease, which the grid was already holding, and was refused.

After: it uses the pane. Panes launch concurrently; one pane admits one live launch at a time; sequential use of a pane is ordinary composition.

<Terminal.Grid columns={2}>
<Terminaltitle="Planner">
<Session.Launch session="planner">You are the repository planner.</Session.Launch>
</Terminal>
<Terminaltitle="Implementor">
<Session.Launch session="implementor">You are the repository implementor.</Session.Launch>
</Terminal>
<Terminaltitle="Reviewer">
<Session.Launch session="reviewer">You are the repository reviewer.</Session.Launch>
</Terminal>
<Terminaltitle="Shell" />
</Terminal.Grid>

The launch itself is unchanged. It receives no pane prop, token, identifier or mode; its AgentLaunchRequest, its result and its retained agent_session_launch phases are the ones a root launch would have.

How it works

pane scope installs a launcher over the pane's claim
→ <Session.Launch> reserve() → claim.admit() (this pane, not the root lease)
→ flush() → the pane's own text
→ launch(request, spawned) → host launcher; spawned() trips the pane's latch
→ readiness barrier → attach

packages/core/src/terminal/pane-launcher.ts is the whole mechanism: middleware on the existing NativeLauncher Api, installed in the pane's scope and closed over that pane's claim. reserve and flush are answered here and deliberately not delegated — delegating would ask for the root lease the grid is holding. launch delegates the exact request onward and only wraps the start report.

Readiness needed a boundary a launch could report, so NativeLauncherHandler.launch now takes the runtime's child-start event as a parameter — not a request member, not a context, not a return value, which is what the spec requires of the latch. The foreground launcher reports it from the child's own spawn event, before it waits for the exit. nativeLaunch() is unchanged for provider adapters, which hear nothing about the start and so cannot fake one.

A paired pane also flushes what it has rendered so far before the UI draws over it. That is the root flush rule applied in the one place a pane's text goes: composite.display(ordinal, …).

Review guide

Start with:packages/test-agent/src/TerminalGridNativeLaunch.test.md — the authored journey: a 2×2 grid of three native Agent sessions and the host's default shell.

Then review:

  1. packages/core/src/terminal/pane-launcher.ts — the whole delivered mechanism.
  2. packages/runtime/launcher.ts — the start event, and where the foreground launcher reports it.
  3. packages/core/src/expand.tspaneWork(), where the launcher and the pane flush are installed.
  4. packages/test-agent/tests/terminal-grid-native-launch.test.ts — Tier GN, and packages/core/tests/agent-session-launch.test.ts — Tier SP.

Look carefully at:

  • reserve() returning a resource that holds claim.admit() for the launch's whole scope — not just the child's lifetime. That is what keeps the pane held while the session lease around the child is still unwinding, and what refuses a second overlapping launch.
  • The spawned wrapper in launch. Reporting anywhere else — after preparation, after the reservation, on an allocated PID — would present a pane that never started as one that is running.

What must stay true

  • Root <Session.Launch> is unchanged — enforced by installing nothing at the root, checked by SL1–SL18 (all still passing).
  • No pane identity in the launch — enforced by the claim being closed over rather than passed, checked by GN2, which scans the launch requests and the retained agent_session_launch records for the authored pane titles, ordinal and columns.
  • Terminal ownership grants no session — enforced by leaving the coordinator untouched, checked by GN4: two panes naming one logical session still contend, and exactly one retains session-busy.
  • Only a started child makes a pane ready — enforced by the spawned parameter, checked by SP3, SP4 and FL9.
  • A pane is held until everything around its launch is done — enforced by the reserve resource's scope, checked by SP5 and GN9.

How to verify it

deno task test packages/core/tests/agent-session-launch.test.ts
deno task test packages/runtime/tests/native-launcher.test.ts
deno task test packages/test-agent/tests/native-launch.test.ts
deno task test packages/test-agent/tests/terminal-grid-native-launch.test.ts

Tier SP — the pane seam, against a stub provider:

  • SP1 proves a pane launch does not take the root lease, and fails if it delegates reserve.
  • SP2 proves two panes hold their terminals at once: each launch waits for the other to have started, so a serialised pair waits for a start that cannot happen and hangs rather than passing.
  • SP3 proves readiness comes only from the start, and fails if anything earlier trips the latch.
  • SP4 proves a launch that fails before the start shows no partial grid and keeps its completed durable phases.
  • SP5 proves the pane stays held through both halves — refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both.

Tier FL — the foreground launcher, with real children:

  • FL8/FL9 prove the real spawn event is reported once before waiting, and never reported for a child that could not start.

Tier GN — the whole TestAgent stack, real worker over a real ACP connection, deterministic coordinator:

  • GN1 the 2×2 journey: three native sessions and a shell, all four started before attach, none having left by then, in the authored row-major positions and forms.
  • GN2 no pane identity in the launch request or the retained record.
  • GN3 a pane launch never reaches the host's launcher.
  • GN4 two panes naming one session contend; exactly one session-busy, and nothing attaches.
  • GN5 with no terminal provider, nothing starts at all.
  • GN6 a completed grid replays with no provider, launcher or agent contact.
  • GN7 after attachment one UI exits nonzero while its sibling is live: only that pane fails, the sibling is observed alive on the far side of the failure and stops only at reader close, and the grid ends on the pane that failed.
  • GN8 reader close cancels both live launches; neither pane fails, the composite comes down, and a root launch afterwards naming a session a pane held proves both leases came back.
  • GN9 a pane admits its next user only once the last one is wholly done.
  • GN10 a grid interrupted with a live pane launch, resumed on the same journal: fresh composite, native child restarted on the retained identity, nothing prepared again, retained record deep-equal.

Each claim was broken on purpose and re-run: removing the pane launcher fails SP1–SP4 and GN1–GN4, GN7–GN9; never reporting readiness fails SP1, SP2, SP3 and SP5; releasing the pane as soon as it is taken fails SP5 and only SP5.

Scope

Included

  • The pane-scoped native launcher, and its installation in paired panes.
  • The runtime child-start event on NativeLauncherHandler.launch.
  • A pane flush, so a pane's rendered text reaches the reader before the UI covers it.
  • The authored 2×2 journey and its three scenario documents.

Intentionally unchanged

  • tmux.Open terminal grids with tmux in foreground runs #732 owns the provider; this Story proves the contract against a controlled one.
  • Agent advertisements. No agent is advertised or de-advertised here.
  • The session coordinator, its natural key, and the ACPX construction routes.
  • nativeLaunch()'s signature, so no provider adapter changed.

New abstractions

  • usePaneNativeLauncher(claim, flush) exists because a pane's terminal has to answer reserve and flush for whatever is written in it, and <Session.Launch> must not learn that it is in a pane. Its consumer is paneWork() in expand.ts.
  • ControlledLauncherOptions.start exists so a suite can decide whether a launch starts at all — reporting is what a successful start does, throwing without reporting is what a failure before the start does. Its consumers are Tier SP and Tier GN.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Session.Launch>` written at the root takes the run's one foreground
terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat
the point of a grid, where every pane is interactive at the same time.
So core installs a native launcher in each paired pane's scope, closed over that
pane's claim. `<Session.Launch>` finds it by being written there: it is handed
no pane, ordinal, token or mode, and its request, result and retained phases are
the ones a root launch would have. What changes is which terminal answers
`reserve` and `flush` — the pane's, through its claim, so two panes do not
contend and one pane admits one live launch at a time. A pane also flushes what
it has rendered before the UI draws over it, which is the root rule in the one
place a pane's text goes.
Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch`
takes the runtime's child-start event as a parameter — not a request member, not
a context, not a result — and the foreground launcher reports it from the
child's own `spawn` event, before it waits for the exit. The pane launcher
listens and trips its claim's latch there and nowhere else: preparation, the
reservation, the flush and an allocated PID are not a start, and a child that
never ran never reports one. `nativeLaunch()` is unchanged for adapters, which
hear nothing about the start.
Terminal ownership and Agent-session ownership stay independent. Nothing pane-
derived enters the coordinator key, the launch request, the retained record or a
diagnostic, and two panes naming one logical session still contend through the
existing non-waiting coordinator.
No tmux, no new Agent advertisement, and root launch behavior is unchanged.
Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness,
a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime
launcher (the start event, and a child that never starts), and Tier GN over the
checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent
stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting
readiness fails SP1, SP2, SP3 and SP5.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown

PR #741: ✨ Launch native Agent sessions in independent terminal panes (#731)

12 files, +1508 / -34

Scope

🔴 PR has 1542 lines changed. Split into focused PRs.

🟡 1542 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×10: packages/core/src/expand.ts, packages/acp/src/provider.ts
  • no-empty-function ×3: packages/runtime/launcher.ts, packages/acp/src/provider.ts
  • no-unnecessary-type-assertion ×2: packages/core/src/expand.ts
  • no-redundant-type-constituents ×1: packages/runtime/launcher.ts

Slop

  • packages/acp/src/provider.ts:2766// path there is.
  • packages/acp/src/provider.ts:2787// deliberately.
  • packages/core/src/expand.ts:2254// makes this pane ready.
  • packages/runtime/launcher.ts:267// reports having started.

Oxlint slop signals:

  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 51 diagnostics across 3 files (15 rules)
Density: 0.034 violations/added-line

no-unused-vars (10): packages/core/src/expand.ts, packages/acp/src/provider.ts
consistent-function-scoping (10): packages/acp/src/provider.ts
no-shadow (5): packages/core/src/expand.ts, packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/expand.ts, packages/acp/src/provider.ts
no-empty-function (3): packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-useless-spread (3): packages/acp/src/provider.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-floating-promises (2): packages/acp/src/provider.ts
no-unnecessary-type-assertion (2): packages/core/src/expand.ts
unbound-method (2): packages/core/src/expand.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-redundant-type-constituents (1): packages/runtime/launcher.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

No production change. The pane-scoped launcher, the runtime spawn callback, the
authority boundaries and root-launch behavior are exactly as reviewed.
The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent
sessions and the host's default shell. All four children report their start
before the composite attaches, each waits for its siblings while holding its own
pane, and the row reads back the authored row-major positions and forms.
Four rows added, all driven by signals this run produced:
- GN7: after attachment one native UI exits nonzero while its sibling is live.
Only that pane fails; the sibling is observed alive on the far side of the
failure and stops only when the reader leaves; the grid ends on the pane that
failed, and the close's cancellation is not a second failure.
- GN8: the reader leaves with both launches live. Both are cancelled where they
stood, neither pane fails, the composite comes down — and a root launch after
the grid, naming a session a pane held, proves both leases came back. Which
refusal it gets is the proof: not "already holds this run's terminal", not
"another owner is using session", but the #517 recovery tombstone a cancelled
native UI leaves behind.
- GN9: a pane admits its next user only once the last one is wholly done, with
the launch and the prompt that follows it going through the real coordinator.
- GN10: a grid interrupted with a live pane launch, resumed on the same journal.
It rebuilds the composite, starts the native child on the identity the first
attempt retained, prepares nothing, and the retained record comes back
unchanged — identity, route, binding and phase alike.
SP5 now proves the pane stays held through both halves: refused while the child
is live, refused again once the child has gone but the lease around it is still
unwinding, admitted only after both. A launch that merely returned showed only
the first.
Two harness repairs. Pane states are read as a set of panes rather than a count
of messages — a pane still live when the reader leaves is told twice, once from
the outcome close decided and once from its own settlement, and that is display
rather than a second settlement. And a generated variant is written to a
directory of its own with copies of the scenarios it names, so a killed run
leaves nothing in the repository.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

The harness's interrupted branch built a `Result` as an object literal and cast
it. Effection has a constructor for exactly that, so it uses it: no cast, and
the type is the constructor's rather than an assertion's.
Behavior and evidence are unchanged.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

An orderly cancellation that finished proves everything a normal return proves,
and must say so. It did not: `ownership.quiesced()` was a statement after
`authority.perform()`, and cancellation unwinds past every statement after the
operation it cancels. A reader closing a terminal grid therefore left every
session its panes had launched carrying a recovery tombstone, and the next owner
was told to recover a session nothing was using.
The launch now runs in a scope the ownership body owns, and the acknowledgement
is that scope's cleanup — reached on every path there is, cancellation included.
It brings the launch down deliberately and reads the outcome of doing so, so the
two facts it needs are facts rather than inferences: the native child and its
cleanup settled, and this provider holds no handle for the session. A teardown
that could not prove the child stopped throws out of `destroy()` and is not
quiescence — and is still a failure, so it propagates rather than passing
quietly.
Nothing grid-specific reaches the provider. Reader close is the ordinary launch
cancellation path, and this is the ordinary launch cancellation path's rule.
The conservative cases keep their tombstone: a detach that failed or a session
prepared and never handed over leaves a handle, and a child or provider cleanup
that failed leaves the acknowledgement unmade. Cancellation, a released lease, a
PID and elapsed time still prove nothing on their own.
CX1 asserted the behavior this replaces — that a cancelled launch stays owned —
so it now asserts the accepted one. CX2 is new and holds the other half: a
cleanup that could not finish withholds quiescence, and the record stays active.
GN8 is rebuilt as directed: two pane children held on unresolved operations,
signals from each child's own teardown, teardown proven to finish after both,
and a root launch afterwards on one of the same logical sessions that acquires
ownership and starts — receiving neither session-busy nor
session-recovery-required, and reclaiming the root foreground lease as it goes.
Broken on purpose and re-run: acknowledging only on a normal return fails CX1
and GN8; acknowledging without proving the cleanup settled fails CX2, and only
CX2.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 4 redundant comments. Inline suggestions to remove them below.

// the reader closing a terminal grid is one — unwinds past every
// statement after it, so a decision written down here would never be
// reached; written as this scope's cleanup, it is reached on every
// path there is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// path there is.

// prepared and never handed over, leaves one. Either one
// missing leaves the session owned rather than looking
// finished, which is what the next owner is told to recover
// deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// deliberately.

// A `<Session.Launch>` written in this pane finds this launcher simply
// by being here: it reserves and flushes this pane instead of competing
// for the run's one foreground lease, and the child it starts is what
// makes this pane ready.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// makes this pane ready.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

✨ Launch native Agent sessions in independent terminal panes (#731) - #741

Draft
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch
Draft

✨ Launch native Agent sessions in independent terminal panes (#731)#741
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch

Conversation

@taras

@tarastaras commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes#731. Third implementation Story under Quest #717, stacked on #738.

Base is agent/issue-730-terminal-execution, not main, as the Story's verification stack directs. Branched from b2f826ac002eca28813526e18acbd6861b3f8c89, the accepted head of #738.

Why

A <Session.Launch> at the root takes the run's one foreground-terminal lease, so native UIs are sequential by construction — the second waits for the first to close. Inside a <Terminal> that defeats the point of a grid, where every pane stays interactive at the same time. This Story lets an Implementor, a Planner and a reviewer sit in three panes at once, without weakening either of the two ownerships involved.

What changes

Before: a <Session.Launch> written inside a paired <Terminal> asked for the run's foreground lease, which the grid was already holding, and was refused.

After: it uses the pane. Panes launch concurrently; one pane admits one live launch at a time; sequential use of a pane is ordinary composition.

<Terminal.Grid columns={2}>
<Terminaltitle="Planner">
<Session.Launch session="planner">You are the repository planner.</Session.Launch>
</Terminal>
<Terminaltitle="Implementor">
<Session.Launch session="implementor">You are the repository implementor.</Session.Launch>
</Terminal>
<Terminaltitle="Reviewer">
<Session.Launch session="reviewer">You are the repository reviewer.</Session.Launch>
</Terminal>
<Terminaltitle="Shell" />
</Terminal.Grid>

The launch itself is unchanged. It receives no pane prop, token, identifier or mode; its AgentLaunchRequest, its result and its retained agent_session_launch phases are the ones a root launch would have.

How it works

pane scope installs a launcher over the pane's claim
→ <Session.Launch> reserve() → claim.admit() (this pane, not the root lease)
→ flush() → the pane's own text
→ launch(request, spawned) → host launcher; spawned() trips the pane's latch
→ readiness barrier → attach

packages/core/src/terminal/pane-launcher.ts is the whole mechanism: middleware on the existing NativeLauncher Api, installed in the pane's scope and closed over that pane's claim. reserve and flush are answered here and deliberately not delegated — delegating would ask for the root lease the grid is holding. launch delegates the exact request onward and only wraps the start report.

Readiness needed a boundary a launch could report, so NativeLauncherHandler.launch now takes the runtime's child-start event as a parameter — not a request member, not a context, not a return value, which is what the spec requires of the latch. The foreground launcher reports it from the child's own spawn event, before it waits for the exit. nativeLaunch() is unchanged for provider adapters, which hear nothing about the start and so cannot fake one.

A paired pane also flushes what it has rendered so far before the UI draws over it. That is the root flush rule applied in the one place a pane's text goes: composite.display(ordinal, …).

Review guide

Start with:packages/test-agent/src/TerminalGridNativeLaunch.test.md — the authored journey: a 2×2 grid of three native Agent sessions and the host's default shell.

Then review:

  1. packages/core/src/terminal/pane-launcher.ts — the whole delivered mechanism.
  2. packages/runtime/launcher.ts — the start event, and where the foreground launcher reports it.
  3. packages/core/src/expand.tspaneWork(), where the launcher and the pane flush are installed.
  4. packages/test-agent/tests/terminal-grid-native-launch.test.ts — Tier GN, and packages/core/tests/agent-session-launch.test.ts — Tier SP.

Look carefully at:

  • reserve() returning a resource that holds claim.admit() for the launch's whole scope — not just the child's lifetime. That is what keeps the pane held while the session lease around the child is still unwinding, and what refuses a second overlapping launch.
  • The spawned wrapper in launch. Reporting anywhere else — after preparation, after the reservation, on an allocated PID — would present a pane that never started as one that is running.

What must stay true

  • Root <Session.Launch> is unchanged — enforced by installing nothing at the root, checked by SL1–SL18 (all still passing).
  • No pane identity in the launch — enforced by the claim being closed over rather than passed, checked by GN2, which scans the launch requests and the retained agent_session_launch records for the authored pane titles, ordinal and columns.
  • Terminal ownership grants no session — enforced by leaving the coordinator untouched, checked by GN4: two panes naming one logical session still contend, and exactly one retains session-busy.
  • Only a started child makes a pane ready — enforced by the spawned parameter, checked by SP3, SP4 and FL9.
  • A pane is held until everything around its launch is done — enforced by the reserve resource's scope, checked by SP5 and GN9.

How to verify it

deno task test packages/core/tests/agent-session-launch.test.ts
deno task test packages/runtime/tests/native-launcher.test.ts
deno task test packages/test-agent/tests/native-launch.test.ts
deno task test packages/test-agent/tests/terminal-grid-native-launch.test.ts

Tier SP — the pane seam, against a stub provider:

  • SP1 proves a pane launch does not take the root lease, and fails if it delegates reserve.
  • SP2 proves two panes hold their terminals at once: each launch waits for the other to have started, so a serialised pair waits for a start that cannot happen and hangs rather than passing.
  • SP3 proves readiness comes only from the start, and fails if anything earlier trips the latch.
  • SP4 proves a launch that fails before the start shows no partial grid and keeps its completed durable phases.
  • SP5 proves the pane stays held through both halves — refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both.

Tier FL — the foreground launcher, with real children:

  • FL8/FL9 prove the real spawn event is reported once before waiting, and never reported for a child that could not start.

Tier GN — the whole TestAgent stack, real worker over a real ACP connection, deterministic coordinator:

  • GN1 the 2×2 journey: three native sessions and a shell, all four started before attach, none having left by then, in the authored row-major positions and forms.
  • GN2 no pane identity in the launch request or the retained record.
  • GN3 a pane launch never reaches the host's launcher.
  • GN4 two panes naming one session contend; exactly one session-busy, and nothing attaches.
  • GN5 with no terminal provider, nothing starts at all.
  • GN6 a completed grid replays with no provider, launcher or agent contact.
  • GN7 after attachment one UI exits nonzero while its sibling is live: only that pane fails, the sibling is observed alive on the far side of the failure and stops only at reader close, and the grid ends on the pane that failed.
  • GN8 reader close cancels both live launches; neither pane fails, the composite comes down, and a root launch afterwards naming a session a pane held proves both leases came back.
  • GN9 a pane admits its next user only once the last one is wholly done.
  • GN10 a grid interrupted with a live pane launch, resumed on the same journal: fresh composite, native child restarted on the retained identity, nothing prepared again, retained record deep-equal.

Each claim was broken on purpose and re-run: removing the pane launcher fails SP1–SP4 and GN1–GN4, GN7–GN9; never reporting readiness fails SP1, SP2, SP3 and SP5; releasing the pane as soon as it is taken fails SP5 and only SP5.

Scope

Included

  • The pane-scoped native launcher, and its installation in paired panes.
  • The runtime child-start event on NativeLauncherHandler.launch.
  • A pane flush, so a pane's rendered text reaches the reader before the UI covers it.
  • The authored 2×2 journey and its three scenario documents.

Intentionally unchanged

  • tmux.Open terminal grids with tmux in foreground runs #732 owns the provider; this Story proves the contract against a controlled one.
  • Agent advertisements. No agent is advertised or de-advertised here.
  • The session coordinator, its natural key, and the ACPX construction routes.
  • nativeLaunch()'s signature, so no provider adapter changed.

New abstractions

  • usePaneNativeLauncher(claim, flush) exists because a pane's terminal has to answer reserve and flush for whatever is written in it, and <Session.Launch> must not learn that it is in a pane. Its consumer is paneWork() in expand.ts.
  • ControlledLauncherOptions.start exists so a suite can decide whether a launch starts at all — reporting is what a successful start does, throwing without reporting is what a failure before the start does. Its consumers are Tier SP and Tier GN.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Session.Launch>` written at the root takes the run's one foreground
terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat
the point of a grid, where every pane is interactive at the same time.
So core installs a native launcher in each paired pane's scope, closed over that
pane's claim. `<Session.Launch>` finds it by being written there: it is handed
no pane, ordinal, token or mode, and its request, result and retained phases are
the ones a root launch would have. What changes is which terminal answers
`reserve` and `flush` — the pane's, through its claim, so two panes do not
contend and one pane admits one live launch at a time. A pane also flushes what
it has rendered before the UI draws over it, which is the root rule in the one
place a pane's text goes.
Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch`
takes the runtime's child-start event as a parameter — not a request member, not
a context, not a result — and the foreground launcher reports it from the
child's own `spawn` event, before it waits for the exit. The pane launcher
listens and trips its claim's latch there and nowhere else: preparation, the
reservation, the flush and an allocated PID are not a start, and a child that
never ran never reports one. `nativeLaunch()` is unchanged for adapters, which
hear nothing about the start.
Terminal ownership and Agent-session ownership stay independent. Nothing pane-
derived enters the coordinator key, the launch request, the retained record or a
diagnostic, and two panes naming one logical session still contend through the
existing non-waiting coordinator.
No tmux, no new Agent advertisement, and root launch behavior is unchanged.
Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness,
a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime
launcher (the start event, and a child that never starts), and Tier GN over the
checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent
stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting
readiness fails SP1, SP2, SP3 and SP5.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown

PR #741: ✨ Launch native Agent sessions in independent terminal panes (#731)

12 files, +1508 / -34

Scope

🔴 PR has 1542 lines changed. Split into focused PRs.

🟡 1542 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×10: packages/core/src/expand.ts, packages/acp/src/provider.ts
  • no-empty-function ×3: packages/runtime/launcher.ts, packages/acp/src/provider.ts
  • no-unnecessary-type-assertion ×2: packages/core/src/expand.ts
  • no-redundant-type-constituents ×1: packages/runtime/launcher.ts

Slop

  • packages/acp/src/provider.ts:2766// path there is.
  • packages/acp/src/provider.ts:2787// deliberately.
  • packages/core/src/expand.ts:2254// makes this pane ready.
  • packages/runtime/launcher.ts:267// reports having started.

Oxlint slop signals:

  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 51 diagnostics across 3 files (15 rules)
Density: 0.034 violations/added-line

no-unused-vars (10): packages/core/src/expand.ts, packages/acp/src/provider.ts
consistent-function-scoping (10): packages/acp/src/provider.ts
no-shadow (5): packages/core/src/expand.ts, packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/expand.ts, packages/acp/src/provider.ts
no-empty-function (3): packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-useless-spread (3): packages/acp/src/provider.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-floating-promises (2): packages/acp/src/provider.ts
no-unnecessary-type-assertion (2): packages/core/src/expand.ts
unbound-method (2): packages/core/src/expand.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-redundant-type-constituents (1): packages/runtime/launcher.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

No production change. The pane-scoped launcher, the runtime spawn callback, the
authority boundaries and root-launch behavior are exactly as reviewed.
The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent
sessions and the host's default shell. All four children report their start
before the composite attaches, each waits for its siblings while holding its own
pane, and the row reads back the authored row-major positions and forms.
Four rows added, all driven by signals this run produced:
- GN7: after attachment one native UI exits nonzero while its sibling is live.
Only that pane fails; the sibling is observed alive on the far side of the
failure and stops only when the reader leaves; the grid ends on the pane that
failed, and the close's cancellation is not a second failure.
- GN8: the reader leaves with both launches live. Both are cancelled where they
stood, neither pane fails, the composite comes down — and a root launch after
the grid, naming a session a pane held, proves both leases came back. Which
refusal it gets is the proof: not "already holds this run's terminal", not
"another owner is using session", but the #517 recovery tombstone a cancelled
native UI leaves behind.
- GN9: a pane admits its next user only once the last one is wholly done, with
the launch and the prompt that follows it going through the real coordinator.
- GN10: a grid interrupted with a live pane launch, resumed on the same journal.
It rebuilds the composite, starts the native child on the identity the first
attempt retained, prepares nothing, and the retained record comes back
unchanged — identity, route, binding and phase alike.
SP5 now proves the pane stays held through both halves: refused while the child
is live, refused again once the child has gone but the lease around it is still
unwinding, admitted only after both. A launch that merely returned showed only
the first.
Two harness repairs. Pane states are read as a set of panes rather than a count
of messages — a pane still live when the reader leaves is told twice, once from
the outcome close decided and once from its own settlement, and that is display
rather than a second settlement. And a generated variant is written to a
directory of its own with copies of the scenarios it names, so a killed run
leaves nothing in the repository.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

The harness's interrupted branch built a `Result` as an object literal and cast
it. Effection has a constructor for exactly that, so it uses it: no cast, and
the type is the constructor's rather than an assertion's.
Behavior and evidence are unchanged.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

An orderly cancellation that finished proves everything a normal return proves,
and must say so. It did not: `ownership.quiesced()` was a statement after
`authority.perform()`, and cancellation unwinds past every statement after the
operation it cancels. A reader closing a terminal grid therefore left every
session its panes had launched carrying a recovery tombstone, and the next owner
was told to recover a session nothing was using.
The launch now runs in a scope the ownership body owns, and the acknowledgement
is that scope's cleanup — reached on every path there is, cancellation included.
It brings the launch down deliberately and reads the outcome of doing so, so the
two facts it needs are facts rather than inferences: the native child and its
cleanup settled, and this provider holds no handle for the session. A teardown
that could not prove the child stopped throws out of `destroy()` and is not
quiescence — and is still a failure, so it propagates rather than passing
quietly.
Nothing grid-specific reaches the provider. Reader close is the ordinary launch
cancellation path, and this is the ordinary launch cancellation path's rule.
The conservative cases keep their tombstone: a detach that failed or a session
prepared and never handed over leaves a handle, and a child or provider cleanup
that failed leaves the acknowledgement unmade. Cancellation, a released lease, a
PID and elapsed time still prove nothing on their own.
CX1 asserted the behavior this replaces — that a cancelled launch stays owned —
so it now asserts the accepted one. CX2 is new and holds the other half: a
cleanup that could not finish withholds quiescence, and the record stays active.
GN8 is rebuilt as directed: two pane children held on unresolved operations,
signals from each child's own teardown, teardown proven to finish after both,
and a root launch afterwards on one of the same logical sessions that acquires
ownership and starts — receiving neither session-busy nor
session-recovery-required, and reclaiming the root foreground lease as it goes.
Broken on purpose and re-run: acknowledging only on a normal return fails CX1
and GN8; acknowledging without proving the cleanup settled fails CX2, and only
CX2.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 4 redundant comments. Inline suggestions to remove them below.

// the reader closing a terminal grid is one — unwinds past every
// statement after it, so a decision written down here would never be
// reached; written as this scope's cleanup, it is reached on every
// path there is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// path there is.

// prepared and never handed over, leaves one. Either one
// missing leaves the session owned rather than looking
// finished, which is what the next owner is told to recover
// deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// deliberately.

// A `<Session.Launch>` written in this pane finds this launcher simply
// by being here: it reserves and flushes this pane instead of competing
// for the run's one foreground lease, and the child it starts is what
// makes this pane ready.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// makes this pane ready.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

✨ Launch native Agent sessions in independent terminal panes (#731) - #741

Draft
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch
Draft

✨ Launch native Agent sessions in independent terminal panes (#731)#741
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch

Conversation

@taras

@tarastaras commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes#731. Third implementation Story under Quest #717, stacked on #738.

Base is agent/issue-730-terminal-execution, not main, as the Story's verification stack directs. Branched from b2f826ac002eca28813526e18acbd6861b3f8c89, the accepted head of #738.

Why

A <Session.Launch> at the root takes the run's one foreground-terminal lease, so native UIs are sequential by construction — the second waits for the first to close. Inside a <Terminal> that defeats the point of a grid, where every pane stays interactive at the same time. This Story lets an Implementor, a Planner and a reviewer sit in three panes at once, without weakening either of the two ownerships involved.

What changes

Before: a <Session.Launch> written inside a paired <Terminal> asked for the run's foreground lease, which the grid was already holding, and was refused.

After: it uses the pane. Panes launch concurrently; one pane admits one live launch at a time; sequential use of a pane is ordinary composition.

<Terminal.Grid columns={2}>
<Terminaltitle="Planner">
<Session.Launch session="planner">You are the repository planner.</Session.Launch>
</Terminal>
<Terminaltitle="Implementor">
<Session.Launch session="implementor">You are the repository implementor.</Session.Launch>
</Terminal>
<Terminaltitle="Reviewer">
<Session.Launch session="reviewer">You are the repository reviewer.</Session.Launch>
</Terminal>
<Terminaltitle="Shell" />
</Terminal.Grid>

The launch itself is unchanged. It receives no pane prop, token, identifier or mode; its AgentLaunchRequest, its result and its retained agent_session_launch phases are the ones a root launch would have.

How it works

pane scope installs a launcher over the pane's claim
→ <Session.Launch> reserve() → claim.admit() (this pane, not the root lease)
→ flush() → the pane's own text
→ launch(request, spawned) → host launcher; spawned() trips the pane's latch
→ readiness barrier → attach

packages/core/src/terminal/pane-launcher.ts is the whole mechanism: middleware on the existing NativeLauncher Api, installed in the pane's scope and closed over that pane's claim. reserve and flush are answered here and deliberately not delegated — delegating would ask for the root lease the grid is holding. launch delegates the exact request onward and only wraps the start report.

Readiness needed a boundary a launch could report, so NativeLauncherHandler.launch now takes the runtime's child-start event as a parameter — not a request member, not a context, not a return value, which is what the spec requires of the latch. The foreground launcher reports it from the child's own spawn event, before it waits for the exit. nativeLaunch() is unchanged for provider adapters, which hear nothing about the start and so cannot fake one.

A paired pane also flushes what it has rendered so far before the UI draws over it. That is the root flush rule applied in the one place a pane's text goes: composite.display(ordinal, …).

Review guide

Start with:packages/test-agent/src/TerminalGridNativeLaunch.test.md — the authored journey: a 2×2 grid of three native Agent sessions and the host's default shell.

Then review:

  1. packages/core/src/terminal/pane-launcher.ts — the whole delivered mechanism.
  2. packages/runtime/launcher.ts — the start event, and where the foreground launcher reports it.
  3. packages/core/src/expand.tspaneWork(), where the launcher and the pane flush are installed.
  4. packages/test-agent/tests/terminal-grid-native-launch.test.ts — Tier GN, and packages/core/tests/agent-session-launch.test.ts — Tier SP.

Look carefully at:

  • reserve() returning a resource that holds claim.admit() for the launch's whole scope — not just the child's lifetime. That is what keeps the pane held while the session lease around the child is still unwinding, and what refuses a second overlapping launch.
  • The spawned wrapper in launch. Reporting anywhere else — after preparation, after the reservation, on an allocated PID — would present a pane that never started as one that is running.

What must stay true

  • Root <Session.Launch> is unchanged — enforced by installing nothing at the root, checked by SL1–SL18 (all still passing).
  • No pane identity in the launch — enforced by the claim being closed over rather than passed, checked by GN2, which scans the launch requests and the retained agent_session_launch records for the authored pane titles, ordinal and columns.
  • Terminal ownership grants no session — enforced by leaving the coordinator untouched, checked by GN4: two panes naming one logical session still contend, and exactly one retains session-busy.
  • Only a started child makes a pane ready — enforced by the spawned parameter, checked by SP3, SP4 and FL9.
  • A pane is held until everything around its launch is done — enforced by the reserve resource's scope, checked by SP5 and GN9.

How to verify it

deno task test packages/core/tests/agent-session-launch.test.ts
deno task test packages/runtime/tests/native-launcher.test.ts
deno task test packages/test-agent/tests/native-launch.test.ts
deno task test packages/test-agent/tests/terminal-grid-native-launch.test.ts

Tier SP — the pane seam, against a stub provider:

  • SP1 proves a pane launch does not take the root lease, and fails if it delegates reserve.
  • SP2 proves two panes hold their terminals at once: each launch waits for the other to have started, so a serialised pair waits for a start that cannot happen and hangs rather than passing.
  • SP3 proves readiness comes only from the start, and fails if anything earlier trips the latch.
  • SP4 proves a launch that fails before the start shows no partial grid and keeps its completed durable phases.
  • SP5 proves the pane stays held through both halves — refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both.

Tier FL — the foreground launcher, with real children:

  • FL8/FL9 prove the real spawn event is reported once before waiting, and never reported for a child that could not start.

Tier GN — the whole TestAgent stack, real worker over a real ACP connection, deterministic coordinator:

  • GN1 the 2×2 journey: three native sessions and a shell, all four started before attach, none having left by then, in the authored row-major positions and forms.
  • GN2 no pane identity in the launch request or the retained record.
  • GN3 a pane launch never reaches the host's launcher.
  • GN4 two panes naming one session contend; exactly one session-busy, and nothing attaches.
  • GN5 with no terminal provider, nothing starts at all.
  • GN6 a completed grid replays with no provider, launcher or agent contact.
  • GN7 after attachment one UI exits nonzero while its sibling is live: only that pane fails, the sibling is observed alive on the far side of the failure and stops only at reader close, and the grid ends on the pane that failed.
  • GN8 reader close cancels both live launches; neither pane fails, the composite comes down, and a root launch afterwards naming a session a pane held proves both leases came back.
  • GN9 a pane admits its next user only once the last one is wholly done.
  • GN10 a grid interrupted with a live pane launch, resumed on the same journal: fresh composite, native child restarted on the retained identity, nothing prepared again, retained record deep-equal.

Each claim was broken on purpose and re-run: removing the pane launcher fails SP1–SP4 and GN1–GN4, GN7–GN9; never reporting readiness fails SP1, SP2, SP3 and SP5; releasing the pane as soon as it is taken fails SP5 and only SP5.

Scope

Included

  • The pane-scoped native launcher, and its installation in paired panes.
  • The runtime child-start event on NativeLauncherHandler.launch.
  • A pane flush, so a pane's rendered text reaches the reader before the UI covers it.
  • The authored 2×2 journey and its three scenario documents.

Intentionally unchanged

  • tmux.Open terminal grids with tmux in foreground runs #732 owns the provider; this Story proves the contract against a controlled one.
  • Agent advertisements. No agent is advertised or de-advertised here.
  • The session coordinator, its natural key, and the ACPX construction routes.
  • nativeLaunch()'s signature, so no provider adapter changed.

New abstractions

  • usePaneNativeLauncher(claim, flush) exists because a pane's terminal has to answer reserve and flush for whatever is written in it, and <Session.Launch> must not learn that it is in a pane. Its consumer is paneWork() in expand.ts.
  • ControlledLauncherOptions.start exists so a suite can decide whether a launch starts at all — reporting is what a successful start does, throwing without reporting is what a failure before the start does. Its consumers are Tier SP and Tier GN.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Session.Launch>` written at the root takes the run's one foreground
terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat
the point of a grid, where every pane is interactive at the same time.
So core installs a native launcher in each paired pane's scope, closed over that
pane's claim. `<Session.Launch>` finds it by being written there: it is handed
no pane, ordinal, token or mode, and its request, result and retained phases are
the ones a root launch would have. What changes is which terminal answers
`reserve` and `flush` — the pane's, through its claim, so two panes do not
contend and one pane admits one live launch at a time. A pane also flushes what
it has rendered before the UI draws over it, which is the root rule in the one
place a pane's text goes.
Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch`
takes the runtime's child-start event as a parameter — not a request member, not
a context, not a result — and the foreground launcher reports it from the
child's own `spawn` event, before it waits for the exit. The pane launcher
listens and trips its claim's latch there and nowhere else: preparation, the
reservation, the flush and an allocated PID are not a start, and a child that
never ran never reports one. `nativeLaunch()` is unchanged for adapters, which
hear nothing about the start.
Terminal ownership and Agent-session ownership stay independent. Nothing pane-
derived enters the coordinator key, the launch request, the retained record or a
diagnostic, and two panes naming one logical session still contend through the
existing non-waiting coordinator.
No tmux, no new Agent advertisement, and root launch behavior is unchanged.
Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness,
a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime
launcher (the start event, and a child that never starts), and Tier GN over the
checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent
stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting
readiness fails SP1, SP2, SP3 and SP5.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown

PR #741: ✨ Launch native Agent sessions in independent terminal panes (#731)

12 files, +1508 / -34

Scope

🔴 PR has 1542 lines changed. Split into focused PRs.

🟡 1542 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×10: packages/core/src/expand.ts, packages/acp/src/provider.ts
  • no-empty-function ×3: packages/runtime/launcher.ts, packages/acp/src/provider.ts
  • no-unnecessary-type-assertion ×2: packages/core/src/expand.ts
  • no-redundant-type-constituents ×1: packages/runtime/launcher.ts

Slop

  • packages/acp/src/provider.ts:2766// path there is.
  • packages/acp/src/provider.ts:2787// deliberately.
  • packages/core/src/expand.ts:2254// makes this pane ready.
  • packages/runtime/launcher.ts:267// reports having started.

Oxlint slop signals:

  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 51 diagnostics across 3 files (15 rules)
Density: 0.034 violations/added-line

no-unused-vars (10): packages/core/src/expand.ts, packages/acp/src/provider.ts
consistent-function-scoping (10): packages/acp/src/provider.ts
no-shadow (5): packages/core/src/expand.ts, packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/expand.ts, packages/acp/src/provider.ts
no-empty-function (3): packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-useless-spread (3): packages/acp/src/provider.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-floating-promises (2): packages/acp/src/provider.ts
no-unnecessary-type-assertion (2): packages/core/src/expand.ts
unbound-method (2): packages/core/src/expand.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-redundant-type-constituents (1): packages/runtime/launcher.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

No production change. The pane-scoped launcher, the runtime spawn callback, the
authority boundaries and root-launch behavior are exactly as reviewed.
The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent
sessions and the host's default shell. All four children report their start
before the composite attaches, each waits for its siblings while holding its own
pane, and the row reads back the authored row-major positions and forms.
Four rows added, all driven by signals this run produced:
- GN7: after attachment one native UI exits nonzero while its sibling is live.
Only that pane fails; the sibling is observed alive on the far side of the
failure and stops only when the reader leaves; the grid ends on the pane that
failed, and the close's cancellation is not a second failure.
- GN8: the reader leaves with both launches live. Both are cancelled where they
stood, neither pane fails, the composite comes down — and a root launch after
the grid, naming a session a pane held, proves both leases came back. Which
refusal it gets is the proof: not "already holds this run's terminal", not
"another owner is using session", but the #517 recovery tombstone a cancelled
native UI leaves behind.
- GN9: a pane admits its next user only once the last one is wholly done, with
the launch and the prompt that follows it going through the real coordinator.
- GN10: a grid interrupted with a live pane launch, resumed on the same journal.
It rebuilds the composite, starts the native child on the identity the first
attempt retained, prepares nothing, and the retained record comes back
unchanged — identity, route, binding and phase alike.
SP5 now proves the pane stays held through both halves: refused while the child
is live, refused again once the child has gone but the lease around it is still
unwinding, admitted only after both. A launch that merely returned showed only
the first.
Two harness repairs. Pane states are read as a set of panes rather than a count
of messages — a pane still live when the reader leaves is told twice, once from
the outcome close decided and once from its own settlement, and that is display
rather than a second settlement. And a generated variant is written to a
directory of its own with copies of the scenarios it names, so a killed run
leaves nothing in the repository.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

The harness's interrupted branch built a `Result` as an object literal and cast
it. Effection has a constructor for exactly that, so it uses it: no cast, and
the type is the constructor's rather than an assertion's.
Behavior and evidence are unchanged.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

An orderly cancellation that finished proves everything a normal return proves,
and must say so. It did not: `ownership.quiesced()` was a statement after
`authority.perform()`, and cancellation unwinds past every statement after the
operation it cancels. A reader closing a terminal grid therefore left every
session its panes had launched carrying a recovery tombstone, and the next owner
was told to recover a session nothing was using.
The launch now runs in a scope the ownership body owns, and the acknowledgement
is that scope's cleanup — reached on every path there is, cancellation included.
It brings the launch down deliberately and reads the outcome of doing so, so the
two facts it needs are facts rather than inferences: the native child and its
cleanup settled, and this provider holds no handle for the session. A teardown
that could not prove the child stopped throws out of `destroy()` and is not
quiescence — and is still a failure, so it propagates rather than passing
quietly.
Nothing grid-specific reaches the provider. Reader close is the ordinary launch
cancellation path, and this is the ordinary launch cancellation path's rule.
The conservative cases keep their tombstone: a detach that failed or a session
prepared and never handed over leaves a handle, and a child or provider cleanup
that failed leaves the acknowledgement unmade. Cancellation, a released lease, a
PID and elapsed time still prove nothing on their own.
CX1 asserted the behavior this replaces — that a cancelled launch stays owned —
so it now asserts the accepted one. CX2 is new and holds the other half: a
cleanup that could not finish withholds quiescence, and the record stays active.
GN8 is rebuilt as directed: two pane children held on unresolved operations,
signals from each child's own teardown, teardown proven to finish after both,
and a root launch afterwards on one of the same logical sessions that acquires
ownership and starts — receiving neither session-busy nor
session-recovery-required, and reclaiming the root foreground lease as it goes.
Broken on purpose and re-run: acknowledging only on a normal return fails CX1
and GN8; acknowledging without proving the cleanup settled fails CX2, and only
CX2.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 4 redundant comments. Inline suggestions to remove them below.

// the reader closing a terminal grid is one — unwinds past every
// statement after it, so a decision written down here would never be
// reached; written as this scope's cleanup, it is reached on every
// path there is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// path there is.

// prepared and never handed over, leaves one. Either one
// missing leaves the session owned rather than looking
// finished, which is what the next owner is told to recover
// deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// deliberately.

// A `<Session.Launch>` written in this pane finds this launcher simply
// by being here: it reserves and flushes this pane instead of competing
// for the run's one foreground lease, and the child it starts is what
// makes this pane ready.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// makes this pane ready.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

✨ Launch native Agent sessions in independent terminal panes (#731) - #741

Draft
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch
Draft

✨ Launch native Agent sessions in independent terminal panes (#731)#741
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch

Conversation

@taras

@tarastaras commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes#731. Third implementation Story under Quest #717, stacked on #738.

Base is agent/issue-730-terminal-execution, not main, as the Story's verification stack directs. Branched from b2f826ac002eca28813526e18acbd6861b3f8c89, the accepted head of #738.

Why

A <Session.Launch> at the root takes the run's one foreground-terminal lease, so native UIs are sequential by construction — the second waits for the first to close. Inside a <Terminal> that defeats the point of a grid, where every pane stays interactive at the same time. This Story lets an Implementor, a Planner and a reviewer sit in three panes at once, without weakening either of the two ownerships involved.

What changes

Before: a <Session.Launch> written inside a paired <Terminal> asked for the run's foreground lease, which the grid was already holding, and was refused.

After: it uses the pane. Panes launch concurrently; one pane admits one live launch at a time; sequential use of a pane is ordinary composition.

<Terminal.Grid columns={2}>
<Terminaltitle="Planner">
<Session.Launch session="planner">You are the repository planner.</Session.Launch>
</Terminal>
<Terminaltitle="Implementor">
<Session.Launch session="implementor">You are the repository implementor.</Session.Launch>
</Terminal>
<Terminaltitle="Reviewer">
<Session.Launch session="reviewer">You are the repository reviewer.</Session.Launch>
</Terminal>
<Terminaltitle="Shell" />
</Terminal.Grid>

The launch itself is unchanged. It receives no pane prop, token, identifier or mode; its AgentLaunchRequest, its result and its retained agent_session_launch phases are the ones a root launch would have.

How it works

pane scope installs a launcher over the pane's claim
→ <Session.Launch> reserve() → claim.admit() (this pane, not the root lease)
→ flush() → the pane's own text
→ launch(request, spawned) → host launcher; spawned() trips the pane's latch
→ readiness barrier → attach

packages/core/src/terminal/pane-launcher.ts is the whole mechanism: middleware on the existing NativeLauncher Api, installed in the pane's scope and closed over that pane's claim. reserve and flush are answered here and deliberately not delegated — delegating would ask for the root lease the grid is holding. launch delegates the exact request onward and only wraps the start report.

Readiness needed a boundary a launch could report, so NativeLauncherHandler.launch now takes the runtime's child-start event as a parameter — not a request member, not a context, not a return value, which is what the spec requires of the latch. The foreground launcher reports it from the child's own spawn event, before it waits for the exit. nativeLaunch() is unchanged for provider adapters, which hear nothing about the start and so cannot fake one.

A paired pane also flushes what it has rendered so far before the UI draws over it. That is the root flush rule applied in the one place a pane's text goes: composite.display(ordinal, …).

Review guide

Start with:packages/test-agent/src/TerminalGridNativeLaunch.test.md — the authored journey: a 2×2 grid of three native Agent sessions and the host's default shell.

Then review:

  1. packages/core/src/terminal/pane-launcher.ts — the whole delivered mechanism.
  2. packages/runtime/launcher.ts — the start event, and where the foreground launcher reports it.
  3. packages/core/src/expand.tspaneWork(), where the launcher and the pane flush are installed.
  4. packages/test-agent/tests/terminal-grid-native-launch.test.ts — Tier GN, and packages/core/tests/agent-session-launch.test.ts — Tier SP.

Look carefully at:

  • reserve() returning a resource that holds claim.admit() for the launch's whole scope — not just the child's lifetime. That is what keeps the pane held while the session lease around the child is still unwinding, and what refuses a second overlapping launch.
  • The spawned wrapper in launch. Reporting anywhere else — after preparation, after the reservation, on an allocated PID — would present a pane that never started as one that is running.

What must stay true

  • Root <Session.Launch> is unchanged — enforced by installing nothing at the root, checked by SL1–SL18 (all still passing).
  • No pane identity in the launch — enforced by the claim being closed over rather than passed, checked by GN2, which scans the launch requests and the retained agent_session_launch records for the authored pane titles, ordinal and columns.
  • Terminal ownership grants no session — enforced by leaving the coordinator untouched, checked by GN4: two panes naming one logical session still contend, and exactly one retains session-busy.
  • Only a started child makes a pane ready — enforced by the spawned parameter, checked by SP3, SP4 and FL9.
  • A pane is held until everything around its launch is done — enforced by the reserve resource's scope, checked by SP5 and GN9.

How to verify it

deno task test packages/core/tests/agent-session-launch.test.ts
deno task test packages/runtime/tests/native-launcher.test.ts
deno task test packages/test-agent/tests/native-launch.test.ts
deno task test packages/test-agent/tests/terminal-grid-native-launch.test.ts

Tier SP — the pane seam, against a stub provider:

  • SP1 proves a pane launch does not take the root lease, and fails if it delegates reserve.
  • SP2 proves two panes hold their terminals at once: each launch waits for the other to have started, so a serialised pair waits for a start that cannot happen and hangs rather than passing.
  • SP3 proves readiness comes only from the start, and fails if anything earlier trips the latch.
  • SP4 proves a launch that fails before the start shows no partial grid and keeps its completed durable phases.
  • SP5 proves the pane stays held through both halves — refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both.

Tier FL — the foreground launcher, with real children:

  • FL8/FL9 prove the real spawn event is reported once before waiting, and never reported for a child that could not start.

Tier GN — the whole TestAgent stack, real worker over a real ACP connection, deterministic coordinator:

  • GN1 the 2×2 journey: three native sessions and a shell, all four started before attach, none having left by then, in the authored row-major positions and forms.
  • GN2 no pane identity in the launch request or the retained record.
  • GN3 a pane launch never reaches the host's launcher.
  • GN4 two panes naming one session contend; exactly one session-busy, and nothing attaches.
  • GN5 with no terminal provider, nothing starts at all.
  • GN6 a completed grid replays with no provider, launcher or agent contact.
  • GN7 after attachment one UI exits nonzero while its sibling is live: only that pane fails, the sibling is observed alive on the far side of the failure and stops only at reader close, and the grid ends on the pane that failed.
  • GN8 reader close cancels both live launches; neither pane fails, the composite comes down, and a root launch afterwards naming a session a pane held proves both leases came back.
  • GN9 a pane admits its next user only once the last one is wholly done.
  • GN10 a grid interrupted with a live pane launch, resumed on the same journal: fresh composite, native child restarted on the retained identity, nothing prepared again, retained record deep-equal.

Each claim was broken on purpose and re-run: removing the pane launcher fails SP1–SP4 and GN1–GN4, GN7–GN9; never reporting readiness fails SP1, SP2, SP3 and SP5; releasing the pane as soon as it is taken fails SP5 and only SP5.

Scope

Included

  • The pane-scoped native launcher, and its installation in paired panes.
  • The runtime child-start event on NativeLauncherHandler.launch.
  • A pane flush, so a pane's rendered text reaches the reader before the UI covers it.
  • The authored 2×2 journey and its three scenario documents.

Intentionally unchanged

  • tmux.Open terminal grids with tmux in foreground runs #732 owns the provider; this Story proves the contract against a controlled one.
  • Agent advertisements. No agent is advertised or de-advertised here.
  • The session coordinator, its natural key, and the ACPX construction routes.
  • nativeLaunch()'s signature, so no provider adapter changed.

New abstractions

  • usePaneNativeLauncher(claim, flush) exists because a pane's terminal has to answer reserve and flush for whatever is written in it, and <Session.Launch> must not learn that it is in a pane. Its consumer is paneWork() in expand.ts.
  • ControlledLauncherOptions.start exists so a suite can decide whether a launch starts at all — reporting is what a successful start does, throwing without reporting is what a failure before the start does. Its consumers are Tier SP and Tier GN.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Session.Launch>` written at the root takes the run's one foreground
terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat
the point of a grid, where every pane is interactive at the same time.
So core installs a native launcher in each paired pane's scope, closed over that
pane's claim. `<Session.Launch>` finds it by being written there: it is handed
no pane, ordinal, token or mode, and its request, result and retained phases are
the ones a root launch would have. What changes is which terminal answers
`reserve` and `flush` — the pane's, through its claim, so two panes do not
contend and one pane admits one live launch at a time. A pane also flushes what
it has rendered before the UI draws over it, which is the root rule in the one
place a pane's text goes.
Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch`
takes the runtime's child-start event as a parameter — not a request member, not
a context, not a result — and the foreground launcher reports it from the
child's own `spawn` event, before it waits for the exit. The pane launcher
listens and trips its claim's latch there and nowhere else: preparation, the
reservation, the flush and an allocated PID are not a start, and a child that
never ran never reports one. `nativeLaunch()` is unchanged for adapters, which
hear nothing about the start.
Terminal ownership and Agent-session ownership stay independent. Nothing pane-
derived enters the coordinator key, the launch request, the retained record or a
diagnostic, and two panes naming one logical session still contend through the
existing non-waiting coordinator.
No tmux, no new Agent advertisement, and root launch behavior is unchanged.
Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness,
a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime
launcher (the start event, and a child that never starts), and Tier GN over the
checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent
stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting
readiness fails SP1, SP2, SP3 and SP5.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown

PR #741: ✨ Launch native Agent sessions in independent terminal panes (#731)

12 files, +1508 / -34

Scope

🔴 PR has 1542 lines changed. Split into focused PRs.

🟡 1542 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×10: packages/core/src/expand.ts, packages/acp/src/provider.ts
  • no-empty-function ×3: packages/runtime/launcher.ts, packages/acp/src/provider.ts
  • no-unnecessary-type-assertion ×2: packages/core/src/expand.ts
  • no-redundant-type-constituents ×1: packages/runtime/launcher.ts

Slop

  • packages/acp/src/provider.ts:2766// path there is.
  • packages/acp/src/provider.ts:2787// deliberately.
  • packages/core/src/expand.ts:2254// makes this pane ready.
  • packages/runtime/launcher.ts:267// reports having started.

Oxlint slop signals:

  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 51 diagnostics across 3 files (15 rules)
Density: 0.034 violations/added-line

no-unused-vars (10): packages/core/src/expand.ts, packages/acp/src/provider.ts
consistent-function-scoping (10): packages/acp/src/provider.ts
no-shadow (5): packages/core/src/expand.ts, packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/expand.ts, packages/acp/src/provider.ts
no-empty-function (3): packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-useless-spread (3): packages/acp/src/provider.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-floating-promises (2): packages/acp/src/provider.ts
no-unnecessary-type-assertion (2): packages/core/src/expand.ts
unbound-method (2): packages/core/src/expand.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-redundant-type-constituents (1): packages/runtime/launcher.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

No production change. The pane-scoped launcher, the runtime spawn callback, the
authority boundaries and root-launch behavior are exactly as reviewed.
The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent
sessions and the host's default shell. All four children report their start
before the composite attaches, each waits for its siblings while holding its own
pane, and the row reads back the authored row-major positions and forms.
Four rows added, all driven by signals this run produced:
- GN7: after attachment one native UI exits nonzero while its sibling is live.
Only that pane fails; the sibling is observed alive on the far side of the
failure and stops only when the reader leaves; the grid ends on the pane that
failed, and the close's cancellation is not a second failure.
- GN8: the reader leaves with both launches live. Both are cancelled where they
stood, neither pane fails, the composite comes down — and a root launch after
the grid, naming a session a pane held, proves both leases came back. Which
refusal it gets is the proof: not "already holds this run's terminal", not
"another owner is using session", but the #517 recovery tombstone a cancelled
native UI leaves behind.
- GN9: a pane admits its next user only once the last one is wholly done, with
the launch and the prompt that follows it going through the real coordinator.
- GN10: a grid interrupted with a live pane launch, resumed on the same journal.
It rebuilds the composite, starts the native child on the identity the first
attempt retained, prepares nothing, and the retained record comes back
unchanged — identity, route, binding and phase alike.
SP5 now proves the pane stays held through both halves: refused while the child
is live, refused again once the child has gone but the lease around it is still
unwinding, admitted only after both. A launch that merely returned showed only
the first.
Two harness repairs. Pane states are read as a set of panes rather than a count
of messages — a pane still live when the reader leaves is told twice, once from
the outcome close decided and once from its own settlement, and that is display
rather than a second settlement. And a generated variant is written to a
directory of its own with copies of the scenarios it names, so a killed run
leaves nothing in the repository.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

The harness's interrupted branch built a `Result` as an object literal and cast
it. Effection has a constructor for exactly that, so it uses it: no cast, and
the type is the constructor's rather than an assertion's.
Behavior and evidence are unchanged.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

An orderly cancellation that finished proves everything a normal return proves,
and must say so. It did not: `ownership.quiesced()` was a statement after
`authority.perform()`, and cancellation unwinds past every statement after the
operation it cancels. A reader closing a terminal grid therefore left every
session its panes had launched carrying a recovery tombstone, and the next owner
was told to recover a session nothing was using.
The launch now runs in a scope the ownership body owns, and the acknowledgement
is that scope's cleanup — reached on every path there is, cancellation included.
It brings the launch down deliberately and reads the outcome of doing so, so the
two facts it needs are facts rather than inferences: the native child and its
cleanup settled, and this provider holds no handle for the session. A teardown
that could not prove the child stopped throws out of `destroy()` and is not
quiescence — and is still a failure, so it propagates rather than passing
quietly.
Nothing grid-specific reaches the provider. Reader close is the ordinary launch
cancellation path, and this is the ordinary launch cancellation path's rule.
The conservative cases keep their tombstone: a detach that failed or a session
prepared and never handed over leaves a handle, and a child or provider cleanup
that failed leaves the acknowledgement unmade. Cancellation, a released lease, a
PID and elapsed time still prove nothing on their own.
CX1 asserted the behavior this replaces — that a cancelled launch stays owned —
so it now asserts the accepted one. CX2 is new and holds the other half: a
cleanup that could not finish withholds quiescence, and the record stays active.
GN8 is rebuilt as directed: two pane children held on unresolved operations,
signals from each child's own teardown, teardown proven to finish after both,
and a root launch afterwards on one of the same logical sessions that acquires
ownership and starts — receiving neither session-busy nor
session-recovery-required, and reclaiming the root foreground lease as it goes.
Broken on purpose and re-run: acknowledging only on a normal return fails CX1
and GN8; acknowledging without proving the cleanup settled fails CX2, and only
CX2.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 4 redundant comments. Inline suggestions to remove them below.

// the reader closing a terminal grid is one — unwinds past every
// statement after it, so a decision written down here would never be
// reached; written as this scope's cleanup, it is reached on every
// path there is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// path there is.

// prepared and never handed over, leaves one. Either one
// missing leaves the session owned rather than looking
// finished, which is what the next owner is told to recover
// deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// deliberately.

// A `<Session.Launch>` written in this pane finds this launcher simply
// by being here: it reserves and flushes this pane instead of competing
// for the run's one foreground lease, and the child it starts is what
// makes this pane ready.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// makes this pane ready.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

✨ Launch native Agent sessions in independent terminal panes (#731) - #741

Draft
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch
Draft

✨ Launch native Agent sessions in independent terminal panes (#731)#741
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch

Conversation

@taras

@tarastaras commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes#731. Third implementation Story under Quest #717, stacked on #738.

Base is agent/issue-730-terminal-execution, not main, as the Story's verification stack directs. Branched from b2f826ac002eca28813526e18acbd6861b3f8c89, the accepted head of #738.

Why

A <Session.Launch> at the root takes the run's one foreground-terminal lease, so native UIs are sequential by construction — the second waits for the first to close. Inside a <Terminal> that defeats the point of a grid, where every pane stays interactive at the same time. This Story lets an Implementor, a Planner and a reviewer sit in three panes at once, without weakening either of the two ownerships involved.

What changes

Before: a <Session.Launch> written inside a paired <Terminal> asked for the run's foreground lease, which the grid was already holding, and was refused.

After: it uses the pane. Panes launch concurrently; one pane admits one live launch at a time; sequential use of a pane is ordinary composition.

<Terminal.Grid columns={2}>
<Terminaltitle="Planner">
<Session.Launch session="planner">You are the repository planner.</Session.Launch>
</Terminal>
<Terminaltitle="Implementor">
<Session.Launch session="implementor">You are the repository implementor.</Session.Launch>
</Terminal>
<Terminaltitle="Reviewer">
<Session.Launch session="reviewer">You are the repository reviewer.</Session.Launch>
</Terminal>
<Terminaltitle="Shell" />
</Terminal.Grid>

The launch itself is unchanged. It receives no pane prop, token, identifier or mode; its AgentLaunchRequest, its result and its retained agent_session_launch phases are the ones a root launch would have.

How it works

pane scope installs a launcher over the pane's claim
→ <Session.Launch> reserve() → claim.admit() (this pane, not the root lease)
→ flush() → the pane's own text
→ launch(request, spawned) → host launcher; spawned() trips the pane's latch
→ readiness barrier → attach

packages/core/src/terminal/pane-launcher.ts is the whole mechanism: middleware on the existing NativeLauncher Api, installed in the pane's scope and closed over that pane's claim. reserve and flush are answered here and deliberately not delegated — delegating would ask for the root lease the grid is holding. launch delegates the exact request onward and only wraps the start report.

Readiness needed a boundary a launch could report, so NativeLauncherHandler.launch now takes the runtime's child-start event as a parameter — not a request member, not a context, not a return value, which is what the spec requires of the latch. The foreground launcher reports it from the child's own spawn event, before it waits for the exit. nativeLaunch() is unchanged for provider adapters, which hear nothing about the start and so cannot fake one.

A paired pane also flushes what it has rendered so far before the UI draws over it. That is the root flush rule applied in the one place a pane's text goes: composite.display(ordinal, …).

Review guide

Start with:packages/test-agent/src/TerminalGridNativeLaunch.test.md — the authored journey: a 2×2 grid of three native Agent sessions and the host's default shell.

Then review:

  1. packages/core/src/terminal/pane-launcher.ts — the whole delivered mechanism.
  2. packages/runtime/launcher.ts — the start event, and where the foreground launcher reports it.
  3. packages/core/src/expand.tspaneWork(), where the launcher and the pane flush are installed.
  4. packages/test-agent/tests/terminal-grid-native-launch.test.ts — Tier GN, and packages/core/tests/agent-session-launch.test.ts — Tier SP.

Look carefully at:

  • reserve() returning a resource that holds claim.admit() for the launch's whole scope — not just the child's lifetime. That is what keeps the pane held while the session lease around the child is still unwinding, and what refuses a second overlapping launch.
  • The spawned wrapper in launch. Reporting anywhere else — after preparation, after the reservation, on an allocated PID — would present a pane that never started as one that is running.

What must stay true

  • Root <Session.Launch> is unchanged — enforced by installing nothing at the root, checked by SL1–SL18 (all still passing).
  • No pane identity in the launch — enforced by the claim being closed over rather than passed, checked by GN2, which scans the launch requests and the retained agent_session_launch records for the authored pane titles, ordinal and columns.
  • Terminal ownership grants no session — enforced by leaving the coordinator untouched, checked by GN4: two panes naming one logical session still contend, and exactly one retains session-busy.
  • Only a started child makes a pane ready — enforced by the spawned parameter, checked by SP3, SP4 and FL9.
  • A pane is held until everything around its launch is done — enforced by the reserve resource's scope, checked by SP5 and GN9.

How to verify it

deno task test packages/core/tests/agent-session-launch.test.ts
deno task test packages/runtime/tests/native-launcher.test.ts
deno task test packages/test-agent/tests/native-launch.test.ts
deno task test packages/test-agent/tests/terminal-grid-native-launch.test.ts

Tier SP — the pane seam, against a stub provider:

  • SP1 proves a pane launch does not take the root lease, and fails if it delegates reserve.
  • SP2 proves two panes hold their terminals at once: each launch waits for the other to have started, so a serialised pair waits for a start that cannot happen and hangs rather than passing.
  • SP3 proves readiness comes only from the start, and fails if anything earlier trips the latch.
  • SP4 proves a launch that fails before the start shows no partial grid and keeps its completed durable phases.
  • SP5 proves the pane stays held through both halves — refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both.

Tier FL — the foreground launcher, with real children:

  • FL8/FL9 prove the real spawn event is reported once before waiting, and never reported for a child that could not start.

Tier GN — the whole TestAgent stack, real worker over a real ACP connection, deterministic coordinator:

  • GN1 the 2×2 journey: three native sessions and a shell, all four started before attach, none having left by then, in the authored row-major positions and forms.
  • GN2 no pane identity in the launch request or the retained record.
  • GN3 a pane launch never reaches the host's launcher.
  • GN4 two panes naming one session contend; exactly one session-busy, and nothing attaches.
  • GN5 with no terminal provider, nothing starts at all.
  • GN6 a completed grid replays with no provider, launcher or agent contact.
  • GN7 after attachment one UI exits nonzero while its sibling is live: only that pane fails, the sibling is observed alive on the far side of the failure and stops only at reader close, and the grid ends on the pane that failed.
  • GN8 reader close cancels both live launches; neither pane fails, the composite comes down, and a root launch afterwards naming a session a pane held proves both leases came back.
  • GN9 a pane admits its next user only once the last one is wholly done.
  • GN10 a grid interrupted with a live pane launch, resumed on the same journal: fresh composite, native child restarted on the retained identity, nothing prepared again, retained record deep-equal.

Each claim was broken on purpose and re-run: removing the pane launcher fails SP1–SP4 and GN1–GN4, GN7–GN9; never reporting readiness fails SP1, SP2, SP3 and SP5; releasing the pane as soon as it is taken fails SP5 and only SP5.

Scope

Included

  • The pane-scoped native launcher, and its installation in paired panes.
  • The runtime child-start event on NativeLauncherHandler.launch.
  • A pane flush, so a pane's rendered text reaches the reader before the UI covers it.
  • The authored 2×2 journey and its three scenario documents.

Intentionally unchanged

  • tmux.Open terminal grids with tmux in foreground runs #732 owns the provider; this Story proves the contract against a controlled one.
  • Agent advertisements. No agent is advertised or de-advertised here.
  • The session coordinator, its natural key, and the ACPX construction routes.
  • nativeLaunch()'s signature, so no provider adapter changed.

New abstractions

  • usePaneNativeLauncher(claim, flush) exists because a pane's terminal has to answer reserve and flush for whatever is written in it, and <Session.Launch> must not learn that it is in a pane. Its consumer is paneWork() in expand.ts.
  • ControlledLauncherOptions.start exists so a suite can decide whether a launch starts at all — reporting is what a successful start does, throwing without reporting is what a failure before the start does. Its consumers are Tier SP and Tier GN.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Session.Launch>` written at the root takes the run's one foreground
terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat
the point of a grid, where every pane is interactive at the same time.
So core installs a native launcher in each paired pane's scope, closed over that
pane's claim. `<Session.Launch>` finds it by being written there: it is handed
no pane, ordinal, token or mode, and its request, result and retained phases are
the ones a root launch would have. What changes is which terminal answers
`reserve` and `flush` — the pane's, through its claim, so two panes do not
contend and one pane admits one live launch at a time. A pane also flushes what
it has rendered before the UI draws over it, which is the root rule in the one
place a pane's text goes.
Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch`
takes the runtime's child-start event as a parameter — not a request member, not
a context, not a result — and the foreground launcher reports it from the
child's own `spawn` event, before it waits for the exit. The pane launcher
listens and trips its claim's latch there and nowhere else: preparation, the
reservation, the flush and an allocated PID are not a start, and a child that
never ran never reports one. `nativeLaunch()` is unchanged for adapters, which
hear nothing about the start.
Terminal ownership and Agent-session ownership stay independent. Nothing pane-
derived enters the coordinator key, the launch request, the retained record or a
diagnostic, and two panes naming one logical session still contend through the
existing non-waiting coordinator.
No tmux, no new Agent advertisement, and root launch behavior is unchanged.
Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness,
a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime
launcher (the start event, and a child that never starts), and Tier GN over the
checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent
stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting
readiness fails SP1, SP2, SP3 and SP5.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown

PR #741: ✨ Launch native Agent sessions in independent terminal panes (#731)

12 files, +1508 / -34

Scope

🔴 PR has 1542 lines changed. Split into focused PRs.

🟡 1542 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×10: packages/core/src/expand.ts, packages/acp/src/provider.ts
  • no-empty-function ×3: packages/runtime/launcher.ts, packages/acp/src/provider.ts
  • no-unnecessary-type-assertion ×2: packages/core/src/expand.ts
  • no-redundant-type-constituents ×1: packages/runtime/launcher.ts

Slop

  • packages/acp/src/provider.ts:2766// path there is.
  • packages/acp/src/provider.ts:2787// deliberately.
  • packages/core/src/expand.ts:2254// makes this pane ready.
  • packages/runtime/launcher.ts:267// reports having started.

Oxlint slop signals:

  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 51 diagnostics across 3 files (15 rules)
Density: 0.034 violations/added-line

no-unused-vars (10): packages/core/src/expand.ts, packages/acp/src/provider.ts
consistent-function-scoping (10): packages/acp/src/provider.ts
no-shadow (5): packages/core/src/expand.ts, packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/expand.ts, packages/acp/src/provider.ts
no-empty-function (3): packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-useless-spread (3): packages/acp/src/provider.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-floating-promises (2): packages/acp/src/provider.ts
no-unnecessary-type-assertion (2): packages/core/src/expand.ts
unbound-method (2): packages/core/src/expand.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-redundant-type-constituents (1): packages/runtime/launcher.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

No production change. The pane-scoped launcher, the runtime spawn callback, the
authority boundaries and root-launch behavior are exactly as reviewed.
The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent
sessions and the host's default shell. All four children report their start
before the composite attaches, each waits for its siblings while holding its own
pane, and the row reads back the authored row-major positions and forms.
Four rows added, all driven by signals this run produced:
- GN7: after attachment one native UI exits nonzero while its sibling is live.
Only that pane fails; the sibling is observed alive on the far side of the
failure and stops only when the reader leaves; the grid ends on the pane that
failed, and the close's cancellation is not a second failure.
- GN8: the reader leaves with both launches live. Both are cancelled where they
stood, neither pane fails, the composite comes down — and a root launch after
the grid, naming a session a pane held, proves both leases came back. Which
refusal it gets is the proof: not "already holds this run's terminal", not
"another owner is using session", but the #517 recovery tombstone a cancelled
native UI leaves behind.
- GN9: a pane admits its next user only once the last one is wholly done, with
the launch and the prompt that follows it going through the real coordinator.
- GN10: a grid interrupted with a live pane launch, resumed on the same journal.
It rebuilds the composite, starts the native child on the identity the first
attempt retained, prepares nothing, and the retained record comes back
unchanged — identity, route, binding and phase alike.
SP5 now proves the pane stays held through both halves: refused while the child
is live, refused again once the child has gone but the lease around it is still
unwinding, admitted only after both. A launch that merely returned showed only
the first.
Two harness repairs. Pane states are read as a set of panes rather than a count
of messages — a pane still live when the reader leaves is told twice, once from
the outcome close decided and once from its own settlement, and that is display
rather than a second settlement. And a generated variant is written to a
directory of its own with copies of the scenarios it names, so a killed run
leaves nothing in the repository.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

The harness's interrupted branch built a `Result` as an object literal and cast
it. Effection has a constructor for exactly that, so it uses it: no cast, and
the type is the constructor's rather than an assertion's.
Behavior and evidence are unchanged.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

An orderly cancellation that finished proves everything a normal return proves,
and must say so. It did not: `ownership.quiesced()` was a statement after
`authority.perform()`, and cancellation unwinds past every statement after the
operation it cancels. A reader closing a terminal grid therefore left every
session its panes had launched carrying a recovery tombstone, and the next owner
was told to recover a session nothing was using.
The launch now runs in a scope the ownership body owns, and the acknowledgement
is that scope's cleanup — reached on every path there is, cancellation included.
It brings the launch down deliberately and reads the outcome of doing so, so the
two facts it needs are facts rather than inferences: the native child and its
cleanup settled, and this provider holds no handle for the session. A teardown
that could not prove the child stopped throws out of `destroy()` and is not
quiescence — and is still a failure, so it propagates rather than passing
quietly.
Nothing grid-specific reaches the provider. Reader close is the ordinary launch
cancellation path, and this is the ordinary launch cancellation path's rule.
The conservative cases keep their tombstone: a detach that failed or a session
prepared and never handed over leaves a handle, and a child or provider cleanup
that failed leaves the acknowledgement unmade. Cancellation, a released lease, a
PID and elapsed time still prove nothing on their own.
CX1 asserted the behavior this replaces — that a cancelled launch stays owned —
so it now asserts the accepted one. CX2 is new and holds the other half: a
cleanup that could not finish withholds quiescence, and the record stays active.
GN8 is rebuilt as directed: two pane children held on unresolved operations,
signals from each child's own teardown, teardown proven to finish after both,
and a root launch afterwards on one of the same logical sessions that acquires
ownership and starts — receiving neither session-busy nor
session-recovery-required, and reclaiming the root foreground lease as it goes.
Broken on purpose and re-run: acknowledging only on a normal return fails CX1
and GN8; acknowledging without proving the cleanup settled fails CX2, and only
CX2.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 4 redundant comments. Inline suggestions to remove them below.

// the reader closing a terminal grid is one — unwinds past every
// statement after it, so a decision written down here would never be
// reached; written as this scope's cleanup, it is reached on every
// path there is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// path there is.

// prepared and never handed over, leaves one. Either one
// missing leaves the session owned rather than looking
// finished, which is what the next owner is told to recover
// deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// deliberately.

// A `<Session.Launch>` written in this pane finds this launcher simply
// by being here: it reserves and flushes this pane instead of competing
// for the run's one foreground lease, and the child it starts is what
// makes this pane ready.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// makes this pane ready.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

✨ Launch native Agent sessions in independent terminal panes (#731) - #741

Draft
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch
Draft

✨ Launch native Agent sessions in independent terminal panes (#731)#741
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch

Conversation

@taras

@tarastaras commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes#731. Third implementation Story under Quest #717, stacked on #738.

Base is agent/issue-730-terminal-execution, not main, as the Story's verification stack directs. Branched from b2f826ac002eca28813526e18acbd6861b3f8c89, the accepted head of #738.

Why

A <Session.Launch> at the root takes the run's one foreground-terminal lease, so native UIs are sequential by construction — the second waits for the first to close. Inside a <Terminal> that defeats the point of a grid, where every pane stays interactive at the same time. This Story lets an Implementor, a Planner and a reviewer sit in three panes at once, without weakening either of the two ownerships involved.

What changes

Before: a <Session.Launch> written inside a paired <Terminal> asked for the run's foreground lease, which the grid was already holding, and was refused.

After: it uses the pane. Panes launch concurrently; one pane admits one live launch at a time; sequential use of a pane is ordinary composition.

<Terminal.Grid columns={2}>
<Terminaltitle="Planner">
<Session.Launch session="planner">You are the repository planner.</Session.Launch>
</Terminal>
<Terminaltitle="Implementor">
<Session.Launch session="implementor">You are the repository implementor.</Session.Launch>
</Terminal>
<Terminaltitle="Reviewer">
<Session.Launch session="reviewer">You are the repository reviewer.</Session.Launch>
</Terminal>
<Terminaltitle="Shell" />
</Terminal.Grid>

The launch itself is unchanged. It receives no pane prop, token, identifier or mode; its AgentLaunchRequest, its result and its retained agent_session_launch phases are the ones a root launch would have.

How it works

pane scope installs a launcher over the pane's claim
→ <Session.Launch> reserve() → claim.admit() (this pane, not the root lease)
→ flush() → the pane's own text
→ launch(request, spawned) → host launcher; spawned() trips the pane's latch
→ readiness barrier → attach

packages/core/src/terminal/pane-launcher.ts is the whole mechanism: middleware on the existing NativeLauncher Api, installed in the pane's scope and closed over that pane's claim. reserve and flush are answered here and deliberately not delegated — delegating would ask for the root lease the grid is holding. launch delegates the exact request onward and only wraps the start report.

Readiness needed a boundary a launch could report, so NativeLauncherHandler.launch now takes the runtime's child-start event as a parameter — not a request member, not a context, not a return value, which is what the spec requires of the latch. The foreground launcher reports it from the child's own spawn event, before it waits for the exit. nativeLaunch() is unchanged for provider adapters, which hear nothing about the start and so cannot fake one.

A paired pane also flushes what it has rendered so far before the UI draws over it. That is the root flush rule applied in the one place a pane's text goes: composite.display(ordinal, …).

Review guide

Start with:packages/test-agent/src/TerminalGridNativeLaunch.test.md — the authored journey: a 2×2 grid of three native Agent sessions and the host's default shell.

Then review:

  1. packages/core/src/terminal/pane-launcher.ts — the whole delivered mechanism.
  2. packages/runtime/launcher.ts — the start event, and where the foreground launcher reports it.
  3. packages/core/src/expand.tspaneWork(), where the launcher and the pane flush are installed.
  4. packages/test-agent/tests/terminal-grid-native-launch.test.ts — Tier GN, and packages/core/tests/agent-session-launch.test.ts — Tier SP.

Look carefully at:

  • reserve() returning a resource that holds claim.admit() for the launch's whole scope — not just the child's lifetime. That is what keeps the pane held while the session lease around the child is still unwinding, and what refuses a second overlapping launch.
  • The spawned wrapper in launch. Reporting anywhere else — after preparation, after the reservation, on an allocated PID — would present a pane that never started as one that is running.

What must stay true

  • Root <Session.Launch> is unchanged — enforced by installing nothing at the root, checked by SL1–SL18 (all still passing).
  • No pane identity in the launch — enforced by the claim being closed over rather than passed, checked by GN2, which scans the launch requests and the retained agent_session_launch records for the authored pane titles, ordinal and columns.
  • Terminal ownership grants no session — enforced by leaving the coordinator untouched, checked by GN4: two panes naming one logical session still contend, and exactly one retains session-busy.
  • Only a started child makes a pane ready — enforced by the spawned parameter, checked by SP3, SP4 and FL9.
  • A pane is held until everything around its launch is done — enforced by the reserve resource's scope, checked by SP5 and GN9.

How to verify it

deno task test packages/core/tests/agent-session-launch.test.ts
deno task test packages/runtime/tests/native-launcher.test.ts
deno task test packages/test-agent/tests/native-launch.test.ts
deno task test packages/test-agent/tests/terminal-grid-native-launch.test.ts

Tier SP — the pane seam, against a stub provider:

  • SP1 proves a pane launch does not take the root lease, and fails if it delegates reserve.
  • SP2 proves two panes hold their terminals at once: each launch waits for the other to have started, so a serialised pair waits for a start that cannot happen and hangs rather than passing.
  • SP3 proves readiness comes only from the start, and fails if anything earlier trips the latch.
  • SP4 proves a launch that fails before the start shows no partial grid and keeps its completed durable phases.
  • SP5 proves the pane stays held through both halves — refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both.

Tier FL — the foreground launcher, with real children:

  • FL8/FL9 prove the real spawn event is reported once before waiting, and never reported for a child that could not start.

Tier GN — the whole TestAgent stack, real worker over a real ACP connection, deterministic coordinator:

  • GN1 the 2×2 journey: three native sessions and a shell, all four started before attach, none having left by then, in the authored row-major positions and forms.
  • GN2 no pane identity in the launch request or the retained record.
  • GN3 a pane launch never reaches the host's launcher.
  • GN4 two panes naming one session contend; exactly one session-busy, and nothing attaches.
  • GN5 with no terminal provider, nothing starts at all.
  • GN6 a completed grid replays with no provider, launcher or agent contact.
  • GN7 after attachment one UI exits nonzero while its sibling is live: only that pane fails, the sibling is observed alive on the far side of the failure and stops only at reader close, and the grid ends on the pane that failed.
  • GN8 reader close cancels both live launches; neither pane fails, the composite comes down, and a root launch afterwards naming a session a pane held proves both leases came back.
  • GN9 a pane admits its next user only once the last one is wholly done.
  • GN10 a grid interrupted with a live pane launch, resumed on the same journal: fresh composite, native child restarted on the retained identity, nothing prepared again, retained record deep-equal.

Each claim was broken on purpose and re-run: removing the pane launcher fails SP1–SP4 and GN1–GN4, GN7–GN9; never reporting readiness fails SP1, SP2, SP3 and SP5; releasing the pane as soon as it is taken fails SP5 and only SP5.

Scope

Included

  • The pane-scoped native launcher, and its installation in paired panes.
  • The runtime child-start event on NativeLauncherHandler.launch.
  • A pane flush, so a pane's rendered text reaches the reader before the UI covers it.
  • The authored 2×2 journey and its three scenario documents.

Intentionally unchanged

  • tmux.Open terminal grids with tmux in foreground runs #732 owns the provider; this Story proves the contract against a controlled one.
  • Agent advertisements. No agent is advertised or de-advertised here.
  • The session coordinator, its natural key, and the ACPX construction routes.
  • nativeLaunch()'s signature, so no provider adapter changed.

New abstractions

  • usePaneNativeLauncher(claim, flush) exists because a pane's terminal has to answer reserve and flush for whatever is written in it, and <Session.Launch> must not learn that it is in a pane. Its consumer is paneWork() in expand.ts.
  • ControlledLauncherOptions.start exists so a suite can decide whether a launch starts at all — reporting is what a successful start does, throwing without reporting is what a failure before the start does. Its consumers are Tier SP and Tier GN.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Session.Launch>` written at the root takes the run's one foreground
terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat
the point of a grid, where every pane is interactive at the same time.
So core installs a native launcher in each paired pane's scope, closed over that
pane's claim. `<Session.Launch>` finds it by being written there: it is handed
no pane, ordinal, token or mode, and its request, result and retained phases are
the ones a root launch would have. What changes is which terminal answers
`reserve` and `flush` — the pane's, through its claim, so two panes do not
contend and one pane admits one live launch at a time. A pane also flushes what
it has rendered before the UI draws over it, which is the root rule in the one
place a pane's text goes.
Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch`
takes the runtime's child-start event as a parameter — not a request member, not
a context, not a result — and the foreground launcher reports it from the
child's own `spawn` event, before it waits for the exit. The pane launcher
listens and trips its claim's latch there and nowhere else: preparation, the
reservation, the flush and an allocated PID are not a start, and a child that
never ran never reports one. `nativeLaunch()` is unchanged for adapters, which
hear nothing about the start.
Terminal ownership and Agent-session ownership stay independent. Nothing pane-
derived enters the coordinator key, the launch request, the retained record or a
diagnostic, and two panes naming one logical session still contend through the
existing non-waiting coordinator.
No tmux, no new Agent advertisement, and root launch behavior is unchanged.
Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness,
a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime
launcher (the start event, and a child that never starts), and Tier GN over the
checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent
stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting
readiness fails SP1, SP2, SP3 and SP5.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown

PR #741: ✨ Launch native Agent sessions in independent terminal panes (#731)

12 files, +1508 / -34

Scope

🔴 PR has 1542 lines changed. Split into focused PRs.

🟡 1542 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×10: packages/core/src/expand.ts, packages/acp/src/provider.ts
  • no-empty-function ×3: packages/runtime/launcher.ts, packages/acp/src/provider.ts
  • no-unnecessary-type-assertion ×2: packages/core/src/expand.ts
  • no-redundant-type-constituents ×1: packages/runtime/launcher.ts

Slop

  • packages/acp/src/provider.ts:2766// path there is.
  • packages/acp/src/provider.ts:2787// deliberately.
  • packages/core/src/expand.ts:2254// makes this pane ready.
  • packages/runtime/launcher.ts:267// reports having started.

Oxlint slop signals:

  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 51 diagnostics across 3 files (15 rules)
Density: 0.034 violations/added-line

no-unused-vars (10): packages/core/src/expand.ts, packages/acp/src/provider.ts
consistent-function-scoping (10): packages/acp/src/provider.ts
no-shadow (5): packages/core/src/expand.ts, packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/expand.ts, packages/acp/src/provider.ts
no-empty-function (3): packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-useless-spread (3): packages/acp/src/provider.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-floating-promises (2): packages/acp/src/provider.ts
no-unnecessary-type-assertion (2): packages/core/src/expand.ts
unbound-method (2): packages/core/src/expand.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-redundant-type-constituents (1): packages/runtime/launcher.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

No production change. The pane-scoped launcher, the runtime spawn callback, the
authority boundaries and root-launch behavior are exactly as reviewed.
The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent
sessions and the host's default shell. All four children report their start
before the composite attaches, each waits for its siblings while holding its own
pane, and the row reads back the authored row-major positions and forms.
Four rows added, all driven by signals this run produced:
- GN7: after attachment one native UI exits nonzero while its sibling is live.
Only that pane fails; the sibling is observed alive on the far side of the
failure and stops only when the reader leaves; the grid ends on the pane that
failed, and the close's cancellation is not a second failure.
- GN8: the reader leaves with both launches live. Both are cancelled where they
stood, neither pane fails, the composite comes down — and a root launch after
the grid, naming a session a pane held, proves both leases came back. Which
refusal it gets is the proof: not "already holds this run's terminal", not
"another owner is using session", but the #517 recovery tombstone a cancelled
native UI leaves behind.
- GN9: a pane admits its next user only once the last one is wholly done, with
the launch and the prompt that follows it going through the real coordinator.
- GN10: a grid interrupted with a live pane launch, resumed on the same journal.
It rebuilds the composite, starts the native child on the identity the first
attempt retained, prepares nothing, and the retained record comes back
unchanged — identity, route, binding and phase alike.
SP5 now proves the pane stays held through both halves: refused while the child
is live, refused again once the child has gone but the lease around it is still
unwinding, admitted only after both. A launch that merely returned showed only
the first.
Two harness repairs. Pane states are read as a set of panes rather than a count
of messages — a pane still live when the reader leaves is told twice, once from
the outcome close decided and once from its own settlement, and that is display
rather than a second settlement. And a generated variant is written to a
directory of its own with copies of the scenarios it names, so a killed run
leaves nothing in the repository.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

The harness's interrupted branch built a `Result` as an object literal and cast
it. Effection has a constructor for exactly that, so it uses it: no cast, and
the type is the constructor's rather than an assertion's.
Behavior and evidence are unchanged.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

An orderly cancellation that finished proves everything a normal return proves,
and must say so. It did not: `ownership.quiesced()` was a statement after
`authority.perform()`, and cancellation unwinds past every statement after the
operation it cancels. A reader closing a terminal grid therefore left every
session its panes had launched carrying a recovery tombstone, and the next owner
was told to recover a session nothing was using.
The launch now runs in a scope the ownership body owns, and the acknowledgement
is that scope's cleanup — reached on every path there is, cancellation included.
It brings the launch down deliberately and reads the outcome of doing so, so the
two facts it needs are facts rather than inferences: the native child and its
cleanup settled, and this provider holds no handle for the session. A teardown
that could not prove the child stopped throws out of `destroy()` and is not
quiescence — and is still a failure, so it propagates rather than passing
quietly.
Nothing grid-specific reaches the provider. Reader close is the ordinary launch
cancellation path, and this is the ordinary launch cancellation path's rule.
The conservative cases keep their tombstone: a detach that failed or a session
prepared and never handed over leaves a handle, and a child or provider cleanup
that failed leaves the acknowledgement unmade. Cancellation, a released lease, a
PID and elapsed time still prove nothing on their own.
CX1 asserted the behavior this replaces — that a cancelled launch stays owned —
so it now asserts the accepted one. CX2 is new and holds the other half: a
cleanup that could not finish withholds quiescence, and the record stays active.
GN8 is rebuilt as directed: two pane children held on unresolved operations,
signals from each child's own teardown, teardown proven to finish after both,
and a root launch afterwards on one of the same logical sessions that acquires
ownership and starts — receiving neither session-busy nor
session-recovery-required, and reclaiming the root foreground lease as it goes.
Broken on purpose and re-run: acknowledging only on a normal return fails CX1
and GN8; acknowledging without proving the cleanup settled fails CX2, and only
CX2.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 4 redundant comments. Inline suggestions to remove them below.

// the reader closing a terminal grid is one — unwinds past every
// statement after it, so a decision written down here would never be
// reached; written as this scope's cleanup, it is reached on every
// path there is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// path there is.

// prepared and never handed over, leaves one. Either one
// missing leaves the session owned rather than looking
// finished, which is what the next owner is told to recover
// deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// deliberately.

// A `<Session.Launch>` written in this pane finds this launcher simply
// by being here: it reserves and flushes this pane instead of competing
// for the run's one foreground lease, and the child it starts is what
// makes this pane ready.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// makes this pane ready.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

✨ Launch native Agent sessions in independent terminal panes (#731) - #741

Draft
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch
Draft

✨ Launch native Agent sessions in independent terminal panes (#731)#741
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch

Conversation

@taras

@tarastaras commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes#731. Third implementation Story under Quest #717, stacked on #738.

Base is agent/issue-730-terminal-execution, not main, as the Story's verification stack directs. Branched from b2f826ac002eca28813526e18acbd6861b3f8c89, the accepted head of #738.

Why

A <Session.Launch> at the root takes the run's one foreground-terminal lease, so native UIs are sequential by construction — the second waits for the first to close. Inside a <Terminal> that defeats the point of a grid, where every pane stays interactive at the same time. This Story lets an Implementor, a Planner and a reviewer sit in three panes at once, without weakening either of the two ownerships involved.

What changes

Before: a <Session.Launch> written inside a paired <Terminal> asked for the run's foreground lease, which the grid was already holding, and was refused.

After: it uses the pane. Panes launch concurrently; one pane admits one live launch at a time; sequential use of a pane is ordinary composition.

<Terminal.Grid columns={2}>
<Terminaltitle="Planner">
<Session.Launch session="planner">You are the repository planner.</Session.Launch>
</Terminal>
<Terminaltitle="Implementor">
<Session.Launch session="implementor">You are the repository implementor.</Session.Launch>
</Terminal>
<Terminaltitle="Reviewer">
<Session.Launch session="reviewer">You are the repository reviewer.</Session.Launch>
</Terminal>
<Terminaltitle="Shell" />
</Terminal.Grid>

The launch itself is unchanged. It receives no pane prop, token, identifier or mode; its AgentLaunchRequest, its result and its retained agent_session_launch phases are the ones a root launch would have.

How it works

pane scope installs a launcher over the pane's claim
→ <Session.Launch> reserve() → claim.admit() (this pane, not the root lease)
→ flush() → the pane's own text
→ launch(request, spawned) → host launcher; spawned() trips the pane's latch
→ readiness barrier → attach

packages/core/src/terminal/pane-launcher.ts is the whole mechanism: middleware on the existing NativeLauncher Api, installed in the pane's scope and closed over that pane's claim. reserve and flush are answered here and deliberately not delegated — delegating would ask for the root lease the grid is holding. launch delegates the exact request onward and only wraps the start report.

Readiness needed a boundary a launch could report, so NativeLauncherHandler.launch now takes the runtime's child-start event as a parameter — not a request member, not a context, not a return value, which is what the spec requires of the latch. The foreground launcher reports it from the child's own spawn event, before it waits for the exit. nativeLaunch() is unchanged for provider adapters, which hear nothing about the start and so cannot fake one.

A paired pane also flushes what it has rendered so far before the UI draws over it. That is the root flush rule applied in the one place a pane's text goes: composite.display(ordinal, …).

Review guide

Start with:packages/test-agent/src/TerminalGridNativeLaunch.test.md — the authored journey: a 2×2 grid of three native Agent sessions and the host's default shell.

Then review:

  1. packages/core/src/terminal/pane-launcher.ts — the whole delivered mechanism.
  2. packages/runtime/launcher.ts — the start event, and where the foreground launcher reports it.
  3. packages/core/src/expand.tspaneWork(), where the launcher and the pane flush are installed.
  4. packages/test-agent/tests/terminal-grid-native-launch.test.ts — Tier GN, and packages/core/tests/agent-session-launch.test.ts — Tier SP.

Look carefully at:

  • reserve() returning a resource that holds claim.admit() for the launch's whole scope — not just the child's lifetime. That is what keeps the pane held while the session lease around the child is still unwinding, and what refuses a second overlapping launch.
  • The spawned wrapper in launch. Reporting anywhere else — after preparation, after the reservation, on an allocated PID — would present a pane that never started as one that is running.

What must stay true

  • Root <Session.Launch> is unchanged — enforced by installing nothing at the root, checked by SL1–SL18 (all still passing).
  • No pane identity in the launch — enforced by the claim being closed over rather than passed, checked by GN2, which scans the launch requests and the retained agent_session_launch records for the authored pane titles, ordinal and columns.
  • Terminal ownership grants no session — enforced by leaving the coordinator untouched, checked by GN4: two panes naming one logical session still contend, and exactly one retains session-busy.
  • Only a started child makes a pane ready — enforced by the spawned parameter, checked by SP3, SP4 and FL9.
  • A pane is held until everything around its launch is done — enforced by the reserve resource's scope, checked by SP5 and GN9.

How to verify it

deno task test packages/core/tests/agent-session-launch.test.ts
deno task test packages/runtime/tests/native-launcher.test.ts
deno task test packages/test-agent/tests/native-launch.test.ts
deno task test packages/test-agent/tests/terminal-grid-native-launch.test.ts

Tier SP — the pane seam, against a stub provider:

  • SP1 proves a pane launch does not take the root lease, and fails if it delegates reserve.
  • SP2 proves two panes hold their terminals at once: each launch waits for the other to have started, so a serialised pair waits for a start that cannot happen and hangs rather than passing.
  • SP3 proves readiness comes only from the start, and fails if anything earlier trips the latch.
  • SP4 proves a launch that fails before the start shows no partial grid and keeps its completed durable phases.
  • SP5 proves the pane stays held through both halves — refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both.

Tier FL — the foreground launcher, with real children:

  • FL8/FL9 prove the real spawn event is reported once before waiting, and never reported for a child that could not start.

Tier GN — the whole TestAgent stack, real worker over a real ACP connection, deterministic coordinator:

  • GN1 the 2×2 journey: three native sessions and a shell, all four started before attach, none having left by then, in the authored row-major positions and forms.
  • GN2 no pane identity in the launch request or the retained record.
  • GN3 a pane launch never reaches the host's launcher.
  • GN4 two panes naming one session contend; exactly one session-busy, and nothing attaches.
  • GN5 with no terminal provider, nothing starts at all.
  • GN6 a completed grid replays with no provider, launcher or agent contact.
  • GN7 after attachment one UI exits nonzero while its sibling is live: only that pane fails, the sibling is observed alive on the far side of the failure and stops only at reader close, and the grid ends on the pane that failed.
  • GN8 reader close cancels both live launches; neither pane fails, the composite comes down, and a root launch afterwards naming a session a pane held proves both leases came back.
  • GN9 a pane admits its next user only once the last one is wholly done.
  • GN10 a grid interrupted with a live pane launch, resumed on the same journal: fresh composite, native child restarted on the retained identity, nothing prepared again, retained record deep-equal.

Each claim was broken on purpose and re-run: removing the pane launcher fails SP1–SP4 and GN1–GN4, GN7–GN9; never reporting readiness fails SP1, SP2, SP3 and SP5; releasing the pane as soon as it is taken fails SP5 and only SP5.

Scope

Included

  • The pane-scoped native launcher, and its installation in paired panes.
  • The runtime child-start event on NativeLauncherHandler.launch.
  • A pane flush, so a pane's rendered text reaches the reader before the UI covers it.
  • The authored 2×2 journey and its three scenario documents.

Intentionally unchanged

  • tmux.Open terminal grids with tmux in foreground runs #732 owns the provider; this Story proves the contract against a controlled one.
  • Agent advertisements. No agent is advertised or de-advertised here.
  • The session coordinator, its natural key, and the ACPX construction routes.
  • nativeLaunch()'s signature, so no provider adapter changed.

New abstractions

  • usePaneNativeLauncher(claim, flush) exists because a pane's terminal has to answer reserve and flush for whatever is written in it, and <Session.Launch> must not learn that it is in a pane. Its consumer is paneWork() in expand.ts.
  • ControlledLauncherOptions.start exists so a suite can decide whether a launch starts at all — reporting is what a successful start does, throwing without reporting is what a failure before the start does. Its consumers are Tier SP and Tier GN.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Session.Launch>` written at the root takes the run's one foreground
terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat
the point of a grid, where every pane is interactive at the same time.
So core installs a native launcher in each paired pane's scope, closed over that
pane's claim. `<Session.Launch>` finds it by being written there: it is handed
no pane, ordinal, token or mode, and its request, result and retained phases are
the ones a root launch would have. What changes is which terminal answers
`reserve` and `flush` — the pane's, through its claim, so two panes do not
contend and one pane admits one live launch at a time. A pane also flushes what
it has rendered before the UI draws over it, which is the root rule in the one
place a pane's text goes.
Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch`
takes the runtime's child-start event as a parameter — not a request member, not
a context, not a result — and the foreground launcher reports it from the
child's own `spawn` event, before it waits for the exit. The pane launcher
listens and trips its claim's latch there and nowhere else: preparation, the
reservation, the flush and an allocated PID are not a start, and a child that
never ran never reports one. `nativeLaunch()` is unchanged for adapters, which
hear nothing about the start.
Terminal ownership and Agent-session ownership stay independent. Nothing pane-
derived enters the coordinator key, the launch request, the retained record or a
diagnostic, and two panes naming one logical session still contend through the
existing non-waiting coordinator.
No tmux, no new Agent advertisement, and root launch behavior is unchanged.
Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness,
a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime
launcher (the start event, and a child that never starts), and Tier GN over the
checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent
stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting
readiness fails SP1, SP2, SP3 and SP5.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown

PR #741: ✨ Launch native Agent sessions in independent terminal panes (#731)

12 files, +1508 / -34

Scope

🔴 PR has 1542 lines changed. Split into focused PRs.

🟡 1542 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×10: packages/core/src/expand.ts, packages/acp/src/provider.ts
  • no-empty-function ×3: packages/runtime/launcher.ts, packages/acp/src/provider.ts
  • no-unnecessary-type-assertion ×2: packages/core/src/expand.ts
  • no-redundant-type-constituents ×1: packages/runtime/launcher.ts

Slop

  • packages/acp/src/provider.ts:2766// path there is.
  • packages/acp/src/provider.ts:2787// deliberately.
  • packages/core/src/expand.ts:2254// makes this pane ready.
  • packages/runtime/launcher.ts:267// reports having started.

Oxlint slop signals:

  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 51 diagnostics across 3 files (15 rules)
Density: 0.034 violations/added-line

no-unused-vars (10): packages/core/src/expand.ts, packages/acp/src/provider.ts
consistent-function-scoping (10): packages/acp/src/provider.ts
no-shadow (5): packages/core/src/expand.ts, packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/expand.ts, packages/acp/src/provider.ts
no-empty-function (3): packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-useless-spread (3): packages/acp/src/provider.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-floating-promises (2): packages/acp/src/provider.ts
no-unnecessary-type-assertion (2): packages/core/src/expand.ts
unbound-method (2): packages/core/src/expand.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-redundant-type-constituents (1): packages/runtime/launcher.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

No production change. The pane-scoped launcher, the runtime spawn callback, the
authority boundaries and root-launch behavior are exactly as reviewed.
The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent
sessions and the host's default shell. All four children report their start
before the composite attaches, each waits for its siblings while holding its own
pane, and the row reads back the authored row-major positions and forms.
Four rows added, all driven by signals this run produced:
- GN7: after attachment one native UI exits nonzero while its sibling is live.
Only that pane fails; the sibling is observed alive on the far side of the
failure and stops only when the reader leaves; the grid ends on the pane that
failed, and the close's cancellation is not a second failure.
- GN8: the reader leaves with both launches live. Both are cancelled where they
stood, neither pane fails, the composite comes down — and a root launch after
the grid, naming a session a pane held, proves both leases came back. Which
refusal it gets is the proof: not "already holds this run's terminal", not
"another owner is using session", but the #517 recovery tombstone a cancelled
native UI leaves behind.
- GN9: a pane admits its next user only once the last one is wholly done, with
the launch and the prompt that follows it going through the real coordinator.
- GN10: a grid interrupted with a live pane launch, resumed on the same journal.
It rebuilds the composite, starts the native child on the identity the first
attempt retained, prepares nothing, and the retained record comes back
unchanged — identity, route, binding and phase alike.
SP5 now proves the pane stays held through both halves: refused while the child
is live, refused again once the child has gone but the lease around it is still
unwinding, admitted only after both. A launch that merely returned showed only
the first.
Two harness repairs. Pane states are read as a set of panes rather than a count
of messages — a pane still live when the reader leaves is told twice, once from
the outcome close decided and once from its own settlement, and that is display
rather than a second settlement. And a generated variant is written to a
directory of its own with copies of the scenarios it names, so a killed run
leaves nothing in the repository.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

The harness's interrupted branch built a `Result` as an object literal and cast
it. Effection has a constructor for exactly that, so it uses it: no cast, and
the type is the constructor's rather than an assertion's.
Behavior and evidence are unchanged.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

An orderly cancellation that finished proves everything a normal return proves,
and must say so. It did not: `ownership.quiesced()` was a statement after
`authority.perform()`, and cancellation unwinds past every statement after the
operation it cancels. A reader closing a terminal grid therefore left every
session its panes had launched carrying a recovery tombstone, and the next owner
was told to recover a session nothing was using.
The launch now runs in a scope the ownership body owns, and the acknowledgement
is that scope's cleanup — reached on every path there is, cancellation included.
It brings the launch down deliberately and reads the outcome of doing so, so the
two facts it needs are facts rather than inferences: the native child and its
cleanup settled, and this provider holds no handle for the session. A teardown
that could not prove the child stopped throws out of `destroy()` and is not
quiescence — and is still a failure, so it propagates rather than passing
quietly.
Nothing grid-specific reaches the provider. Reader close is the ordinary launch
cancellation path, and this is the ordinary launch cancellation path's rule.
The conservative cases keep their tombstone: a detach that failed or a session
prepared and never handed over leaves a handle, and a child or provider cleanup
that failed leaves the acknowledgement unmade. Cancellation, a released lease, a
PID and elapsed time still prove nothing on their own.
CX1 asserted the behavior this replaces — that a cancelled launch stays owned —
so it now asserts the accepted one. CX2 is new and holds the other half: a
cleanup that could not finish withholds quiescence, and the record stays active.
GN8 is rebuilt as directed: two pane children held on unresolved operations,
signals from each child's own teardown, teardown proven to finish after both,
and a root launch afterwards on one of the same logical sessions that acquires
ownership and starts — receiving neither session-busy nor
session-recovery-required, and reclaiming the root foreground lease as it goes.
Broken on purpose and re-run: acknowledging only on a normal return fails CX1
and GN8; acknowledging without proving the cleanup settled fails CX2, and only
CX2.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 4 redundant comments. Inline suggestions to remove them below.

// the reader closing a terminal grid is one — unwinds past every
// statement after it, so a decision written down here would never be
// reached; written as this scope's cleanup, it is reached on every
// path there is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// path there is.

// prepared and never handed over, leaves one. Either one
// missing leaves the session owned rather than looking
// finished, which is what the next owner is told to recover
// deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// deliberately.

// A `<Session.Launch>` written in this pane finds this launcher simply
// by being here: it reserves and flushes this pane instead of competing
// for the run's one foreground lease, and the child it starts is what
// makes this pane ready.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// makes this pane ready.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

✨ Launch native Agent sessions in independent terminal panes (#731) - #741

Draft
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch
Draft

✨ Launch native Agent sessions in independent terminal panes (#731)#741
taras wants to merge 4 commits into
agent/issue-730-terminal-executionfrom
agent/issue-731-pane-native-launch

Conversation

@taras

@tarastaras commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes#731. Third implementation Story under Quest #717, stacked on #738.

Base is agent/issue-730-terminal-execution, not main, as the Story's verification stack directs. Branched from b2f826ac002eca28813526e18acbd6861b3f8c89, the accepted head of #738.

Why

A <Session.Launch> at the root takes the run's one foreground-terminal lease, so native UIs are sequential by construction — the second waits for the first to close. Inside a <Terminal> that defeats the point of a grid, where every pane stays interactive at the same time. This Story lets an Implementor, a Planner and a reviewer sit in three panes at once, without weakening either of the two ownerships involved.

What changes

Before: a <Session.Launch> written inside a paired <Terminal> asked for the run's foreground lease, which the grid was already holding, and was refused.

After: it uses the pane. Panes launch concurrently; one pane admits one live launch at a time; sequential use of a pane is ordinary composition.

<Terminal.Grid columns={2}>
<Terminaltitle="Planner">
<Session.Launch session="planner">You are the repository planner.</Session.Launch>
</Terminal>
<Terminaltitle="Implementor">
<Session.Launch session="implementor">You are the repository implementor.</Session.Launch>
</Terminal>
<Terminaltitle="Reviewer">
<Session.Launch session="reviewer">You are the repository reviewer.</Session.Launch>
</Terminal>
<Terminaltitle="Shell" />
</Terminal.Grid>

The launch itself is unchanged. It receives no pane prop, token, identifier or mode; its AgentLaunchRequest, its result and its retained agent_session_launch phases are the ones a root launch would have.

How it works

pane scope installs a launcher over the pane's claim
→ <Session.Launch> reserve() → claim.admit() (this pane, not the root lease)
→ flush() → the pane's own text
→ launch(request, spawned) → host launcher; spawned() trips the pane's latch
→ readiness barrier → attach

packages/core/src/terminal/pane-launcher.ts is the whole mechanism: middleware on the existing NativeLauncher Api, installed in the pane's scope and closed over that pane's claim. reserve and flush are answered here and deliberately not delegated — delegating would ask for the root lease the grid is holding. launch delegates the exact request onward and only wraps the start report.

Readiness needed a boundary a launch could report, so NativeLauncherHandler.launch now takes the runtime's child-start event as a parameter — not a request member, not a context, not a return value, which is what the spec requires of the latch. The foreground launcher reports it from the child's own spawn event, before it waits for the exit. nativeLaunch() is unchanged for provider adapters, which hear nothing about the start and so cannot fake one.

A paired pane also flushes what it has rendered so far before the UI draws over it. That is the root flush rule applied in the one place a pane's text goes: composite.display(ordinal, …).

Review guide

Start with:packages/test-agent/src/TerminalGridNativeLaunch.test.md — the authored journey: a 2×2 grid of three native Agent sessions and the host's default shell.

Then review:

  1. packages/core/src/terminal/pane-launcher.ts — the whole delivered mechanism.
  2. packages/runtime/launcher.ts — the start event, and where the foreground launcher reports it.
  3. packages/core/src/expand.tspaneWork(), where the launcher and the pane flush are installed.
  4. packages/test-agent/tests/terminal-grid-native-launch.test.ts — Tier GN, and packages/core/tests/agent-session-launch.test.ts — Tier SP.

Look carefully at:

  • reserve() returning a resource that holds claim.admit() for the launch's whole scope — not just the child's lifetime. That is what keeps the pane held while the session lease around the child is still unwinding, and what refuses a second overlapping launch.
  • The spawned wrapper in launch. Reporting anywhere else — after preparation, after the reservation, on an allocated PID — would present a pane that never started as one that is running.

What must stay true

  • Root <Session.Launch> is unchanged — enforced by installing nothing at the root, checked by SL1–SL18 (all still passing).
  • No pane identity in the launch — enforced by the claim being closed over rather than passed, checked by GN2, which scans the launch requests and the retained agent_session_launch records for the authored pane titles, ordinal and columns.
  • Terminal ownership grants no session — enforced by leaving the coordinator untouched, checked by GN4: two panes naming one logical session still contend, and exactly one retains session-busy.
  • Only a started child makes a pane ready — enforced by the spawned parameter, checked by SP3, SP4 and FL9.
  • A pane is held until everything around its launch is done — enforced by the reserve resource's scope, checked by SP5 and GN9.

How to verify it

deno task test packages/core/tests/agent-session-launch.test.ts
deno task test packages/runtime/tests/native-launcher.test.ts
deno task test packages/test-agent/tests/native-launch.test.ts
deno task test packages/test-agent/tests/terminal-grid-native-launch.test.ts

Tier SP — the pane seam, against a stub provider:

  • SP1 proves a pane launch does not take the root lease, and fails if it delegates reserve.
  • SP2 proves two panes hold their terminals at once: each launch waits for the other to have started, so a serialised pair waits for a start that cannot happen and hangs rather than passing.
  • SP3 proves readiness comes only from the start, and fails if anything earlier trips the latch.
  • SP4 proves a launch that fails before the start shows no partial grid and keeps its completed durable phases.
  • SP5 proves the pane stays held through both halves — refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both.

Tier FL — the foreground launcher, with real children:

  • FL8/FL9 prove the real spawn event is reported once before waiting, and never reported for a child that could not start.

Tier GN — the whole TestAgent stack, real worker over a real ACP connection, deterministic coordinator:

  • GN1 the 2×2 journey: three native sessions and a shell, all four started before attach, none having left by then, in the authored row-major positions and forms.
  • GN2 no pane identity in the launch request or the retained record.
  • GN3 a pane launch never reaches the host's launcher.
  • GN4 two panes naming one session contend; exactly one session-busy, and nothing attaches.
  • GN5 with no terminal provider, nothing starts at all.
  • GN6 a completed grid replays with no provider, launcher or agent contact.
  • GN7 after attachment one UI exits nonzero while its sibling is live: only that pane fails, the sibling is observed alive on the far side of the failure and stops only at reader close, and the grid ends on the pane that failed.
  • GN8 reader close cancels both live launches; neither pane fails, the composite comes down, and a root launch afterwards naming a session a pane held proves both leases came back.
  • GN9 a pane admits its next user only once the last one is wholly done.
  • GN10 a grid interrupted with a live pane launch, resumed on the same journal: fresh composite, native child restarted on the retained identity, nothing prepared again, retained record deep-equal.

Each claim was broken on purpose and re-run: removing the pane launcher fails SP1–SP4 and GN1–GN4, GN7–GN9; never reporting readiness fails SP1, SP2, SP3 and SP5; releasing the pane as soon as it is taken fails SP5 and only SP5.

Scope

Included

  • The pane-scoped native launcher, and its installation in paired panes.
  • The runtime child-start event on NativeLauncherHandler.launch.
  • A pane flush, so a pane's rendered text reaches the reader before the UI covers it.
  • The authored 2×2 journey and its three scenario documents.

Intentionally unchanged

  • tmux.Open terminal grids with tmux in foreground runs #732 owns the provider; this Story proves the contract against a controlled one.
  • Agent advertisements. No agent is advertised or de-advertised here.
  • The session coordinator, its natural key, and the ACPX construction routes.
  • nativeLaunch()'s signature, so no provider adapter changed.

New abstractions

  • usePaneNativeLauncher(claim, flush) exists because a pane's terminal has to answer reserve and flush for whatever is written in it, and <Session.Launch> must not learn that it is in a pane. Its consumer is paneWork() in expand.ts.
  • ControlledLauncherOptions.start exists so a suite can decide whether a launch starts at all — reporting is what a successful start does, throwing without reporting is what a failure before the start does. Its consumers are Tier SP and Tier GN.

Risks and limitations

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

A `<Session.Launch>` written at the root takes the run's one foreground
terminal, so native UIs are sequential. Inside a `<Terminal>` that would defeat
the point of a grid, where every pane is interactive at the same time.
So core installs a native launcher in each paired pane's scope, closed over that
pane's claim. `<Session.Launch>` finds it by being written there: it is handed
no pane, ordinal, token or mode, and its request, result and retained phases are
the ones a root launch would have. What changes is which terminal answers
`reserve` and `flush` — the pane's, through its claim, so two panes do not
contend and one pane admits one live launch at a time. A pane also flushes what
it has rendered before the UI draws over it, which is the root rule in the one
place a pane's text goes.
Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch`
takes the runtime's child-start event as a parameter — not a request member, not
a context, not a result — and the foreground launcher reports it from the
child's own `spawn` event, before it waits for the exit. The pane launcher
listens and trips its claim's latch there and nowhere else: preparation, the
reservation, the flush and an allocated PID are not a start, and a child that
never ran never reports one. `nativeLaunch()` is unchanged for adapters, which
hear nothing about the start.
Terminal ownership and Agent-session ownership stay independent. Nothing pane-
derived enters the coordinator key, the launch request, the retained record or a
diagnostic, and two panes naming one logical session still contend through the
existing non-waiting coordinator.
No tmux, no new Agent advertisement, and root launch behavior is unchanged.
Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness,
a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime
launcher (the start event, and a child that never starts), and Tier GN over the
checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent
stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting
readiness fails SP1, SP2, SP3 and SP5.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

@github-actions

github-actionsBot commented Sep 3, 2026

Copy link
Copy Markdown

PR #741: ✨ Launch native Agent sessions in independent terminal panes (#731)

12 files, +1508 / -34

Scope

🔴 PR has 1542 lines changed. Split into focused PRs.

🟡 1542 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unused-vars ×10: packages/core/src/expand.ts, packages/acp/src/provider.ts
  • no-empty-function ×3: packages/runtime/launcher.ts, packages/acp/src/provider.ts
  • no-unnecessary-type-assertion ×2: packages/core/src/expand.ts
  • no-redundant-type-constituents ×1: packages/runtime/launcher.ts

Slop

  • packages/acp/src/provider.ts:2766// path there is.
  • packages/acp/src/provider.ts:2787// deliberately.
  • packages/core/src/expand.ts:2254// makes this pane ready.
  • packages/runtime/launcher.ts:267// reports having started.

Oxlint slop signals:

  • no-inferrable-types ×2: packages/core/src/expand.ts

Static Analysis

Oxlint: 51 diagnostics across 3 files (15 rules)
Density: 0.034 violations/added-line

no-unused-vars (10): packages/core/src/expand.ts, packages/acp/src/provider.ts
consistent-function-scoping (10): packages/acp/src/provider.ts
no-shadow (5): packages/core/src/expand.ts, packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-base-to-string (4): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/expand.ts, packages/acp/src/provider.ts
no-empty-function (3): packages/runtime/launcher.ts, packages/acp/src/provider.ts
no-useless-spread (3): packages/acp/src/provider.ts
no-inferrable-types (2): packages/core/src/expand.ts
no-floating-promises (2): packages/acp/src/provider.ts
no-unnecessary-type-assertion (2): packages/core/src/expand.ts
unbound-method (2): packages/core/src/expand.ts
no-useless-fallback-in-spread (1): packages/core/src/expand.ts
no-redundant-type-constituents (1): packages/runtime/launcher.ts
restrict-template-expressions (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

No production change. The pane-scoped launcher, the runtime spawn callback, the
authority boundaries and root-launch behavior are exactly as reviewed.
The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent
sessions and the host's default shell. All four children report their start
before the composite attaches, each waits for its siblings while holding its own
pane, and the row reads back the authored row-major positions and forms.
Four rows added, all driven by signals this run produced:
- GN7: after attachment one native UI exits nonzero while its sibling is live.
Only that pane fails; the sibling is observed alive on the far side of the
failure and stops only when the reader leaves; the grid ends on the pane that
failed, and the close's cancellation is not a second failure.
- GN8: the reader leaves with both launches live. Both are cancelled where they
stood, neither pane fails, the composite comes down — and a root launch after
the grid, naming a session a pane held, proves both leases came back. Which
refusal it gets is the proof: not "already holds this run's terminal", not
"another owner is using session", but the #517 recovery tombstone a cancelled
native UI leaves behind.
- GN9: a pane admits its next user only once the last one is wholly done, with
the launch and the prompt that follows it going through the real coordinator.
- GN10: a grid interrupted with a live pane launch, resumed on the same journal.
It rebuilds the composite, starts the native child on the identity the first
attempt retained, prepares nothing, and the retained record comes back
unchanged — identity, route, binding and phase alike.
SP5 now proves the pane stays held through both halves: refused while the child
is live, refused again once the child has gone but the lease around it is still
unwinding, admitted only after both. A launch that merely returned showed only
the first.
Two harness repairs. Pane states are read as a set of panes rather than a count
of messages — a pane still live when the reader leaves is told twice, once from
the outcome close decided and once from its own settlement, and that is display
rather than a second settlement. And a generated variant is written to a
directory of its own with copies of the scenarios it names, so a killed run
leaves nothing in the repository.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

The harness's interrupted branch built a `Result` as an object literal and cast
it. Effection has a constructor for exactly that, so it uses it: no cast, and
the type is the constructor's rather than an assertion's.
Behavior and evidence are unchanged.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 1 redundant comment. Inline suggestions to remove them below.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

An orderly cancellation that finished proves everything a normal return proves,
and must say so. It did not: `ownership.quiesced()` was a statement after
`authority.perform()`, and cancellation unwinds past every statement after the
operation it cancels. A reader closing a terminal grid therefore left every
session its panes had launched carrying a recovery tombstone, and the next owner
was told to recover a session nothing was using.
The launch now runs in a scope the ownership body owns, and the acknowledgement
is that scope's cleanup — reached on every path there is, cancellation included.
It brings the launch down deliberately and reads the outcome of doing so, so the
two facts it needs are facts rather than inferences: the native child and its
cleanup settled, and this provider holds no handle for the session. A teardown
that could not prove the child stopped throws out of `destroy()` and is not
quiescence — and is still a failure, so it propagates rather than passing
quietly.
Nothing grid-specific reaches the provider. Reader close is the ordinary launch
cancellation path, and this is the ordinary launch cancellation path's rule.
The conservative cases keep their tombstone: a detach that failed or a session
prepared and never handed over leaves a handle, and a child or provider cleanup
that failed leaves the acknowledgement unmade. Cancellation, a released lease, a
PID and elapsed time still prove nothing on their own.
CX1 asserted the behavior this replaces — that a cancelled launch stays owned —
so it now asserts the accepted one. CX2 is new and holds the other half: a
cleanup that could not finish withholds quiescence, and the record stays active.
GN8 is rebuilt as directed: two pane children held on unresolved operations,
signals from each child's own teardown, teardown proven to finish after both,
and a root launch afterwards on one of the same logical sessions that acquires
ownership and starts — receiving neither session-busy nor
session-recovery-required, and reclaiming the root foreground lease as it goes.
Broken on purpose and re-run: acknowledging only on a normal return fails CX1
and GN8; acknowledging without proving the cleanup settled fails CX2, and only
CX2.

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 4 redundant comments. Inline suggestions to remove them below.

// the reader closing a terminal grid is one — unwinds past every
// statement after it, so a decision written down here would never be
// reached; written as this scope's cleanup, it is reached on every
// path there is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// path there is.

// prepared and never handed over, leaves one. Either one
// missing leaves the session owned rather than looking
// finished, which is what the next owner is told to recover
// deliberately.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// deliberately.

// A `<Session.Launch>` written in this pane finds this launcher simply
// by being here: it reserves and flushes this pane instead of competing
// for the run's one foreground lease, and the child it starts is what
// makes this pane ready.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// makes this pane ready.


// The runtime's own start event, and the only thing reported as one. A
// spawn that fails emits `error` instead, so a child that never ran never
// reports having started.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant comment — restates what the code does.

Suggested change
// reports having started.

Sign up for freeto 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

@taras