-
Notifications
You must be signed in to change notification settings - Fork 0
feat(codex): drive, resume & answer Codex over the app-server JSON-RPC transport #6
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
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
4c48ce1
feat(ingest): drive codex via the app-server JSON-RPC transport
timhanlon df57d0d
feat(provider): declare an app-server launch capability on ProviderSpec
timhanlon 6e52a41
feat(ingest): land live codex app-server turns in the shared store
timhanlon 6544b54
feat(codex): driver registry + approvalId, the app-server answer surface
timhanlon f6d3fe0
feat(rpc): expose codex app-server approvals to the renderer
timhanlon a131e1e
feat(chat): inline approval card for the app-server answer surface
timhanlon 16b14b1
feat(session): RpcSessionManager — runtime owner for app-server sessions
timhanlon 24f7cbf
feat(session): SessionRuntimeRouter — route pty vs rpc, project rpc t…
timhanlon b2ad8e3
feat(chat): app-server approval cards + rpc launch entry in the renderer
timhanlon 1b93805
fix(session): surface rpc sessions in the unified WatchSessions view
timhanlon a5b43e6
fix(codex): address app-server driver review — transport close, rpc s…
timhanlon 46918aa
feat(codex): resume a codex session into the rpc (app-server) runtime
timhanlon 99656fe
fix(session): release the boot-restored PTY shell when resuming into rpc
timhanlon 67482a1
fix(codex): live 'generating' status + non-blocking composer for rpc …
timhanlon 9dd3f70
fix(chat): show the optimistic user message before the rpc reply lands
timhanlon fba20f0
feat(session): live runtime field on TargetSession; skip terminal for…
timhanlon 65d3453
feat(shell): split focus into PTY-pane focus vs. current composer target
timhanlon 20ab24b
refactor(session): detached sessions are runtime-neutral (drop the rp…
timhanlon 3725df0
fix(session): keep stopped/exited sessions in the unified list
timhanlon 98ada92
fix(session): compute resumable for DB-derived detached sessions
timhanlon 3761ae2
refactor(session): one presenter for TargetSession derived fields
timhanlon 08cb52c
fix(codex): address greptile PR review (trim assistant text, guard re…
timhanlon 0cb2025
fix(chat): publish the rollback when an rpc prompt fails (no ghost bu…
timhanlon 53cd05d
fix(codex): fail the turn when app-server exits mid-turn (no hang)
timhanlon 84e7009
fix(codex): fail pending transport requests on scope close (no hang o…
timhanlon e0c1b12
fix(codex): serialize rpc launches (atomic idempotency check)
timhanlon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,243 @@ | ||
| import { Data, Effect, Option, Queue, type Scope, Stream, SubscriptionRef } from "effect" | ||
| import type { ExtractedRows } from "../../db/schema.js" | ||
| import { obj, str } from "../../extract/json.js" | ||
| import { normalizeAppServerThread } from "./normalize.js" | ||
| import { type ApprovalRequestParams, decodeApprovalParams, decodeThreadStart } from "./protocol.js" | ||
| import { type AppServerTransport, type AppServerTransportError, makeAppServerTransport } from "./transport.js" | ||
|
|
||
| /** | ||
| * The codex app-server driver: the one place that knows the protocol. It owns a | ||
| * live `codex app-server` process (via the generic transport), runs the | ||
| * `initialize → thread/start → turn/start` handshake, folds each turn's | ||
| * `item/completed` + `thread/tokenUsage/updated` stream into the shared | ||
| * `ExtractedRows` (via {@link normalizeAppServerThread}), and runs the approval | ||
| * state machine so `item/<kind>/requestApproval` server requests become a deterministic | ||
| * pending-input signal instead of the racy `outputText`-flash inference. | ||
| * | ||
| * It is a *service* that owns process + thread state, not an `AgentProvider` | ||
| * (whose `collect` is a stateless parse pass). Wire types never escape this file: | ||
| * callers see `ExtractedRows`, a `PendingApproval` list, and a turn status. | ||
| */ | ||
| export class CodexDriverError extends Data.TaggedError("CodexDriverError")<{ | ||
| readonly message: string | ||
| readonly cause?: unknown | ||
| }> {} | ||
|
|
||
| /** An outstanding approval request — the pending-input signal for the app-server path. */ | ||
| export interface PendingApproval { | ||
| /** The JSON-RPC request id — the reliable routing key for `answerApproval`. */ | ||
| readonly id: number | string | ||
| /** Codex's approval handle when present (commandExecution only) — display/correlation detail. */ | ||
| readonly approvalId: string | null | ||
| /** Links to the exact tool-call item already in the projection. */ | ||
| readonly itemId: string | null | ||
| readonly command: string | null | ||
| /** Server-supplied allowable answers; the UI offers these verbatim. */ | ||
| readonly availableDecisions: ReadonlyArray<unknown> | ||
| } | ||
|
|
||
| export interface TurnResult { | ||
| /** `completed` | `interrupted` | `failed` | `unknown`. */ | ||
| readonly status: string | ||
| /** The session's cumulative rows through this turn — ready for `IngestStore.replaceSession`. */ | ||
| readonly rows: ExtractedRows | ||
| } | ||
|
|
||
| export interface CodexAppServerDriver { | ||
| readonly threadId: string | ||
| /** Send a user turn and resolve once it completes, with the session's cumulative rows + status. */ | ||
| readonly runTurn: (text: string) => Effect.Effect<TurnResult, CodexDriverError> | ||
| /** The current outstanding approvals; subscribe via `.changes` for the live signal. */ | ||
| readonly pendingApprovals: SubscriptionRef.SubscriptionRef<ReadonlyArray<PendingApproval>> | ||
| /** Answer an approval with a server-offered decision (pass it through verbatim). */ | ||
| readonly answerApproval: (id: number | string, decision: unknown) => Effect.Effect<void, CodexDriverError> | ||
| } | ||
|
|
||
| export interface CodexDriverOptions { | ||
| readonly cwd: string | ||
| readonly model?: string | ||
| readonly sandbox?: "read-only" | "workspace-write" | "danger-full-access" | ||
| readonly approvalPolicy?: "untrusted" | "on-failure" | "on-request" | "never" | ||
| /** Defaults to `codex`. */ | ||
| readonly command?: string | ||
| /** Defaults to `["app-server"]`. */ | ||
| readonly args?: ReadonlyArray<string> | ||
| readonly env?: Record<string, string> | ||
| readonly clientName?: string | ||
| /** Resume an existing thread by id (`thread/resume`) instead of starting a | ||
| * fresh one (`thread/start`). The id is a codex session id — the same one the | ||
| * PTY path resumes with — so a session started here rejoins under its old id. */ | ||
| readonly resumeThreadId?: string | ||
| } | ||
|
|
||
| interface TurnOutcome { | ||
| readonly items: ReadonlyArray<unknown> | ||
| readonly usage: ReadonlyArray<unknown> | ||
| readonly status: string | ||
| } | ||
|
|
||
| // What `runTurn` awaits: a completed turn, or an `aborted` signal offered when | ||
| // the notification stream ends (the process died mid-turn) so the awaiting | ||
| // `Queue.take` fails fast instead of blocking forever on a completion that will | ||
| // never arrive. | ||
| type TurnSignal = { readonly kind: "completed"; readonly outcome: TurnOutcome } | { readonly kind: "aborted" } | ||
|
|
||
| const wrap = | ||
| (message: string) => | ||
| <A, R>(effect: Effect.Effect<A, AppServerTransportError, R>): Effect.Effect<A, CodexDriverError, R> => | ||
| effect.pipe(Effect.mapError((cause) => new CodexDriverError({ message, cause }))) | ||
|
|
||
| export const makeCodexAppServerDriver = ( | ||
| options: CodexDriverOptions, | ||
| ): Effect.Effect<CodexAppServerDriver, CodexDriverError, Scope.Scope> => | ||
| Effect.gen(function* () { | ||
| const transport: AppServerTransport = yield* makeAppServerTransport({ | ||
| command: options.command ?? "codex", | ||
| args: options.args ?? ["app-server"], | ||
| cwd: options.cwd, | ||
| env: options.env, | ||
| }).pipe(wrap("failed to spawn codex app-server")) | ||
|
|
||
| // Handshake: initialize → initialized → thread/start (fresh) or thread/resume | ||
| // (rejoin an existing thread by id). Both answer with `{ thread: { id } }`, so | ||
| // the same decoder extracts the thread id either way. | ||
| yield* transport | ||
| .request("initialize", { | ||
| clientInfo: { name: options.clientName ?? "arc", title: "Arc", version: "0.0.1" }, | ||
| }) | ||
| .pipe(wrap("initialize failed")) | ||
| yield* transport.notify("initialized", {}).pipe(wrap("initialized notify failed")) | ||
| const threadConfig = { | ||
| cwd: options.cwd, | ||
| model: options.model, | ||
| sandbox: options.sandbox, | ||
| approvalPolicy: options.approvalPolicy, | ||
| } | ||
| const [method, params] = options.resumeThreadId | ||
| ? (["thread/resume", { threadId: options.resumeThreadId, ...threadConfig }] as const) | ||
| : (["thread/start", threadConfig] as const) | ||
| const started = yield* transport.request(method, params).pipe(wrap(`${method} failed`)) | ||
| const thread = decodeThreadStart(started) | ||
| if (Option.isNone(thread)) { | ||
| return yield* Effect.fail( | ||
| new CodexDriverError({ message: `${method} returned no thread id`, cause: started }), | ||
| ) | ||
| } | ||
| const threadId = thread.value.thread.id | ||
|
|
||
| const pendingApprovals = yield* SubscriptionRef.make<ReadonlyArray<PendingApproval>>([]) | ||
| const turnOutcomes = yield* Queue.make<TurnSignal>() | ||
|
|
||
| // One sequential fiber folds the notification stream. Accumulation is | ||
| // thread-cumulative, not per-turn: the store's `replaceSession` replaces a | ||
| // session with the whole row set (the file scraper re-parses the entire | ||
| // rollout each time), so each `turn/completed` hands `runTurn` the session's | ||
| // rows *so far* — a snapshot copy, since the accumulators keep growing. | ||
| // Single-consumer, so the mutable accumulators need no locking. | ||
| const items: Array<unknown> = [] | ||
| const usage: Array<unknown> = [] | ||
| yield* transport.notifications.pipe( | ||
| Stream.runForEach((notification) => | ||
| Effect.gen(function* () { | ||
| switch (notification.method) { | ||
| case "item/completed": { | ||
| const item = obj(notification.params)?.["item"] | ||
| if (item !== undefined) items.push(item) | ||
| break | ||
| } | ||
| case "thread/tokenUsage/updated": { | ||
| usage.push(notification.params) | ||
| break | ||
| } | ||
| case "turn/completed": { | ||
| const turn = obj(obj(notification.params)?.["turn"]) | ||
| const status = str(turn?.["status"]) ?? "unknown" | ||
| yield* Queue.offer(turnOutcomes, { | ||
| kind: "completed", | ||
| outcome: { items: items.slice(), usage: usage.slice(), status }, | ||
| }) | ||
| break | ||
| } | ||
| case "serverRequest/resolved": { | ||
| // Guard the id: an absent/malformed field would leave `requestId` | ||
| // undefined, and `approval.id !== undefined` matches every pending | ||
| // approval — so the filter would clear nothing and the card would | ||
| // stick until scope close. Only act on a real id. | ||
| const requestId = obj(notification.params)?.["requestId"] | ||
| if (typeof requestId !== "number" && typeof requestId !== "string") break | ||
| yield* SubscriptionRef.update(pendingApprovals, (list) => | ||
| list.filter((approval) => approval.id !== requestId), | ||
| ) | ||
| break | ||
| } | ||
| } | ||
| }), | ||
| ), | ||
| // The transport shuts its queues on process exit, which *interrupts* this | ||
| // fold (a blocked `Queue.take` on a shut-down queue is interrupted, not a | ||
| // graceful end) — so use `ensuring`, not `andThen`, to signal an in-flight | ||
| // turn. Its `turn/completed` will never arrive; the waiting `runTurn` must | ||
| // fail instead of blocking on the outcome queue forever. | ||
| Effect.ensuring(Queue.offer(turnOutcomes, { kind: "aborted" as const })), | ||
| Effect.forkScoped, | ||
| ) | ||
|
timhanlon marked this conversation as resolved.
|
||
|
|
||
| // Approvals: record each `requestApproval` as a pending-input signal. We do | ||
| // not auto-answer — the UI (or a headless policy) calls `answerApproval`. | ||
| // Any other server request is answered emptily so the server never blocks. | ||
| yield* transport.serverRequests.pipe( | ||
| Stream.runForEach((request) => | ||
| Effect.gen(function* () { | ||
| if (!request.method.endsWith("requestApproval")) { | ||
| yield* transport.respond(request.id, {}).pipe(Effect.ignore) | ||
| return | ||
| } | ||
| const params: ApprovalRequestParams = decodeApprovalParams(request.params).pipe( | ||
| Option.getOrElse(() => ({}) as ApprovalRequestParams), | ||
| ) | ||
| const approval: PendingApproval = { | ||
| id: request.id, | ||
| approvalId: params.approvalId ?? null, | ||
| itemId: params.itemId ?? null, | ||
| command: params.command ?? null, | ||
| availableDecisions: params.availableDecisions ?? [], | ||
| } | ||
| yield* SubscriptionRef.update(pendingApprovals, (list) => [...list, approval]) | ||
| }), | ||
| ), | ||
| Effect.forkScoped, | ||
| ) | ||
|
|
||
| const runTurn = (text: string): Effect.Effect<TurnResult, CodexDriverError> => | ||
| Effect.gen(function* () { | ||
| yield* transport | ||
| .request("turn/start", { threadId, input: [{ type: "text", text }] }) | ||
| .pipe(wrap("turn/start failed")) | ||
| const signal = yield* Queue.take(turnOutcomes) | ||
| if (signal.kind === "aborted") { | ||
| return yield* Effect.fail( | ||
| new CodexDriverError({ message: "codex app-server exited before the turn completed" }), | ||
| ) | ||
| } | ||
| const outcome = signal.outcome | ||
| const rows = normalizeAppServerThread(outcome.items, outcome.usage, { | ||
| nativeSessionId: threadId, | ||
| workspaceRoot: options.cwd, | ||
| sourcePath: `appserver:${threadId}`, | ||
| model: options.model ?? null, | ||
| }) | ||
| return { status: outcome.status, rows } | ||
| }) | ||
|
|
||
| const answerApproval = (id: number | string, decision: unknown): Effect.Effect<void, CodexDriverError> => | ||
| transport | ||
| .respond(id, { decision }) | ||
| .pipe( | ||
| Effect.andThen( | ||
| SubscriptionRef.update(pendingApprovals, (list) => list.filter((approval) => approval.id !== id)), | ||
| ), | ||
| wrap("answerApproval failed"), | ||
| ) | ||
|
|
||
| return { threadId, runTurn, pendingApprovals, answerApproval } | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { Effect, type Scope } from "effect" | ||
| import type { AppServerCapability } from "../../../../shared/provider.js" | ||
| import { IngestStore } from "../../db/store.js" | ||
| import { | ||
| type CodexAppServerDriver, | ||
| CodexDriverError, | ||
| type CodexDriverOptions, | ||
| makeCodexAppServerDriver, | ||
| } from "./driver.js" | ||
|
|
||
| export interface CodexLaunchParams { | ||
| readonly cwd: string | ||
| readonly model?: string | ||
| readonly sandbox?: CodexDriverOptions["sandbox"] | ||
| readonly approvalPolicy?: CodexDriverOptions["approvalPolicy"] | ||
| readonly env?: Record<string, string> | ||
| readonly clientName?: string | ||
| /** Rejoin an existing thread by id (`thread/resume`) instead of starting fresh. */ | ||
| readonly resumeThreadId?: string | ||
| } | ||
|
|
||
| /** | ||
| * Launch a codex app-server driver from a provider's {@link AppServerCapability} | ||
| * and persist each completed turn's cumulative rows into the shared | ||
| * {@link IngestStore}. The rows and the store are the same ones the rollout-file | ||
| * provider writes, so a live-driven session and a scraped session are | ||
| * indistinguishable downstream — one store, one projection. This is the reader | ||
| * of the `ProviderSpec.appServer` capability. | ||
| * | ||
| * Returns the driver with a `runTurn` that also persists; `pendingApprovals` / | ||
| * `answerApproval` are unchanged (the UI answers the pending-input signal). | ||
| */ | ||
| export const launchCodexAppServerSession = ( | ||
| capability: AppServerCapability, | ||
| params: CodexLaunchParams, | ||
| ): Effect.Effect<CodexAppServerDriver, CodexDriverError, Scope.Scope | IngestStore> => | ||
| Effect.gen(function* () { | ||
| const store = yield* IngestStore | ||
| const driver = yield* makeCodexAppServerDriver({ | ||
| command: capability.launchCmd, | ||
| args: [...capability.args], | ||
| cwd: params.cwd, | ||
| model: params.model, | ||
| sandbox: params.sandbox, | ||
| approvalPolicy: params.approvalPolicy, | ||
| env: params.env, | ||
| clientName: params.clientName, | ||
| resumeThreadId: params.resumeThreadId, | ||
| }) | ||
|
|
||
| const runTurn = (text: string) => | ||
| driver.runTurn(text).pipe( | ||
| Effect.tap((result) => | ||
| store | ||
| .replaceSession(result.rows) | ||
| .pipe(Effect.mapError((cause) => new CodexDriverError({ message: "persist turn failed", cause }))), | ||
| ), | ||
| ) | ||
|
|
||
| return { ...driver, runTurn } | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.