Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/LOCAL_SHELL_SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,43 @@ tools. Online account-agent and ATS chats remain separate surfaces.
review again to retry. Later chat prompts do not automatically include it.
Ordinary chat history still honors `AETHER_NO_HISTORY=1`.

## Manage pending console entries

TTY type-ahead and `/queue <task>` use a strict memory-only FIFO with stable
local IDs such as `q3`. `/queue` or `/queue list` displays the running entry
as immutable, then every pending entry's ID, type, approval state and text.
List text is terminal-escaped for safe inspection. Shell commands remain user
shell actions; listing, editing or queueing them never turns them into chat.

- `/queue edit <id> <replacement>` edits a pending entry in place. Shell edits
require `!command`; a chat cannot become a shell/slash action. Active entries
and shell resets cannot be edited. Invalid edits leave the entry unchanged
- `/queue remove <id>` discards only that pending entry; `/queue clear` discards
all pending entries. Both are local and neither interrupts the active turn
- `/queue edit <id> <replacement body>` on a shell-share keeps the original
capture binding but revokes its old Send approval. The new exact preview is
shown. Use `/queue send <id>` after reviewing it; an empty selection cannot
be sent. FIFO pauses at an unapproved head rather than skipping it
- After removing a paused head, `/queue run` explicitly resumes ready pending
entries. List/edit/remove/clear never launch a model or process by themselves
- The bound is 32 pending entries and 64 KiB of UTF-8 serialized input, including
retained capture provenance. Enqueue and edits enforce both limits; oversized
edits preserve the previous entry. New rejected submissions are not queued
- Cancellation, turn/slash failure, auth-new, shell state loss, checkout/session
changes and exit discard pending work and display the discarded IDs/types and
remaining count. Discarded IDs never silently restart after auth repair or
shell reset. A separately saved failed active chat still follows explicit
authentication-repair controls; it does not contain the discarded queue
- Terminal handoff is refused while entries remain pending; run or clear them
first so an old queued command cannot inherit a reconciled terminal checkout
- Queue management and queued shell text are excluded from ordinary history.
Typing ahead is preserved while the active operation finishes or fails

Pipe/non-TTY mode remains sequential: it finishes one input line before reading
the next. It has no editable pending queue; queue-management commands print
local guidance and never invoke a model. Use TTY mode for responsive management
while streaming. Explicit shell-preview commands work in both modes.

## Interactive Linux terminal

Use `/terminal <command>` in the local coding console when a program needs
Expand Down
6 changes: 3 additions & 3 deletions docs/generated/commands.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- GENERATED FILE: run `npm run docs:generate`; do not edit by hand. -->
<!-- manifest-digest: sha256:473c81160b9dc898982fd36f0375b098ddb176a1606f64e96523016b414f4cbb -->
<!-- manifest-digest: sha256:60815a100b6fcf400cb39020c18ccf3acf1a2cc411f4faca8fd59e307555eba5 -->
# Generated command reference

This reference is generated from the validated, versioned command manifest. Availability is evaluated at runtime; a listed command may still require authentication, a hosted capability, or local tooling.
Expand Down Expand Up @@ -519,9 +519,9 @@ show the current local terminal ID and state

Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.terminal-status`

#### `/queue <task>`
#### `/queue [<task> | list | edit <id> <msg> | remove <id> | clear | send <id> | run]`

queue a task \(runs when current finishes\)
inspect and manage bounded, typed pending console entries

Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.queue`

Expand Down
44 changes: 44 additions & 0 deletions docs/issue-284-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Issue 284: bounded pending console management

## Contract

TTY pending entries have stable local IDs and retained types (chat, user shell,
shell reset, or reviewed shell-share). The active entry is immutable. List,
edit, remove and clear are local controls handled before history and ordinary
submission; malformed management syntax fails closed. Pending entries are
bounded at 32 entries and 64 KiB of UTF-8 serialized input, including retained
attachment provenance. Edits enforce the same bound and preserve position.

