diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a41969..2d26c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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": { 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), 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 { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 959653e..defc77a 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); @@ -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); @@ -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( @@ -2093,29 +2168,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 - - )} - + )} - shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d exit + shift+tab approvals · ctrl+o thinking · esc interrupt · ctrl+d/c exit diff --git a/src/ui/components/QueuedMessages.tsx b/src/ui/components/QueuedMessages.tsx new file mode 100644 index 0000000..ba81018 --- /dev/null +++ b/src/ui/components/QueuedMessages.tsx @@ -0,0 +1,86 @@ +import React, { useState } from "react"; +import { Box, Text } from "../primitives.js"; +import { COLORS } from "../../branding.js"; +import type { SubmittedPrompt } from "../../attachments.js"; + +/** Messages shown before the queue collapses into a "… N more" line. */ +const MAX_VISIBLE = 5; +const QUEUE_PREVIEW_LIMIT = 80; +const ACTION_TAG = "[send now]"; + +function fit(text: string, maxWidth: number): string { + if (text.length <= maxWidth) return text; + if (maxWidth <= 1) return text.slice(0, Math.max(0, maxWidth)); + return text.slice(0, maxWidth - 1) + "…"; +} + +function previewText(message: SubmittedPrompt): string { + const text = (message.text || "Attached files").replace(/\n/g, "↵"); + const truncated = + text.length <= QUEUE_PREVIEW_LIMIT + ? text + : text.slice(0, QUEUE_PREVIEW_LIMIT - 1) + "…"; + return message.attachments.length > 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`} + + )} + + ); +}