Visitar URL original
Merge pull request #71 from kernel/hypeship/queue-turn-interrupts · kernel/browser-loop@e40312d · GitHub
Skip to content

Commit e40312d

Browse files
authored
Merge pull request #71 from kernel/hypeship/queue-turn-interrupts
Queue messages during active CLI turns
2 parents 0c7bd59 + bac5ab2 commit e40312d

9 files changed

Lines changed: 265 additions & 18 deletions

File tree

‎.github/workflows/release-cua-cli.yml‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ on:
1111
required: true
1212
default: "ext"
1313
type: string
14+
bundle_workspace_dependencies:
15+
description: "Bundle branch builds of cua-ai and cua-agent instead of using their published versions"
16+
required: true
17+
default: true
18+
type: boolean
1419

1520
permissions:
1621
contents: read
@@ -119,7 +124,7 @@ jobs:
119124
- run: npm run build --workspace @onkernel/cua-agent
120125

121126
- name: Verify published production dependencies
122-
if: github.event_name == 'push'
127+
if: github.event_name == 'push' || !inputs.bundle_workspace_dependencies
123128
run: |
124129
node -p 'require("./packages/cli/package.json").dependencies["@onkernel/cua-ai"]' \
125130
| xargs -I{} npm view @onkernel/cua-ai@{} version
@@ -140,18 +145,18 @@ jobs:
140145
run: npm test --workspace @onkernel/cua-cli
141146

142147
- name: Pack production tarball
143-
if: github.event_name == 'push'
148+
if: github.event_name == 'push' || !inputs.bundle_workspace_dependencies
144149
run: npm pack --workspace @onkernel/cua-cli --pack-destination "$RUNNER_TEMP"
145150

146151
- name: Pack branch workspace dependencies
147-
if: github.event_name == 'workflow_dispatch'
152+
if: github.event_name == 'workflow_dispatch' && inputs.bundle_workspace_dependencies
148153
run: |
149154
mkdir -p "$RUNNER_TEMP/workspace-packages"
150155
npm pack --workspace @onkernel/cua-ai --pack-destination "$RUNNER_TEMP/workspace-packages"
151156
npm pack --workspace @onkernel/cua-agent --pack-destination "$RUNNER_TEMP/workspace-packages"
152157
153158
- name: Pack bundled CLI prerelease
154-
if: github.event_name == 'workflow_dispatch'
159+
if: github.event_name == 'workflow_dispatch' && inputs.bundle_workspace_dependencies
155160
run: |
156161
export STAGE_DIR="$RUNNER_TEMP/cua-cli-prerelease"
157162
mkdir -p "$STAGE_DIR/node_modules/@onkernel"
@@ -194,12 +199,15 @@ jobs:
194199
npm pack "$STAGE_DIR" --pack-destination "$RUNNER_TEMP"
195200
196201
- name: CLI bin smoke test
202+
env:
203+
BUNDLE_WORKSPACE_DEPENDENCIES: ${{ inputs.bundle_workspace_dependencies }}
197204
run: |
205+
TARBALL=$(find "$RUNNER_TEMP" -maxdepth 1 -name "onkernel-cua-cli-*.tgz" -print -quit)
198206
SMOKE_DIR=$(mktemp -d)
199207
cd "$SMOKE_DIR"
200208
npm init -y > /dev/null
201-
npm install "$RUNNER_TEMP"/onkernel-cua-cli-*.tgz
202-
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
209+
npm install "$TARBALL"
210+
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "$BUNDLE_WORKSPACE_DEPENDENCIES" == "true" ]]; then
203211
test -f node_modules/@onkernel/cua-cli/node_modules/@onkernel/cua-ai/dist/index.js
204212
test -f node_modules/@onkernel/cua-cli/node_modules/@onkernel/cua-agent/dist/index.js
205213
fi
@@ -208,6 +216,12 @@ jobs:
208216
echo "$OUTPUT" | grep -q "Usage:"
209217
echo "$OUTPUT" | grep -q "cua \[options\] \[prompt\.\.\.\]"
210218
219+
GLOBAL_DIR=$(mktemp -d)
220+
npm install --global --prefix "$GLOBAL_DIR" "$TARBALL"
221+
MODELS_OUTPUT=$("$GLOBAL_DIR/bin/cua" models -p openrouter)
222+
echo "$MODELS_OUTPUT"
223+
echo "$MODELS_OUTPUT" | grep -q "openrouter:moonshotai/kimi-k3"
224+
211225
- name: Publish production release
212226
if: github.event_name == 'push'
213227
run: npm publish --workspace @onkernel/cua-cli --access public

‎package-lock.json‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/cli/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 0.8.0 - 2026-08-03
4+
5+
- Queue messages submitted during an active turn for steering at the next agent
6+
step. Pressing `esc` interrupts the active work and immediately starts a new
7+
turn with any steering messages that were still queued.
8+
39
## 0.7.0 - 2026-08-03
410