A reviewed shell-share keeps its capture identity. Editing it revokes prior
Send approval and pauses strict FIFO at that entry until explicit `/queue send`.
Removal never dispatches later work; `/queue run` is the explicit ready-FIFO
resume control. Empty attachment selections cannot be approved. Failure,
cancellation, checkout/session changes, auth-new, and exit display discarded
IDs/types and pending count; discarded entries never resume after recovery.
Terminal handoff requires an empty pending queue to prevent inherited checkout
changes. Existing model-switch controls remain reachable and preserve a saved
draft without overtaking earlier queued entries.

Pipe mode is intentionally sequential. Queue-management input prints local
TTY guidance and makes no model requests; it does not imply responsive pipe
queue editing while a prior turn is running.

## Regression coverage

Focused suite: 57 tests pass across console input/shell, attachment/line editor,
pending queue, and model continuation.

- Real synthetic TTY streaming with local list/edit/remove/clear operations
- Model and process spies prove removed/cleared/cancelled entries do not execute
- Active immutability, invalid reclassification, count/UTF-8 edit bounds
- Strict mixed FIFO and paused edited attachment requiring new explicit Send
- 401, ordinary failure, active cancellation, shell loss and checkout reset
- Draft preservation, cancelled model-switch restore, and exit while paused
- Malformed pasted controls cannot become a model prompt or ordinary history
- Non-TTY local guidance; all #281 privacy/binding/line-editor regressions rerun

All fixtures are synthetic. No paid model call, real credential, live service
qualification, branch-protection change, or CI-gate weakening is involved.
Final exact-tree aggregate/CI evidence belongs with the PR. Baseline smoke DNS,
release packet, billing, runner availability and artifact-quota failures must
be reported separately rather than relabeled as feature acceptance passes.
98 changes: 69 additions & 29 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { isLocalModelId, localModelId, ollamaTagFromId, resolveHostedModel, reso
import type { Brain } from "../core/brain.js";
import type { RunOptions, ToolResult } from "../core/tool_executor.js";
import { ToolExecutor } from "../core/tool_executor.js";
import { ConsoleQueue, parseQueueControl, queueKind, type QueuedInput, type QueueEntry } from "./console_queue.js";
import { ConsoleShell, classifyConsoleInput, type ConsoleInput } from "./console_input.js";
import { HostRenderer } from "../ui/host_render.js";
import type { TaskCommand } from "../core/brain.js";
Expand Down Expand Up @@ -1312,7 +1313,8 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
let pasting = false;
let pasteAcc = "";
let carry = ""; // partial escape sequence held across chunk boundaries
const queue: ConsoleInput[] = [];
const queue = new ConsoleQueue();
const discardQueue = (reason: string): void => { process.stdout.write(queue.clear(reason) + "\n"); };
let steering: string | null = null;
const btwNotes: string[] = [];
let turnAbort: AbortController | null = null; // live while a local/cloud turn runs
Expand Down Expand Up @@ -1348,7 +1350,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
process.stdout.removeListener("resize", onResize);
};
const finish = (code: number): void => {
turnAbort?.abort(); slashAbort?.abort(); queue.length = 0;
turnAbort?.abort(); slashAbort?.abort(); discardQueue("closed");
cleanup();
process.stdout.write("\n");
resolve(code);
Expand All @@ -1364,7 +1366,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
turnAbort = new AbortController();
try {
const result = await consoleShell.run(input, turnAbort.signal);
if (result !== "completed") queue.length = 0;

return result;
} finally { turnAbort = null; }
}
Expand Down Expand Up @@ -1451,7 +1453,6 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
continuation.recordTurn(text, toolResults, afterChanges.filter(change => !beforeChanges.includes(change)), outcome.state, !sharedShellResult && !authContinuation);
if (ctx.flags.json) process.stdout.write(turnOutcomeJson(outcome) + "\n");
if (outcome.state === "cancelled") {
queue.length = 0;
if (!ctx.flags.json) process.stdout.write("\n" + theme.dim("✗ turn aborted") + "\n");
return "aborted";
}
Expand All @@ -1460,8 +1461,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
const afterChanges = observedWorkspaceChanges(ctx.flags.cwd);
continuation.recordTurn(text, toolResults, afterChanges.filter(change => !beforeChanges.includes(change)), "failed", !sharedShellResult && !authContinuation);
if (isAbortError(err)) {
// User said stop: drop the queued follow-ups too.
queue.length = 0;
// The drain owner reports/discards pending follow-ups.
const outcome = turnOutcomeForError(err);
if (ctx.flags.json && outcome) process.stdout.write(turnOutcomeJson(outcome) + "\n");
else process.stdout.write("\n" + theme.dim("✗ turn aborted") + "\n");
Expand Down Expand Up @@ -1502,17 +1502,35 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
}
};

