- Notifications
You must be signed in to change notification settings - Fork 1
✨ Launch native Agent sessions in independent terminal panes (#731)#741
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
2b287536b3e75f1d9609d8b8936cFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -22,6 +22,7 @@ | ||||
| import { | ||||
| createChannel, | ||||
| createScope, | ||||
| ensure, | ||||
| Err, | ||||
| Ok, | ||||
| @@ -2756,24 +2757,57 @@ function* useAcpxProviderState( | ||||
| // the reader's terminal while offering no way to reach the owner it | ||||
| // was waiting for. It refuses instead, and the coordinator is what | ||||
| // refuses it. | ||||
| yield* authority.perform(request, { | ||||
| prepare: () => | ||||
| withSessionRoute(context, () => | ||||
| prepareLaunch(invocation, agentName, callerCwd, request.instructions, placement), | ||||
| ), | ||||
| detach: (prepared) => detachSession(invocation, prepared, agentCommandOf(placement)), | ||||
| exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), | ||||
| // | ||||
| // The launch runs in a scope of its own so that this owner can bring | ||||
| // it down deliberately and watch how that goes. A cancelled launch — | ||||
| // 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. | ||||
| const [running, stop] = createScope(yield* useScope()); | ||||
| let stopped = false; | ||||
| yield* ensure(function* () { | ||||
| // Registered after the scope exists, so it runs before the scope | ||||
| // is destroyed on its own: the launch comes down here, and | ||||
| // `destroy()` carries the outcome of its teardown. A child that | ||||
| // could not be proven stopped, or a cleanup that failed, throws | ||||
| // out of it — and is not quiescence, and is still a failure. | ||||
| try { | ||||
| yield* until(stop()); | ||||
| stopped = true; | ||||
| } finally { | ||||
| // Everything this owner started has to be finished with the | ||||
| // session, and that is two facts rather than one: the native | ||||
| // child and its cleanup settled, and this provider holds no | ||||
| // handle for the session — a detach that failed, or a session | ||||
| // 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. | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redundant comment — restates what the code does.
Suggested change
| ||||
| if (stopped && !holding(placement.sessionKey)) { | ||||
| ownership.quiesced(); | ||||
| } | ||||
| } | ||||
| }); | ||||
| // Only here, and only once this provider is holding nothing. By the | ||||
| // time `perform` returns the native child has exited and been reaped, | ||||
| // so what is left to check is the ACP handle: a handoff that released | ||||
| // it quiesces, and one that could not — a detach that failed, a | ||||
| // session prepared but never handed over — leaves the session owned | ||||
| // rather than looking finished. | ||||
| if (!holding(placement.sessionKey)) { | ||||
| ownership.quiesced(); | ||||
| } | ||||
| yield* running.run(() => | ||||
| authority.perform(request, { | ||||
| prepare: () => | ||||
| withSessionRoute(context, () => | ||||
| prepareLaunch( | ||||
| invocation, | ||||
| agentName, | ||||
| callerCwd, | ||||
| request.instructions, | ||||
| placement, | ||||
| ), | ||||
| ), | ||||
| detach: (prepared) => | ||||
| detachSession(invocation, prepared, agentCommandOf(placement)), | ||||
| exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), | ||||
| }), | ||||
| ); | ||||
| }, | ||||
| ); | ||||
| } catch (error) { | ||||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -72,6 +72,7 @@ import { durableGrid, openTerminalGrid, toRequest } from "./terminal/grid.ts"; | ||||
| import type { PaneWork } from "./terminal/grid.ts"; | ||||
| import { recordGridLayout } from "./terminal/journal.ts"; | ||||
| import { usePaneTerminal } from "./terminal/pane.ts"; | ||||
| import { usePaneNativeLauncher } from "./terminal/pane-launcher.ts"; | ||||
| import { | ||||
| asBindingViolation, | ||||
| asExpressionViolation, | ||||
| @@ -2236,13 +2237,28 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { | ||||
| // in its content has no loop to exit and says so. | ||||
| yield* ActiveLoop.set(undefined); | ||||
| yield* usePaneTerminal(claim); | ||||
| const shown: Segment[] = []; | ||||
| // What this pane has rendered and not yet shown. A native UI is about | ||||
| // to draw over the pane, so the same rule the root flush follows holds | ||||
| // here: everything the pane has said reaches the reader first. | ||||
| const flushPane = function* (): Operation<void> { | ||||
| const pending = renderSegments(shown); | ||||
| shown.length = 0; | ||||
| if (pending.length > 0) { | ||||
| yield* composite.display(pane.ordinal, pending); | ||||
| } | ||||
| }; | ||||
| // 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. | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redundant comment — restates what the code does.
Suggested change
| ||||
| yield* usePaneNativeLauncher(claim, flushPane); | ||||
| const siteEnv = yield* env; | ||||
| // Starts from what the grid site can see and keeps its own writes: a | ||||
| // binding this pane makes is visible to later work in this pane and to | ||||
| // nothing else. | ||||
| yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) })); | ||||
| const shown: Segment[] = []; | ||||
| yield* expandSegmentsWithin( | ||||
| pane.element.children, | ||||
| site.parentMeta, | ||||
| @@ -2266,10 +2282,7 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { | ||||
| // one outside the grid. | ||||
| undefined, | ||||
| ); | ||||
| const text = renderSegments(shown); | ||||
| if (text.length > 0) { | ||||
| yield* composite.display(pane.ordinal, text); | ||||
| } | ||||
| yield* flushPane(); | ||||
| }); | ||||
| }, | ||||
| }; | ||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * How a native UI reaches a pane's terminal instead of the run's | ||
| * (architecture.md §Terminal authority, spec §Terminal-grid composition). | ||
| * | ||
| * `<Session.Launch>` written at the root takes the one foreground-terminal | ||
| * lease, and every other launch waits for it. Written inside a pane it must | ||
| * not: panes stay interactive at the same time, which is the whole reason a | ||
| * grid exists. So core installs this in the pane's own scope, and the launch | ||
| * finds it simply by being there. | ||
| * | ||
| * Nothing about the launch changes. It is handed no pane prop, token, | ||
| * identifier or mode; its request, its result and its retained phases are the | ||
| * ones a root launch would have. What changes is which terminal answers | ||
| * `reserve` and `flush`, and that is a composition fact rather than something | ||
| * the document or the provider can see. | ||
| * | ||
| * The claim is the authority, and it is closed over rather than passed on. A | ||
| * pane claim buys one interactive terminal at one ordinal — it says nothing | ||
| * about which Agent session that pane may own, which stays the session | ||
| * coordinator's to answer. | ||
| */ | ||
| import { resource } from "effection"; | ||
| import type { Operation } from "effection"; | ||
| import { NativeLauncher } from "@executablemd/runtime"; | ||
| import type { TerminalPaneClaim } from "./authority.ts"; | ||
| /** | ||
| * Install one pane's native launcher for the scope that runs that pane's work. | ||
| * | ||
| * `flush` is how this pane catches the reader up. A pane's rendered text | ||
| * belongs to the pane, so it goes where the pane's text goes rather than to the | ||
| * root's streams — which the native UI is not drawing over. | ||
| */ | ||
| export function* usePaneNativeLauncher( | ||
| claim: TerminalPaneClaim, | ||
| flush: () => Operation<void>, | ||
| ): Operation<void> { | ||
| yield* NativeLauncher.around({ | ||
| /** | ||
| * This pane, for as long as the launch holds it. | ||
| * | ||
| * Deliberately not delegated: delegating would ask for the root lease, | ||
| * which the grid itself is already holding, and two panes would contend | ||
| * over a terminal neither of them is using. The claim refuses a second live | ||
| * launch on *this* pane and does not contend with any other, which is | ||
| * exactly the exclusivity a pane has. | ||
| * | ||
| * It is released when the launch's scope ends, so the pane is free only | ||
| * after the launcher has finished with the child it started. | ||
| */ | ||
| reserve() { | ||
| return resource<void>(function* (provide) { | ||
| yield* claim.admit(function* () { | ||
| yield* provide(); | ||
| }); | ||
| }); | ||
| }, | ||
| *flush() { | ||
| yield* flush(); | ||
| }, | ||
| *launch([request, spawned], next) { | ||
| // The exact request, untouched, to whichever host launcher is installed. | ||
| // What this adds is a listener: the pane is ready when the runtime says | ||
| // the child started, and at no earlier moment. | ||
| return yield* next(request, () => { | ||
| claim.ready(); | ||
| spawned(); | ||
| }); | ||
| }, | ||
| }); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.