Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ 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.
- **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

- **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

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions src/api/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ export function getGatewayModelId(model: AxonModel): string {
*/
export async function fetchDynamicModels(
token?: string,
organizationId?: string,
): Promise<Record<string, AxonModel>> {
try {
const { getUrlFromToken } = await import("../auth/auth.js");
Expand All @@ -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),
Expand Down
57 changes: 55 additions & 2 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
})
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
27 changes: 26 additions & 1 deletion src/core/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>()
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 {
Expand Down
117 changes: 84 additions & 33 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1629,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);
Expand Down Expand Up @@ -1687,6 +1751,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(
Expand Down Expand Up @@ -2093,29 +2168,11 @@ export function App({
</TranscriptViewport>
<Box flexDirection="column" flexShrink={0}>
{queuedMessages.length > 0 && (
<Box flexDirection="column" paddingLeft={1} marginBottom={1}>
<Text color={COLORS.dim} bold>
Queue ({queuedMessages.length})
</Text>
{queuedMessages.slice(0, 5).map((msg, i) => (
<Text key={i} color={COLORS.dim}>
{i + 1}.{" "}
{truncateForQueue(msg.text || "Attached files").replace(
/\n/g,
"↵",
)}
{msg.attachments.length > 0
? ` · 📎 ${msg.attachments.length}`
: ""}
</Text>
))}
{queuedMessages.length > 5 && (
<Text color={COLORS.dim}>
{" "}
… {queuedMessages.length - 5} more
</Text>
)}
</Box>
<QueuedMessages
messages={queuedMessages}
width={wrapWidth}
onForceSend={forceSendQueued}
/>
)}
<InputBox
active={inputActive}
Expand Down Expand Up @@ -2372,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.
Expand Down Expand Up @@ -2416,12 +2473,6 @@ function estimateRowLines(row: Row, width: number): number {
}
}

const QUEUE_PREVIEW_LIMIT = 80;
function truncateForQueue(text: string): string {
if (text.length <= QUEUE_PREVIEW_LIMIT) return text;
return text.slice(0, QUEUE_PREVIEW_LIMIT - 1) + "…";
}

function LoginSection({
onLogin,
}: {
Expand Down
2 changes: 1 addition & 1 deletion src/ui/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function Header({ cwd, modelName }: { cwd: string; modelName: string }) {
</Box>
<Box marginTop={1}>
<Text color={COLORS.dim}>
shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d exit
shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d/c exit
</Text>
</Box>
</Box>
Expand Down
Loading