From 84db5857506b1622ad60ffbca31695633710f391 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Mon, 21 Sep 2026 19:04:18 +0530 Subject: [PATCH 1/5] feat(ui): force-send queued messages without waiting for the in-flight turn Messages typed while the agent is streaming are held in a FIFO queue and drained one per turn, so a queued message previously had to wait for the whole in-flight turn (including every tool call) to finish before it was even considered. - Extract the queue panel into a new QueuedMessages component that renders a clickable "[send now]" action beside each message, with hover highlighting via mouse events. - Add forceSendQueued() in App: it jumps the target message to the front of the queue, then either drains directly (nothing in flight) or aborts the running turn. The abort makes the agent's turn-end handler drain the queue through the same path a normal turn end takes, so conversation history stays consistent. - Bind ctrl+s to force-send the next message in line; the queue panel header advertises the shortcut. - Move queue preview truncation into QueuedMessages (previewText/fit) and drop the now-unused truncateForQueue helper from App. --- CHANGELOG.md | 4 ++ src/ui/App.tsx | 90 +++++++++++++++++++--------- src/ui/components/QueuedMessages.tsx | 86 ++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 src/ui/components/QueuedMessages.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a41969..68ed850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Force-send a queued message.** Messages typed while the agent is streaming are held in a FIFO queue and drained one per turn, so a queued message previously had to wait for the whole in-flight turn (including every tool call) to finish. The queue panel now shows a clickable `[send now]` action beside each message, and `ctrl+s` force-sends the next one in line. Either path jumps that message to the front of the queue and aborts the in-flight turn so it starts immediately; when nothing is in flight the queue drains directly. + ## [6.8.6] - 2026-09-15 ### Fixed diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 959653e..07d3f26 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -106,6 +106,7 @@ import { TranscriptViewport, } from "./components/TranscriptViewport.js"; import { ScrollToBottomChip } from "./components/ScrollToBottomChip.js"; +import { QueuedMessages } from "./components/QueuedMessages.js"; import { Toast } from "./components/Toast.js"; import { copyToClipboard } from "../utils/clipboard.js"; import { @@ -835,6 +836,50 @@ export function App({ return agentRef.current; }, [createAgent]); + // Force-send: jump a queued message to the front of the queue, then skip + // the wait for the in-flight turn. Aborting makes the agent's `finally` + // emit `turn-end`, whose handler drains the queue and starts the next + // turn — the same path a normal turn end takes, so the conversation + // history stays consistent. + const forceSendQueued = useCallback( + (index = 0) => { + const queue = queueRef.current; + if (queue.length === 0) { + pushRow({ kind: "info", text: "No queued messages to send." }); + return; + } + const clamped = Math.min(Math.max(index, 0), queue.length - 1); + const target = queue[clamped]!; + queueRef.current = [ + target, + ...queue.slice(0, clamped), + ...queue.slice(clamped + 1), + ]; + setQueuedMessages(queueRef.current); + const agent = agentRef.current; + if (!busy || !agent) { + // Nothing in flight — drain the queue directly. + const next = drainQueue(); + if (next === null) return; + pushRow({ + kind: "user", + text: next.text, + attachments: next.attachments.map(attachmentSummary), + }); + setBusy(true); + setBusyLabel("Thinking"); + void getAgent().runTurn(next.text, next.attachments); + return; + } + pushRow({ + kind: "info", + text: `Force-sending queued message (${queueRef.current.length} in queue)…`, + }); + agent.abort(); + }, + [busy, drainQueue, getAgent, pushRow], + ); + const handleResume = useCallback( (session: SessionData) => { setResumableSessions(null); @@ -1687,6 +1732,17 @@ export function App({ ); // The terminal adapter replaces the retained screen rows in place. } + // Ctrl+S force-sends the next queued message without waiting for the + // in-flight turn (the queue panel advertises this next to each message). + if ( + key.ctrl && + input === "s" && + inputActive && + queueRef.current.length > 0 + ) { + forceSendQueued(0); + return; + } }); const handleLogin = useCallback( @@ -2093,29 +2149,11 @@ export function App({ {queuedMessages.length > 0 && ( - - - Queue ({queuedMessages.length}) - - {queuedMessages.slice(0, 5).map((msg, i) => ( - - {i + 1}.{" "} - {truncateForQueue(msg.text || "Attached files").replace( - /\n/g, - "↵", - )} - {msg.attachments.length > 0 - ? ` · 📎 ${msg.attachments.length}` - : ""} - - ))} - {queuedMessages.length > 5 && ( - - {" "} - … {queuedMessages.length - 5} more - - )} - + )} 0 + ? `${truncated} · 📎 ${message.attachments.length}` + : truncated; +} + +export interface QueuedMessagesProps { + messages: SubmittedPrompt[]; + width: number; + /** Force-send the message at `index` (0 = next in line) without waiting. */ + onForceSend: (index: number) => void; +} + +/** Messages typed while the agent is streaming, each with an action to + * force-send it ahead of the in-flight turn. */ +export function QueuedMessages({ + messages, + width, + onForceSend, +}: QueuedMessagesProps) { + const [hovered, setHovered] = useState(null); + const header = fit( + `Queue (${messages.length}) · ${width < 56 ? "ctrl+s" : "ctrl+s sends the next one now"}`, + width, + ); + const textWidth = Math.max(8, width - 6 - ACTION_TAG.length); + + return ( + + + {header} + + {messages.slice(0, MAX_VISIBLE).map((message, index) => { + const isHovered = hovered === index; + return ( + + + {`${index + 1}. ${fit(previewText(message), textWidth)}`} + + { + event.stopPropagation?.(); + onForceSend(index); + }} + onMouseMove={(event) => { + event.stopPropagation?.(); + if (hovered !== index) setHovered(index); + }} + > + {` ${ACTION_TAG}`} + + + ); + })} + {messages.length > MAX_VISIBLE && ( + + {` … ${messages.length - MAX_VISIBLE} more`} + + )} + + ); +} From 9736cfa179c94dddbe73a0f9a7e32de8e5a424af Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Mon, 21 Sep 2026 19:05:02 +0530 Subject: [PATCH 2/5] fix(core): make session persistence crash-safe and incremental Session persistence previously ran only in runTurn's finally block, so killing OrbCode or closing the terminal mid-turn (a long multi-step turn can stream for many minutes) lost the entire in-flight turn: the user prompt, every assistant response, and every tool call/result accumulated across all its steps were never written to disk, and resuming showed the state from before that turn. - Persist right after the user message is pushed and after every model step, so a hard kill loses at most the single in-flight tool call. - saveSession now writes to a pid-suffixed temp file and renames it into place, so a crash mid-write can no longer truncate or corrupt the last good session file. - serializeSession degrades gracefully when a message holds a value JSON cannot represent (BigInt, circular reference) instead of losing the whole session to a stringify throw. - Surface save failures as transcript errors instead of silently swallowing them. - Guard persist() against stale writes: track the session file's last-known mtime (baselined from the resumed file at startup) and refuse to write when another process has written newer turns, warning instead of clobbering. --- CHANGELOG.md | 4 ++++ src/core/agent.ts | 57 ++++++++++++++++++++++++++++++++++++++++++-- src/core/sessions.ts | 27 ++++++++++++++++++++- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ed850..613c30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Force-send a queued message.** Messages typed while the agent is streaming are held in a FIFO queue and drained one per turn, so a queued message previously had to wait for the whole in-flight turn (including every tool call) to finish. The queue panel now shows a clickable `[send now]` action beside each message, and `ctrl+s` force-sends the next one in line. Either path jumps that message to the front of the queue and aborts the in-flight turn so it starts immediately; when nothing is in flight the queue drains directly. +### Fixed + +- **Session data no longer vanishes when quitting mid-turn.** Session persistence previously ran only in `runTurn`'s `finally` — when OrbCode was killed or the terminal closed while a turn was still running (a long multi-step turn can stream for many minutes), the entire in-flight turn's messages (the user prompt, every assistant response, and every tool call/result accumulated across all its steps) were never written to disk, so resuming showed the state from before that turn. The agent now persists right after the user message is pushed and after every model step, so a hard kill loses at most the single in-flight tool call. Session writes are also atomic now (write to a pid-suffixed temp file, then rename), so a crash mid-write can no longer truncate or corrupt the last good session file; a non-serializable value in history degrades to a safe replacer instead of throwing away the whole session; and save failures are surfaced as transcript errors instead of being silently swallowed. + ## [6.8.6] - 2026-09-15 ### Fixed diff --git a/src/core/agent.ts b/src/core/agent.ts index 94c7eb9..e4529a5 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1,5 +1,6 @@ import { execSync } from "node:child_process" import { randomUUID } from "node:crypto" +import * as fs from "node:fs" import type OpenAI from "openai" import { @@ -68,6 +69,10 @@ const PARALLEL_READ_ONLY_TOOLS = new Set([ /** How many times to automatically re-establish a model request that fails * before producing any output (transient/connection errors). */ const MAX_STREAM_RETRIES = 3 +/** Slack (ms) when comparing the session file's mtime against this process's + * last write, so our own just-written file is never mistaken for a foreign + * newer write. */ +const STALE_WRITE_TOLERANCE_MS = 2000 /** Transient failures worth auto-retrying: any transport/connection error (no * usable HTTP status — socket reset, DNS, timeout, TLS drop) plus 5xx/408/429 @@ -364,6 +369,12 @@ export class Agent { private title = "" private createdAt = new Date().toISOString() private lastGitHead?: string + /** + * mtime (ms) of this instance's last session write, or the resumed file's + * mtime at startup. A newer on-disk mtime means another process wrote + * newer turns; persist() then refuses to roll the file back. + */ + private lastSessionWriteMs = 0 private readonly hooks: HookRunner /** MCP server manager (may be undefined when MCP is disabled). */ private mcp?: McpManager @@ -415,6 +426,12 @@ export class Agent { this.title = options.resume.title this.createdAt = options.resume.createdAt this.firstMessageSent = this.messages.length > 0 + // Baseline for the stale-write guard: the resumed file's mtime. + try { + this.lastSessionWriteMs = fs.statSync(getSessionFilePath(this.taskId)).mtimeMs + } catch { + // file missing — nothing to protect yet + } } this.sessionApproveEdits = options.autoApproveEdits this.mcp = options.mcp @@ -579,7 +596,28 @@ export class Agent { /** Write the current conversation to the sessions directory. */ private persist(): void { if (this.messages.length === 0) return + const filePath = getSessionFilePath(this.taskId) try { + // Stale-write guard: if another process (e.g. a zombie left by an + // unfinished quit, or a second OrbCode instance) wrote newer turns to + // this session file, writing our older in-memory history would roll + // the session back. Skip and warn instead of clobbering. + if (this.lastSessionWriteMs > 0) { + try { + const onDiskMs = fs.statSync(filePath).mtimeMs + if (onDiskMs > this.lastSessionWriteMs + STALE_WRITE_TOLERANCE_MS) { + this.options.callbacks.onEvent({ + type: "system", + message: + "Session file was updated by another OrbCode process; skipping save to protect the newer turns.", + isError: false, + }) + return + } + } catch { + // no file on disk yet — nothing to protect + } + } saveSession({ id: this.taskId, cwd: this.options.cwd, @@ -594,8 +632,16 @@ export class Agent { messages: this.messages, transcript: this.transcript, }) - } catch { - // persistence is best-effort; never break the session over it + this.lastSessionWriteMs = Date.now() + } catch (error) { + // Persistence is best-effort and must never break the session, but a + // silent catch here is how whole turns vanished without a trace. + // Surface the failure so the user knows the save didn't happen. + this.options.callbacks.onEvent({ + type: "system", + message: `Failed to save session: ${(error as Error).message}`, + isError: true, + }) } } @@ -738,6 +784,9 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` ] : userContent, }) + // Persist immediately so a hard kill before the first model response + // still leaves the user's prompt on disk. + this.persist() // --- Auto-fetch Figma URLs from the user's message --- // Instead of relying on the model to call figma_fetch, we scan the @@ -1207,6 +1256,10 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` for (let index = batchEnd; index < toolCalls.length; index++) { await runToolCall(toolCalls[index]) } + // Persist after every model step: a hard kill mid-turn (crash, closed + // terminal, kill signal) loses at most the in-flight tool call instead + // of the entire turn's accumulated history. + this.persist() return completed } diff --git a/src/core/sessions.ts b/src/core/sessions.ts index 90f7649..54a6b87 100644 --- a/src/core/sessions.ts +++ b/src/core/sessions.ts @@ -55,10 +55,35 @@ export function getSessionFilePath(id: string): string { return path.join(getSessionsDir(), `${id}.json`) } +/** Serialize a session; degrade gracefully if a message holds a value JSON + * cannot represent (BigInt, circular reference) instead of losing the + * whole session to a stringify throw. */ +function serializeSession(data: SessionData): string { + try { + return JSON.stringify(data) + } catch { + const seen = new WeakSet() + return JSON.stringify(data, (_key, value) => { + if (typeof value === "bigint") return value.toString() + if (value && typeof value === "object") { + if (seen.has(value)) return "[Circular]" + seen.add(value) + } + return value + }) + } +} + export function saveSession(data: SessionData): void { const dir = getSessionsDir() fs.mkdirSync(dir, { recursive: true }) - fs.writeFileSync(getSessionFilePath(data.id), JSON.stringify(data), { mode: 0o600 }) + const target = getSessionFilePath(data.id) + // Write-then-rename so a crash mid-write can never truncate the last + // good session file. The pid suffix keeps concurrent processes from + // colliding on the temp file. + const tmp = `${target}.${process.pid}.tmp` + fs.writeFileSync(tmp, serializeSession(data), { mode: 0o600 }) + fs.renameSync(tmp, target) } export function loadSessionById(id: string): SessionData | undefined { From 31962cf4408fbf0c7d63d9af71dd80bd41950770 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Mon, 21 Sep 2026 19:05:45 +0530 Subject: [PATCH 3/5] fix(ui): make Ctrl+C interrupt the running turn and exit when idle Ctrl+C previously did nothing (no handler existed), so quitting left zombie processes alive with the old conversation in memory; their next save would overwrite the session file with stale history, erasing turns written by a resumed session. Together with the stale-write guard in persist() from the previous commit, this removes both the source of stale writers and the damage they could do. - While a turn is running, Ctrl+C aborts it like Esc, unless a prompt (approval, followup, hook trust, MCP approval) is pending. - When idle, Ctrl+C exits through the same double-press confirmation as Ctrl+D, so an accidental press cannot discard the session. - Update the shortcut hints in the header and the /help panel to "ctrl+d/c exit". --- CHANGELOG.md | 1 + src/ui/App.tsx | 27 +++++++++++++++++++++++---- src/ui/components/Header.tsx | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 613c30a..f3fc61f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Session data no longer vanishes when quitting mid-turn.** Session persistence previously ran only in `runTurn`'s `finally` — when OrbCode was killed or the terminal closed while a turn was still running (a long multi-step turn can stream for many minutes), the entire in-flight turn's messages (the user prompt, every assistant response, and every tool call/result accumulated across all its steps) were never written to disk, so resuming showed the state from before that turn. The agent now persists right after the user message is pushed and after every model step, so a hard kill loses at most the single in-flight tool call. Session writes are also atomic now (write to a pid-suffixed temp file, then rename), so a crash mid-write can no longer truncate or corrupt the last good session file; a non-serializable value in history degrades to a safe replacer instead of throwing away the whole session; and save failures are surfaced as transcript errors instead of being silently swallowed. +- **A stale OrbCode process can no longer roll a session back.** Quitting with Ctrl+C previously did nothing (no handler existed), leaving zombie processes alive with the old conversation in memory; their next save would overwrite the session file with stale history, erasing turns written by a resumed session. Ctrl+C now interrupts the running turn (like Esc) and, when idle, exits through the same double-press confirmation as Ctrl+D. Additionally, `persist()` tracks the session file's last-known mtime and refuses to write when another process has written newer turns, warning instead of clobbering. ## [6.8.6] - 2026-09-15 diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 07d3f26..defc77a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1674,9 +1674,28 @@ export function App({ scrollTranscriptBy(-Math.max(1, contentHeight - 2)); return; } - // Require two presses so an accidental Ctrl+D cannot discard the session. - // The ref makes rapid repeated presses reliable before React re-renders. - if (key.ctrl && input === "d") { + // Ctrl+C interrupts the running turn (like Esc); when idle it exits via + // the same double-press confirmation as Ctrl+D. Previously Ctrl+C did + // nothing, leaving zombie processes whose next save could overwrite + // newer session data written by a resumed process. + if (key.ctrl && input === "c") { + if (busy) { + if ( + !pendingApproval && + !pendingFollowup && + !pendingHookTrust && + !pendingMcpApproval + ) { + agentRef.current?.abort(); + } + return; + } + // Idle: fall through to the shared double-press exit below. + } + // Require two presses so an accidental Ctrl+D/Ctrl+C cannot discard the + // session. The ref makes rapid repeated presses reliable before React + // re-renders. + if (key.ctrl && (input === "d" || input === "c")) { if (exitConfirmationRef.current) { exitConfirmationRef.current = false; setExitConfirmationActive(false); @@ -2410,7 +2429,7 @@ function estimateRowLines(row: Row, width: number): number { wrappedAt("/help all commands", secondCellWidth), ); const shortcuts = wrappedAt( - "shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d exit", + "shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d/c exit", panelWidth, ); // Action/footer top margins plus Header's bottom margin add three rows. diff --git a/src/ui/components/Header.tsx b/src/ui/components/Header.tsx index fc9c36d..3bd8967 100644 --- a/src/ui/components/Header.tsx +++ b/src/ui/components/Header.tsx @@ -84,7 +84,7 @@ export function Header({ cwd, modelName }: { cwd: string; modelName: string }) { - shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d exit + shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d/c exit From 50fe88b108d20fc55b2968b8d81dd0e3078ad18f Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Mon, 21 Sep 2026 19:06:16 +0530 Subject: [PATCH 4/5] feat(api): scope dynamic model fetching to an organization fetchDynamicModels now sends X-KiloCode-OrganizationId and X-Org-Id headers so the gateway can return the models available to the user's organization instead of the global registry. The organization ID comes from the new optional argument, falling back to settings.organizationId when omitted. --- CHANGELOG.md | 1 + src/api/models.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3fc61f..46374de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Force-send a queued message.** Messages typed while the agent is streaming are held in a FIFO queue and drained one per turn, so a queued message previously had to wait for the whole in-flight turn (including every tool call) to finish. The queue panel now shows a clickable `[send now]` action beside each message, and `ctrl+s` force-sends the next one in line. Either path jumps that message to the front of the queue and aborts the in-flight turn so it starts immediately; when nothing is in flight the queue drains directly. +- **Organization-scoped dynamic model catalog.** `fetchDynamicModels` now sends `X-KiloCode-OrganizationId` and `X-Org-Id` headers — from the new optional `organizationId` argument, falling back to `settings.organizationId` when omitted — so the gateway returns the models available to the user's organization instead of the global registry. ### Fixed diff --git a/src/api/models.ts b/src/api/models.ts index 87b7837..ab26f16 100644 --- a/src/api/models.ts +++ b/src/api/models.ts @@ -439,6 +439,7 @@ export function getGatewayModelId(model: AxonModel): string { */ export async function fetchDynamicModels( token?: string, + organizationId?: string, ): Promise> { try { const { getUrlFromToken } = await import("../auth/auth.js"); @@ -453,6 +454,17 @@ export async function fetchDynamicModels( headers.Authorization = `Bearer ${token}`; } + if (!organizationId) { + try { + const { loadSettings } = await import("../config/settings.js"); + organizationId = loadSettings().organizationId; + } catch {} + } + if (organizationId) { + headers["X-KiloCode-OrganizationId"] = organizationId; + headers["X-Org-Id"] = organizationId; + } + const res = await fetch(targetUrl, { headers, signal: AbortSignal.timeout(4000), From 5857c8ac07d0d81bb52fe907dd57a71eb075c2c9 Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Mon, 21 Sep 2026 19:06:42 +0530 Subject: [PATCH 5/5] release: v6.8.7 Force-send queued messages (ctrl+s / [send now]), crash-safe incremental session persistence with a stale-write guard, Ctrl+C interrupt/exit, and organization-scoped dynamic model catalog. --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46374de..2d26c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.8.7] - 2026-09-21 + ### Added - **Force-send a queued message.** Messages typed while the agent is streaming are held in a FIFO queue and drained one per turn, so a queued message previously had to wait for the whole in-flight turn (including every tool call) to finish. The queue panel now shows a clickable `[send now]` action beside each message, and `ctrl+s` force-sends the next one in line. Either path jumps that message to the front of the queue and aborts the in-flight turn so it starts immediately; when nothing is in flight the queue drains directly. diff --git a/package-lock.json b/package-lock.json index e384de3..9bf6b6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@matterailab/orbcode", - "version": "6.8.6", + "version": "6.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@matterailab/orbcode", - "version": "6.8.6", + "version": "6.8.7", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.85", diff --git a/package.json b/package.json index 39434e2..8474ffa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.8.6", + "version": "6.8.7", "description": "OrbCode CLI — agentic coding in your terminal, by MatterAI", "type": "module", "bin": {