const runAndDrain = async (input: ConsoleInput, authContinuation = false): Promise<void> => {
const runAndDrain = async (input: ConsoleInput, authContinuation = false, queued?: QueueEntry): Promise<void> => {
try {
getRegistry().startAgentTimer();
let result = await runQueuedTurn(input, authContinuation);
while (result === "completed" && queue.length > 0) {
const next = queue.shift()!;
const preview = next.kind === "chat" ? next.text : next.kind === "shell" ? "!" + next.command : next.kind === "attachment" ? "reviewed shell attachment" : "/shell-reset";
process.stdout.write(`\n→ Queued: "${previewLine(preview)}"\n`);
result = await runQueuedTurn(next);
if (input.kind === "share" || input.kind === "empty" || input.kind === "error") return;
if (!queued) queue.start(input);
let current = input as QueuedInput;
let continuingAuth = authContinuation;
while (true) {
const sessionId = consoleShell.session.id;
const result = await runQueuedTurn(current, continuingAuth);
continuingAuth = false;
queue.finish();
if (result !== "completed") { discardQueue(result === "aborted" ? "cancelled" : "failed"); break; }
if (consoleShell.session.id !== sessionId || consoleShell.session.state !== "ready") {
discardQueue("shell state changed"); break;
}
const next = queue.take();
if (!next) {
if (queue.waitingForApproval) process.stdout.write(`Queue paused: ${queue.length} pending; first shell-share requires explicit /queue send <id>. Nothing skipped.\n`);
break;
}
process.stdout.write(`\n→ Queued ${next.id} (${queueKind(next.input)})\n`);
current = next.input;
}
} catch (error) {
discardQueue("operation failed");
printError(error, ctx.cfg.baseUrl);
} finally {
queue.finish();
busy = false;
getRegistry().startUserTimer();
}
Expand All @@ -1535,6 +1553,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
return;
case "abort-turn":
active!.abort();
discardQueue("cancelled by user");
ctrlCArmedAt = now;
return;
case "arm-quit":
Expand All @@ -1556,13 +1575,27 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P

const onSubmit = async (): Promise<void> => {
const raw = buf.value;
if (!busy && queue.length && (raw.trim() === "/exit" || raw.trim() === "/quit")) {
buf.clear(); finish(0); return;
}
const control = parseQueueControl(raw);
if (control) {
buf.clear(); // Queue management (including edit bodies) is local, never history.
const result = queue.control(control);
process.stdout.write("\n" + result.message + "\n");
if (!busy && !continuation.pending && result.drain) {
const next = queue.take();
if (next) { busy = true; await runAndDrain(next.input, false, next); }
}
repaint(); return;
}
if (continuation.pending && !raw.trim().startsWith("/switch")) {
process.stdout.write("\nChoose /switch continue, fresh, cancel, brief, or edit first. Draft preserved.\n");
repaint();
return;
}
if (ConsoleShell.isTerminalCommand(raw)) {
if (busy) { process.stdout.write("\nWait for the current operation before terminal handoff.\n"); return; }
if (busy || queue.length) { process.stdout.write("\nFinish the active operation and run or clear pending entries before terminal handoff.\n"); return; }
buf.clear(); // Explicit terminal commands never enter model/history.
busy = true; terminalOwnsInput = true;
try { await consoleShell.terminalCommand(raw); }
Expand All @@ -1585,20 +1618,21 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
let t = input.kind === "chat" || input.kind === "attachment" ? input.text : "";
if (t && heldDraft === t && !t.startsWith("/auth")) heldDraft = null;
if (input.kind !== "chat" && input.kind !== "empty") {
buf.clear(); // shell commands never enter chat history or prompt context
if (busy) {
queue.push(input);
process.stdout.write(`\n⏳ Local shell queued (${queue.length}).\n`);
if (busy || queue.length) {
const added = queue.enqueue(input);
process.stdout.write(typeof added === "string" ? added + "\n" : `\n⏳ Local shell queued ${added.id} (${queue.length}; ${queueKind(added.input)}).\n`);
if (typeof added !== "string") buf.clear();
return;
}
buf.clear(); // shell commands never enter chat history or prompt context
process.stdout.write("\n");
busy = true;
await runAndDrain(input);
renderHudLine(); repaint();
return;
}
// ── mid-turn Enter: bypass commands + type-ahead queueing ──
if (busy) {
if (busy || (queue.length && !t.startsWith("/switch"))) {
if (t.startsWith("/steer ")) {
steering = t.slice(7).trim() || steering;
remember(buf.value);
Expand All @@ -1617,11 +1651,11 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
return;
}
if (t.startsWith("/queue ")) t = t.slice(7).trim();
remember(buf.value);
buf.commit(buf.value);
if (!t || t.startsWith("/")) return; // other slashes wait for the turn
queue.push({ kind: "chat", text: t });
process.stdout.write(`\n⏳ Queued (${queue.length}): "${previewLine(t)}"\n`);
if (!t || t.startsWith("/")) { buf.clear(); return; } // other slashes wait for the turn
const added = queue.enqueue({ kind: "chat", text: t });
if (typeof added === "string") { process.stdout.write(added + "\n"); return; }
remember(buf.value); buf.commit(buf.value);
process.stdout.write(`\n⏳ Queued ${added.id} (${queue.length}; chat): "${previewLine(t)}"\n`);
return;
}

Expand Down Expand Up @@ -1725,7 +1759,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
} else process.stdout.write("No safely rejected task is ready. Use /auth status for details.\n");
} else if (sub === "new") {
process.stdout.write(authRepair.startNewConversation());
queue.length = 0; steering = null; btwNotes.length = 0;
discardQueue("new conversation"); steering = null; btwNotes.length = 0;
} else if (sub === "draft") {
if (buf.value) process.stdout.write("Current draft is still in the input line; clear it before restoring the earlier draft.\n");
else if (!heldDraft) process.stdout.write("No earlier type-ahead draft is saved.\n");
Expand All @@ -1746,11 +1780,11 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
try {
const res = await handleSlash(ctx, t, process.stdout, slashAbort.signal);
if (res.exit) {
cleanup();
resolve(0);
finish(0);
return;
}
if (res.restart) {
discardQueue("session restarted");
applyRestart(ctx.flags, res.restart);
process.stdout.write(theme.dim("session restarted — context cleared.\n"));
}
Expand All @@ -1767,9 +1801,10 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
}
} catch (err) {
if (isAbortError(err)) {
queue.length = 0;
discardQueue("cancelled");
process.stdout.write(theme.dim("✗ canceled") + "\n");
} else {
discardQueue("command failed");
printError(err, ctx.cfg.baseUrl);
}
} finally {
Expand All @@ -1778,7 +1813,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
busy = false;
slashAbort = null;
}
if (queue.length && !continuation.pending) { busy = true; await runAndDrain(queue.shift()!); }
if (queue.length && !continuation.pending) { const next = queue.take(); if (next) { busy = true; await runAndDrain(next.input, false, next); } }
renderHudLine();
repaint();
return;
Expand Down Expand Up @@ -2038,6 +2073,11 @@ export async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = {
try {
if (p) process.stdout.write(p + consoleShell.prompt());
for await (const line of rl) {
if (parseQueueControl(line)) {
process.stdout.write("Line mode runs inputs sequentially; there is no editable pending queue. Use a TTY for /queue list/edit/remove/clear/send. Nothing sent.\n");
if (p) process.stdout.write(p + consoleShell.prompt());
continue;
}
if (continuation.pending && !line.trim().startsWith("/switch")) {
process.stdout.write("Choose /switch continue, fresh, cancel, brief, or edit first; input was not sent.\n");
if (p) process.stdout.write(p + consoleShell.prompt());
Expand Down
Loading