511
- Add `openrouter:moonshotai/kimi-k3` model selection and

‎packages/cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@onkernel/cua-cli",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"description": "Kernel-cloud-browser computer-use TUI built on @onkernel/cua-agent and pi-tui",
55
"license": "MIT",
66
"type": "module",

‎packages/cli/src/tui/main.ts‎

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,13 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
182182

183183
let assistantBuffer: AssistantBuffer | undefined;
184184
let inflight = 0;
185+
let promptRunning = 0;
186+
let turnRevision = 0;
187+
let interruptState: { queued: string[]; cancelled: boolean } | undefined;
185188
let lastDisplayedError: string | undefined;
186189

190+
const isTurnRunning = (): boolean => inflight > 0 || promptRunning > 0;
191+
187192
// Ref of the live model, kept in sync by switchModel so the picker can mark
188193
// it with a ✓. Undefined when opts.modelRef is not a catalog ref.
189194
let currentModelRef: CuaModelRef | undefined = tryResolveModelRef(opts.modelRef);
@@ -229,7 +234,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
229234
* protection, so it refuses up front rather than failing on apply.
230235
*/
231236
const refuseWhileBusy = (command: string): boolean => {
232-
if (inflight === 0) return false;
237+
if (!isTurnRunning() && !interruptState) return false;
233238
messages.addError(`${command} is unavailable while a turn is running`);
234239
requestRender("selector_busy", false, { command });
235240
return true;
@@ -496,10 +501,20 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
496501
});
497502
};
498503

