From f8e54317abed8eddf805871c8408741ff5ce629c Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:07:38 +0000 Subject: [PATCH 1/6] Queue messages during active turns --- packages/cli/CHANGELOG.md | 6 ++ packages/cli/src/tui/main.ts | 85 +++++++++++++++++-- .../cli/test/fixtures/tui-fixtures/steer.json | 23 +++++ packages/cli/test/tui.fixture.test.ts | 38 +++++++++ 4 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 packages/cli/test/fixtures/tui-fixtures/steer.json diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 7579acad..be6eec31 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Queue messages submitted during an active turn for steering at the next agent + step. Pressing `esc` interrupts the active work and immediately starts a new + turn with any steering messages that were still queued. + ## 0.7.0 - 2026-08-03 - Add `openrouter:moonshotai/kimi-k3` model selection and diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts index c12a8e1b..2bb45c19 100644 --- a/packages/cli/src/tui/main.ts +++ b/packages/cli/src/tui/main.ts @@ -182,8 +182,13 @@ export async function runInteractive(opts: InteractiveOptions): Promise let assistantBuffer: AssistantBuffer | undefined; let inflight = 0; + let promptRunning = 0; + let interrupting = false; + let queuedDuringInterrupt: string[] = []; let lastDisplayedError: string | undefined; + const isTurnRunning = (): boolean => inflight > 0 || promptRunning > 0; + // Ref of the live model, kept in sync by switchModel so the picker can mark // it with a ✓. Undefined when opts.modelRef is not a catalog ref. let currentModelRef: CuaModelRef | undefined = tryResolveModelRef(opts.modelRef); @@ -496,6 +501,15 @@ export async function runInteractive(opts: InteractiveOptions): Promise }); }; + const promptAgent = async (text: string): Promise => { + promptRunning += 1; + try { + await opts.harness.prompt(text); + } finally { + promptRunning -= 1; + } + }; + const runPrompt = async (text: string): Promise => { debug?.log("run_prompt_start", { length: text.length }); try { @@ -548,7 +562,19 @@ export async function runInteractive(opts: InteractiveOptions): Promise await opts.harness.skill(skill.name, skillRemainder); return; } - await opts.harness.prompt(text); + if (interrupting) { + queuedDuringInterrupt.push(text); + messages.addNotice("queued for the interrupted turn"); + requestRender("prompt_queued_during_interrupt"); + return; + } + if (isTurnRunning()) { + await opts.harness.steer(text); + messages.addNotice("queued for the next available turn"); + requestRender("prompt_queued_for_steer"); + return; + } + await promptAgent(text); } catch (err) { messages.addError((err as Error).message); debug?.log("run_prompt_error", { message: (err as Error).message }); @@ -568,13 +594,48 @@ export async function runInteractive(opts: InteractiveOptions): Promise void runPrompt(trimmed); }; + const interruptTurn = async (): Promise => { + if (interrupting) return; + interrupting = true; + try { + const { clearedSteer, clearedFollowUp } = await opts.harness.abort(); + const queued = [ + ...clearedSteer.map(userMessageText).filter((text): text is string => !!text), + ...clearedFollowUp.map(userMessageText).filter((text): text is string => !!text), + ...queuedDuringInterrupt, + ]; + queuedDuringInterrupt = []; + if (queued.length === 0) { + messages.addNotice("turn aborted"); + requestRender("input_abort_stream", false, { key: "escape" }); + return; + } + + const text = queued.join("\n\n"); + messages.addNotice(`turn interrupted; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`); + requestRender("input_interrupt_and_send", false, { queued: queued.length }); + interrupting = false; + void promptAgent(text).catch((err: unknown) => { + messages.addError((err as Error).message); + debug?.log("queued_prompt_error", { message: (err as Error).message }); + requestRender("queued_prompt_error"); + }); + } catch (err) { + messages.addError((err as Error).message); + debug?.log("input_interrupt_error", { message: (err as Error).message }); + requestRender("input_interrupt_error"); + } finally { + interrupting = false; + } + }; + const removeListener = tui.addInputListener((data) => { // Input listeners run before the focused component, so an open picker has // to own every key: otherwise ctrl+c / ctrl+d here would quit the app // instead of cancelling the picker. if (activeSelector) return undefined; if (matchesKey(data, "ctrl+c")) { - if (inflight > 0) { + if (isTurnRunning() || interrupting) { void opts.harness.abort(); messages.addNotice("aborted"); debug?.log("input_abort_stream", { key: "ctrl+c" }); @@ -591,11 +652,9 @@ export async function runInteractive(opts: InteractiveOptions): Promise debug?.log("input_exit_request", { key: "ctrl+d" }); return { consume: true }; } - if (matchesKey(data, "escape") && inflight > 0) { - void opts.harness.abort(); - messages.addNotice("turn aborted"); - debug?.log("input_abort_stream", { key: "escape" }); - requestRender("input_abort_stream", false, { key: "escape" }); + if (matchesKey(data, "escape") && (isTurnRunning() || interrupting)) { + void interruptTurn(); + debug?.log("input_interrupt_stream", { key: "escape" }); return { consume: true }; } return undefined; @@ -616,7 +675,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise await waitForExit( () => exitRequested, - () => inflight > 0, + () => isTurnRunning() || interrupting, ); return 0; @@ -644,6 +703,16 @@ function modelLabel(model: Model | undefined): string { return model.id; } +function userMessageText(message: AgentMessage): string | undefined { + if (message.role !== "user") return undefined; + if (typeof message.content === "string") return message.content.trim() || undefined; + const text = message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join(""); + return text.trim() || undefined; +} + function lastErrorMessage(messages: AgentMessage[]): string | undefined { for (let i = messages.length - 1; i >= 0; i -= 1) { const m = messages[i]; diff --git a/packages/cli/test/fixtures/tui-fixtures/steer.json b/packages/cli/test/fixtures/tui-fixtures/steer.json new file mode 100644 index 00000000..b27fea98 --- /dev/null +++ b/packages/cli/test/fixtures/tui-fixtures/steer.json @@ -0,0 +1,23 @@ +{ + "modelRef": "openai:gpt-5.5", + "turns": [ + { + "steps": [ + { + "type": "text", + "text": "working...", + "chunkSize": 5, + "chunkMs": 500 + } + ] + }, + { + "steps": [ + { + "type": "text", + "text": "queued response" + } + ] + } + ] +} diff --git a/packages/cli/test/tui.fixture.test.ts b/packages/cli/test/tui.fixture.test.ts index 8e012aba..9233f564 100644 --- a/packages/cli/test/tui.fixture.test.ts +++ b/packages/cli/test/tui.fixture.test.ts @@ -118,6 +118,44 @@ suite("TUI ptywright scenarios", () => { await exitFixture(session); }); + test("queues input during a running turn for the next agent step", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady } = await loadPtywrightHelpers(); + const session = spawnFixture("steer.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + session.line("start the turn"); + await session.waitForVisible("working...", { timeoutMs: WAIT_MS }); + + session.line("use this next"); + await session.waitForVisible("queued for the next available turn", { timeoutMs: WAIT_MS }); + await session.waitForVisible("queued response", { timeoutMs: WAIT_MS }); + + const snapshot = session.snapshot(); + assert.match(snapshot.visible, /use this next/); + assert.doesNotMatch(snapshot.visible, /AgentHarness is busy/); + + await exitFixture(session); + }); + + test("escape interrupts and immediately sends queued input", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady, KeyEscape } = await loadPtywrightHelpers(); + const session = spawnFixture("abort.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + session.line("please run forever"); + await session.waitForVisible("working...", { timeoutMs: WAIT_MS }); + + session.line("switch to this instead"); + await session.waitForVisible("queued for the next available turn", { timeoutMs: WAIT_MS }); + session.press(KeyEscape); + await session.waitForVisible("turn interrupted; sending 1 queued message", { timeoutMs: WAIT_MS }); + await session.waitForVisible("fixture response", { timeoutMs: WAIT_MS }); + + await exitFixture(session); + }); + test("aborts a running turn with ctrl+c and recovers on the next prompt", async (ctx) => { const { spawnFixture, exitFixture, waitForFixtureReady, KeyCtrlC } = await loadPtywrightHelpers(); const session = spawnFixture("abort.json"); From ab238c01fdea0606848d359d87037e8c45d6b301 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:40:25 +0000 Subject: [PATCH 2/6] Harden queued turn interruption --- packages/cli/src/tui/main.ts | 39 ++++++++++------- .../cli/test/fixtures/scripted-provider.ts | 3 +- .../tui-fixtures/interrupt-cancel.json | 25 +++++++++++ packages/cli/test/tui.fixture.test.ts | 42 +++++++++++++++++++ 4 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts index 2bb45c19..c566b3a0 100644 --- a/packages/cli/src/tui/main.ts +++ b/packages/cli/src/tui/main.ts @@ -183,8 +183,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise let assistantBuffer: AssistantBuffer | undefined; let inflight = 0; let promptRunning = 0; - let interrupting = false; - let queuedDuringInterrupt: string[] = []; + let interruptState: { queued: string[]; cancelled: boolean } | undefined; let lastDisplayedError: string | undefined; const isTurnRunning = (): boolean => inflight > 0 || promptRunning > 0; @@ -234,7 +233,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise * protection, so it refuses up front rather than failing on apply. */ const refuseWhileBusy = (command: string): boolean => { - if (inflight === 0) return false; + if (!isTurnRunning() && !interruptState) return false; messages.addError(`${command} is unavailable while a turn is running`); requestRender("selector_busy", false, { command }); return true; @@ -514,6 +513,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise debug?.log("run_prompt_start", { length: text.length }); try { const parsed = parseSlashCommand(text); + if (parsed && refuseWhileBusy(`/${parsed.command}`)) return; if (parsed?.command === "model") { const argument = parsed.argument.trim(); if (!argument) { @@ -562,8 +562,8 @@ export async function runInteractive(opts: InteractiveOptions): Promise await opts.harness.skill(skill.name, skillRemainder); return; } - if (interrupting) { - queuedDuringInterrupt.push(text); + if (interruptState) { + interruptState.queued.push(text); messages.addNotice("queued for the interrupted turn"); requestRender("prompt_queued_during_interrupt"); return; @@ -595,16 +595,18 @@ export async function runInteractive(opts: InteractiveOptions): Promise }; const interruptTurn = async (): Promise => { - if (interrupting) return; - interrupting = true; + if (interruptState) return; + const state: { queued: string[]; cancelled: boolean } = { queued: [], cancelled: false }; + interruptState = state; try { const { clearedSteer, clearedFollowUp } = await opts.harness.abort(); + if (state.cancelled) return; const queued = [ ...clearedSteer.map(userMessageText).filter((text): text is string => !!text), ...clearedFollowUp.map(userMessageText).filter((text): text is string => !!text), - ...queuedDuringInterrupt, + ...state.queued, ]; - queuedDuringInterrupt = []; + state.queued = []; if (queued.length === 0) { messages.addNotice("turn aborted"); requestRender("input_abort_stream", false, { key: "escape" }); @@ -614,18 +616,19 @@ export async function runInteractive(opts: InteractiveOptions): Promise const text = queued.join("\n\n"); messages.addNotice(`turn interrupted; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`); requestRender("input_interrupt_and_send", false, { queued: queued.length }); - interrupting = false; + interruptState = undefined; void promptAgent(text).catch((err: unknown) => { messages.addError((err as Error).message); debug?.log("queued_prompt_error", { message: (err as Error).message }); requestRender("queued_prompt_error"); }); } catch (err) { + state.queued = []; messages.addError((err as Error).message); debug?.log("input_interrupt_error", { message: (err as Error).message }); requestRender("input_interrupt_error"); } finally { - interrupting = false; + if (interruptState === state) interruptState = undefined; } }; @@ -635,7 +638,15 @@ export async function runInteractive(opts: InteractiveOptions): Promise // instead of cancelling the picker. if (activeSelector) return undefined; if (matchesKey(data, "ctrl+c")) { - if (isTurnRunning() || interrupting) { + if (interruptState) { + interruptState.cancelled = true; + interruptState.queued = []; + messages.addNotice("aborted"); + debug?.log("input_cancel_interrupt_replay", { key: "ctrl+c" }); + requestRender("input_cancel_interrupt_replay", false, { key: "ctrl+c" }); + return { consume: true }; + } + if (isTurnRunning()) { void opts.harness.abort(); messages.addNotice("aborted"); debug?.log("input_abort_stream", { key: "ctrl+c" }); @@ -652,7 +663,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise debug?.log("input_exit_request", { key: "ctrl+d" }); return { consume: true }; } - if (matchesKey(data, "escape") && (isTurnRunning() || interrupting)) { + if (matchesKey(data, "escape") && (isTurnRunning() || interruptState)) { void interruptTurn(); debug?.log("input_interrupt_stream", { key: "escape" }); return { consume: true }; @@ -675,7 +686,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise await waitForExit( () => exitRequested, - () => isTurnRunning() || interrupting, + () => isTurnRunning() || !!interruptState, ); return 0; diff --git a/packages/cli/test/fixtures/scripted-provider.ts b/packages/cli/test/fixtures/scripted-provider.ts index 601b2d71..3514d5ed 100644 --- a/packages/cli/test/fixtures/scripted-provider.ts +++ b/packages/cli/test/fixtures/scripted-provider.ts @@ -13,7 +13,7 @@ import { export type ScriptedStep = | { type: "text"; text: string; chunkSize?: number; chunkMs?: number } | { type: "tool_call"; toolName: string; args: Record; id?: string } - | { type: "wait_abort" } + | { type: "wait_abort"; settleMs?: number } | { type: "error"; message: string }; export interface ScriptedTurn { @@ -154,6 +154,7 @@ function buildStream(model: Model, turn: ScriptedTurn | undefined, signal?: contentIndex += 1; } else if (step.type === "wait_abort") { await waitForAbort(signal); + if (step.settleMs) await new Promise((resolve) => setTimeout(resolve, step.settleMs)); aborted = true; break; } else if (step.type === "error") { diff --git a/packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json b/packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json new file mode 100644 index 00000000..71d3ea90 --- /dev/null +++ b/packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json @@ -0,0 +1,25 @@ +{ + "modelRef": "openai:gpt-5.5", + "turns": [ + { + "steps": [ + { + "type": "text", + "text": "working..." + }, + { + "type": "wait_abort", + "settleMs": 500 + } + ] + }, + { + "steps": [ + { + "type": "text", + "text": "fixture response" + } + ] + } + ] +} diff --git a/packages/cli/test/tui.fixture.test.ts b/packages/cli/test/tui.fixture.test.ts index 9233f564..aa703386 100644 --- a/packages/cli/test/tui.fixture.test.ts +++ b/packages/cli/test/tui.fixture.test.ts @@ -156,6 +156,48 @@ suite("TUI ptywright scenarios", () => { await exitFixture(session); }); + test("ctrl+c cancels an escape-triggered queued replay", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady, KeyCtrlC, KeyEscape } = await loadPtywrightHelpers(); + const session = spawnFixture("interrupt-cancel.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + session.line("please run forever"); + await session.waitForVisible("working...", { timeoutMs: WAIT_MS }); + session.line("do not replay this"); + await session.waitForVisible("queued for the next available turn", { timeoutMs: WAIT_MS }); + + session.press(KeyEscape); + session.press(KeyCtrlC); + await session.waitForVisible("aborted", { timeoutMs: WAIT_MS }); + await session.waitForStable(700, { timeoutMs: WAIT_MS }); + assert.doesNotMatch(session.snapshot().visible, /fixture response/); + + session.line("recover after cancelling replay"); + await session.waitForVisible("fixture response", { timeoutMs: WAIT_MS }); + + await exitFixture(session); + }); + + test("refuses slash commands while a turn is running", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady, KeyCtrlC, KeyEnter } = await loadPtywrightHelpers(); + const session = spawnFixture("abort.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + session.line("please run forever"); + await session.waitForVisible("working...", { timeoutMs: WAIT_MS }); + session.send("/thinking high"); + await session.waitForStable(STABLE_MS, { timeoutMs: WAIT_MS }); + session.press(KeyEnter); + session.press(KeyEnter); + await session.waitForVisible("/thinking is unavailable while a turn is running", { timeoutMs: WAIT_MS }); + + session.press(KeyCtrlC); + await session.waitForVisible("aborted", { timeoutMs: WAIT_MS }); + await exitFixture(session); + }); + test("aborts a running turn with ctrl+c and recovers on the next prompt", async (ctx) => { const { spawnFixture, exitFixture, waitForFixtureReady, KeyCtrlC } = await loadPtywrightHelpers(); const session = spawnFixture("abort.json"); From 4fee18b07482ccc8c65be98229ecc9679c881116 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:46:58 +0000 Subject: [PATCH 3/6] Handle interrupt input races --- packages/cli/src/tui/main.ts | 38 ++++++++++++++++++++------- packages/cli/test/tui.fixture.test.ts | 6 ++--- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts index c566b3a0..db7b4ff5 100644 --- a/packages/cli/src/tui/main.ts +++ b/packages/cli/src/tui/main.ts @@ -183,6 +183,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise let assistantBuffer: AssistantBuffer | undefined; let inflight = 0; let promptRunning = 0; + let turnRevision = 0; let interruptState: { queued: string[]; cancelled: boolean } | undefined; let lastDisplayedError: string | undefined; @@ -564,12 +565,14 @@ export async function runInteractive(opts: InteractiveOptions): Promise } if (interruptState) { interruptState.queued.push(text); - messages.addNotice("queued for the interrupted turn"); + messages.addNotice(interruptState.cancelled ? "queued for after abort" : "queued for the interrupted turn"); requestRender("prompt_queued_during_interrupt"); return; } if (isTurnRunning()) { + const revision = turnRevision; await opts.harness.steer(text); + if (revision !== turnRevision) return; messages.addNotice("queued for the next available turn"); requestRender("prompt_queued_for_steer"); return; @@ -594,13 +597,34 @@ export async function runInteractive(opts: InteractiveOptions): Promise void runPrompt(trimmed); }; + const startQueuedPrompt = (queued: string[], notice: string): void => { + messages.addNotice(`${notice}; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`); + requestRender("queued_prompt_start", false, { queued: queued.length }); + void promptAgent(queued.join("\n\n")).catch((err: unknown) => { + messages.addError((err as Error).message); + debug?.log("queued_prompt_error", { message: (err as Error).message }); + requestRender("queued_prompt_error"); + }); + }; + const interruptTurn = async (): Promise => { if (interruptState) return; const state: { queued: string[]; cancelled: boolean } = { queued: [], cancelled: false }; interruptState = state; + turnRevision += 1; + messages.addNotice("interrupting…"); + requestRender("input_interrupt_start", false, { key: "escape" }); try { const { clearedSteer, clearedFollowUp } = await opts.harness.abort(); - if (state.cancelled) return; + if (state.cancelled) { + const queued = state.queued; + state.queued = []; + if (queued.length > 0) { + interruptState = undefined; + startQueuedPrompt(queued, "abort complete"); + } + return; + } const queued = [ ...clearedSteer.map(userMessageText).filter((text): text is string => !!text), ...clearedFollowUp.map(userMessageText).filter((text): text is string => !!text), @@ -613,15 +637,8 @@ export async function runInteractive(opts: InteractiveOptions): Promise return; } - const text = queued.join("\n\n"); - messages.addNotice(`turn interrupted; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`); - requestRender("input_interrupt_and_send", false, { queued: queued.length }); interruptState = undefined; - void promptAgent(text).catch((err: unknown) => { - messages.addError((err as Error).message); - debug?.log("queued_prompt_error", { message: (err as Error).message }); - requestRender("queued_prompt_error"); - }); + startQueuedPrompt(queued, "turn interrupted"); } catch (err) { state.queued = []; messages.addError((err as Error).message); @@ -647,6 +664,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise return { consume: true }; } if (isTurnRunning()) { + turnRevision += 1; void opts.harness.abort(); messages.addNotice("aborted"); debug?.log("input_abort_stream", { key: "ctrl+c" }); diff --git a/packages/cli/test/tui.fixture.test.ts b/packages/cli/test/tui.fixture.test.ts index aa703386..d81e8f5b 100644 --- a/packages/cli/test/tui.fixture.test.ts +++ b/packages/cli/test/tui.fixture.test.ts @@ -168,13 +168,13 @@ suite("TUI ptywright scenarios", () => { await session.waitForVisible("queued for the next available turn", { timeoutMs: WAIT_MS }); session.press(KeyEscape); + await session.waitForVisible("interrupting…", { timeoutMs: WAIT_MS }); session.press(KeyCtrlC); await session.waitForVisible("aborted", { timeoutMs: WAIT_MS }); - await session.waitForStable(700, { timeoutMs: WAIT_MS }); - assert.doesNotMatch(session.snapshot().visible, /fixture response/); - session.line("recover after cancelling replay"); + await session.waitForVisible("queued for after abort", { timeoutMs: WAIT_MS }); await session.waitForVisible("fixture response", { timeoutMs: WAIT_MS }); + assert.doesNotMatch(session.snapshot().visible, /turn interrupted; sending 1 queued message/); await exitFixture(session); }); From 16a889e9055c832cfd1920ae92ae856905ed62c1 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:52:57 +0000 Subject: [PATCH 4/6] Stabilize interrupt cancellation coverage --- packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json b/packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json index 71d3ea90..e007f95d 100644 --- a/packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json +++ b/packages/cli/test/fixtures/tui-fixtures/interrupt-cancel.json @@ -9,7 +9,7 @@ }, { "type": "wait_abort", - "settleMs": 500 + "settleMs": 3000 } ] }, From cfc0c11a26a9dd927adffdd753ea05c086a93b0d Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:16:13 +0000 Subject: [PATCH 5/6] Support registry-backed CLI prereleases --- .github/workflows/release-cua-cli.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-cua-cli.yml b/.github/workflows/release-cua-cli.yml index 371b14f1..e0e27aa9 100644 --- a/.github/workflows/release-cua-cli.yml +++ b/.github/workflows/release-cua-cli.yml @@ -11,6 +11,11 @@ on: required: true default: "ext" type: string + bundle_workspace_dependencies: + description: "Bundle branch builds of cua-ai and cua-agent instead of using their published versions" + required: true + default: true + type: boolean permissions: contents: read @@ -119,7 +124,7 @@ jobs: - run: npm run build --workspace @onkernel/cua-agent - name: Verify published production dependencies - if: github.event_name == 'push' + if: github.event_name == 'push' || !inputs.bundle_workspace_dependencies run: | node -p 'require("./packages/cli/package.json").dependencies["@onkernel/cua-ai"]' \ | xargs -I{} npm view @onkernel/cua-ai@{} version @@ -140,18 +145,18 @@ jobs: run: npm test --workspace @onkernel/cua-cli - name: Pack production tarball - if: github.event_name == 'push' + if: github.event_name == 'push' || !inputs.bundle_workspace_dependencies run: npm pack --workspace @onkernel/cua-cli --pack-destination "$RUNNER_TEMP" - name: Pack branch workspace dependencies - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.bundle_workspace_dependencies run: | mkdir -p "$RUNNER_TEMP/workspace-packages" npm pack --workspace @onkernel/cua-ai --pack-destination "$RUNNER_TEMP/workspace-packages" npm pack --workspace @onkernel/cua-agent --pack-destination "$RUNNER_TEMP/workspace-packages" - name: Pack bundled CLI prerelease - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.bundle_workspace_dependencies run: | export STAGE_DIR="$RUNNER_TEMP/cua-cli-prerelease" mkdir -p "$STAGE_DIR/node_modules/@onkernel" @@ -194,12 +199,15 @@ jobs: npm pack "$STAGE_DIR" --pack-destination "$RUNNER_TEMP" - name: CLI bin smoke test + env: + BUNDLE_WORKSPACE_DEPENDENCIES: ${{ inputs.bundle_workspace_dependencies }} run: | + TARBALL=$(find "$RUNNER_TEMP" -maxdepth 1 -name "onkernel-cua-cli-*.tgz" -print -quit) SMOKE_DIR=$(mktemp -d) cd "$SMOKE_DIR" npm init -y > /dev/null - npm install "$RUNNER_TEMP"/onkernel-cua-cli-*.tgz - if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + npm install "$TARBALL" + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$BUNDLE_WORKSPACE_DEPENDENCIES" == "true" ]]; then test -f node_modules/@onkernel/cua-cli/node_modules/@onkernel/cua-ai/dist/index.js test -f node_modules/@onkernel/cua-cli/node_modules/@onkernel/cua-agent/dist/index.js fi @@ -208,6 +216,12 @@ jobs: echo "$OUTPUT" | grep -q "Usage:" echo "$OUTPUT" | grep -q "cua \[options\] \[prompt\.\.\.\]" + GLOBAL_DIR=$(mktemp -d) + npm install --global --prefix "$GLOBAL_DIR" "$TARBALL" + MODELS_OUTPUT=$("$GLOBAL_DIR/bin/cua" models -p openrouter) + echo "$MODELS_OUTPUT" + echo "$MODELS_OUTPUT" | grep -q "openrouter:moonshotai/kimi-k3" + - name: Publish production release if: github.event_name == 'push' run: npm publish --workspace @onkernel/cua-cli --access public From bac5ab276ac558bdf75f22b157a810db0f779faa Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:26:40 +0000 Subject: [PATCH 6/6] Prepare CUA CLI 0.8.0 release --- package-lock.json | 2 +- packages/cli/CHANGELOG.md | 2 +- packages/cli/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 42eda063..fc54f51b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6241,7 +6241,7 @@ }, "packages/cli": { "name": "@onkernel/cua-cli", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "dependencies": { "@earendil-works/pi-coding-agent": "0.80.10", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index be6eec31..cb94ad4d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.8.0 - 2026-08-03 - Queue messages submitted during an active turn for steering at the next agent step. Pressing `esc` interrupts the active work and immediately starts a new diff --git a/packages/cli/package.json b/packages/cli/package.json index f0189bad..c10c2dce 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@onkernel/cua-cli", - "version": "0.7.0", + "version": "0.8.0", "description": "Kernel-cloud-browser computer-use TUI built on @onkernel/cua-agent and pi-tui", "license": "MIT", "type": "module",