504+
const promptAgent = async (text: string): Promise<void> => {
505+
promptRunning += 1;
506+
try {
507+
await opts.harness.prompt(text);
508+
} finally {
509+
promptRunning -= 1;
510+
}
511+
};
512+
499513
const runPrompt = async (text: string): Promise<void> => {
500514
debug?.log("run_prompt_start", { length: text.length });
501515
try {
502516
const parsed = parseSlashCommand(text);
517+
if (parsed && refuseWhileBusy(`/${parsed.command}`)) return;
503518
if (parsed?.command === "model") {
504519
const argument = parsed.argument.trim();
505520
if (!argument) {
@@ -548,7 +563,21 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
548563
await opts.harness.skill(skill.name, skillRemainder);
549564
return;
550565
}
551-
await opts.harness.prompt(text);
566+
if (interruptState) {
567+
interruptState.queued.push(text);
568+
messages.addNotice(interruptState.cancelled ? "queued for after abort" : "queued for the interrupted turn");
569+
requestRender("prompt_queued_during_interrupt");
570+
return;
571+
}
572+
if (isTurnRunning()) {
573+
const revision = turnRevision;
574+
await opts.harness.steer(text);
575+
if (revision !== turnRevision) return;
576+
messages.addNotice("queued for the next available turn");
577+
requestRender("prompt_queued_for_steer");
578+
return;
579+
}
580+
await promptAgent(text);
552581
} catch (err) {
553582
messages.addError((err as Error).message);
554583
debug?.log("run_prompt_error", { message: (err as Error).message });
@@ -568,13 +597,74 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
568597
void runPrompt(trimmed);
569598
};
570599

600+
const startQueuedPrompt = (queued: string[], notice: string): void => {
601+
messages.addNotice(`${notice}; sending ${queued.length} queued message${queued.length === 1 ? "" : "s"}`);
602+
requestRender("queued_prompt_start", false, { queued: queued.length });
603+
void promptAgent(queued.join("\n\n")).catch((err: unknown) => {
604+
messages.addError((err as Error).message);
605+
debug?.log("queued_prompt_error", { message: (err as Error).message });
606+
requestRender("queued_prompt_error");
607+
});
608+
};
609+
610+
const interruptTurn = async (): Promise<void> => {
611+
if (interruptState) return;
612+
const state: { queued: string[]; cancelled: boolean } = { queued: [], cancelled: false };
613+
interruptState = state;
614+
turnRevision += 1;
615+
messages.addNotice("interrupting…");
616+
requestRender("input_interrupt_start", false, { key: "escape" });
617+
try {
618+
const { clearedSteer, clearedFollowUp } = await opts.harness.abort();
619+
if (state.cancelled) {
620+
const queued = state.queued;
621+
state.queued = [];
622+
if (queued.length > 0) {
623+
interruptState = undefined;
624+
startQueuedPrompt(queued, "abort complete");
625+
}
626+
return;
627+
}
628+
const queued = [
629+
...clearedSteer.map(userMessageText).filter((text): text is string => !!text),
630+
...clearedFollowUp.map(userMessageText).filter((text): text is string => !!text),
631+
...state.queued,
632+
];
633+
state.queued = [];
634+
if (queued.length === 0) {
635+
messages.addNotice("turn aborted");
636+
requestRender("input_abort_stream", false, { key: "escape" });
637+
return;
638+
}
639+
640+
interruptState = undefined;
641+
startQueuedPrompt(queued, "turn interrupted");
642+
} catch (err) {
643+
state.queued = [];
644+
messages.addError((err as Error).message);
645+
debug?.log("input_interrupt_error", { message: (err as Error).message });
646+
requestRender("input_interrupt_error");
647+
} finally {
648+
if (interruptState === state) interruptState = undefined;
649+
}
650+
};
651+
571652
const removeListener = tui.addInputListener((data) => {
572653
// Input listeners run before the focused component, so an open picker has
573654
// to own every key: otherwise ctrl+c / ctrl+d here would quit the app
574655
// instead of cancelling the picker.
575656
if (activeSelector) return undefined;
576657
if (matchesKey(data, "ctrl+c")) {
577-
if (inflight > 0) {
658+
if (interruptState) {
659+
interruptState.cancelled = true;
660+
interruptState.queued = [];
661+
messages.addNotice("aborted");
662+
debug?.log("input_cancel_interrupt_replay", { key: "ctrl+c" });
663+
requestRender("input_cancel_interrupt_replay", false, { key: "ctrl+c" });
664+
return { consume: true };
665+
}
666+
if (isTurnRunning()) {
667+
turnRevision += 1;
578668
void opts.harness.abort();
579669
messages.addNotice("aborted");
580670
debug?.log("input_abort_stream", { key: "ctrl+c" });
@@ -591,11 +681,9 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
591681
debug?.log("input_exit_request", { key: "ctrl+d" });
592682
return { consume: true };
593683
}
594-
if (matchesKey(data, "escape") && inflight > 0) {
595-
void opts.harness.abort();
596-
messages.addNotice("turn aborted");
597-
debug?.log("input_abort_stream", { key: "escape" });
598-
requestRender("input_abort_stream", false, { key: "escape" });
684+
if (matchesKey(data, "escape") && (isTurnRunning() || interruptState)) {
685+
void interruptTurn();
686+
debug?.log("input_interrupt_stream", { key: "escape" });
599687
return { consume: true };
600688
}
601689
return undefined;
@@ -616,7 +704,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
616704

617705
await waitForExit(
618706
() => exitRequested,
619-
() => inflight > 0,
707+
() => isTurnRunning() || !!interruptState,
620708
);
621709

622710
return 0;
@@ -644,6 +732,16 @@ function modelLabel(model: Model<any> | undefined): string {
644732
return model.id;
645733
}
646734

735+
function userMessageText(message: AgentMessage): string | undefined {
736+
if (message.role !== "user") return undefined;
737+
if (typeof message.content === "string") return message.content.trim() || undefined;
738+
const text = message.content
739+
.filter((content) => content.type === "text")
740+
.map((content) => content.text)
741+
.join("");
742+
return text.trim() || undefined;
743+
}
744+
647745
function lastErrorMessage(messages: AgentMessage[]): string | undefined {
648746
for (let i = messages.length - 1; i >= 0; i -= 1) {
649747
const m = messages[i];

‎packages/cli/test/fixtures/scripted-provider.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
export type ScriptedStep =
1414
| { type: "text"; text: string; chunkSize?: number; chunkMs?: number }
1515
| { type: "tool_call"; toolName: string; args: Record<string, unknown>; id?: string }
16-
| { type: "wait_abort" }
16+
| { type: "wait_abort"; settleMs?: number }
1717
| { type: "error"; message: string };
1818

1919
export interface ScriptedTurn {
@@ -154,6 +154,7 @@ function buildStream(model: Model<Api>, turn: ScriptedTurn | undefined, signal?:
154154
contentIndex += 1;
155155
} else if (step.type === "wait_abort") {
156156
await waitForAbort(signal);
157+
if (step.settleMs) await new Promise((resolve) => setTimeout(resolve, step.settleMs));
157158
aborted = true;
158159
break;
159160
} else if (step.type === "error") {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"modelRef": "openai:gpt-5.5",
3+
"turns": [
4+
{
5+
"steps": [
6+
{
7+
"type": "text",
8+
"text": "working..."
9+
},
10+
{
11+
"type": "wait_abort",
12+
"settleMs": 3000
13+
}
14+
]
15+
},
16+
{
17+
"steps": [
18+
{
19+
"type": "text",
20+
"text": "fixture response"
21+
}
22+
]
23+
}
24+
]
25+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"modelRef": "openai:gpt-5.5",
3+
"turns": [
4+
{
5+
"steps": [
6+
{
7+
"type": "text",
8+
"text": "working...",
9+
"chunkSize": 5,
10+
"chunkMs": 500
11+
}
12+
]
13+
},
14+
{
15+
"steps": [
16+
{
17+
"type": "text",
18+
"text": "queued response"
19+
}
20+
]
21+
}
22+
]
23+
}

0 commit comments

Comments
 (0)