Files
claudecodeui/server/modules/providers/list/opencode/opencode-sessions.provider.ts
Haile 4cee5e7286 Fix/resolve different bugs (#964)
* fix: don't overlap thinking banner over conversation

* feat: support message queue and add attention indicator

* fix(shell): use c to copy for claude

* fix: strip timestamp tags from cursor

* fix: select project when loading session from direct URL

* feat: support attached images for all providers

* feat(opencode): support permission options

* fix: resolve source control and chat UX bugs

- make staging real in the git panel: new /stage and /unstage endpoints,
  /status now reports the actual index via porcelain -z (handles renames,
  conflicts, and paths with spaces), and Stage/Unstage All run real git
  commands instead of toggling client-side state
- add branch search to the header dropdown and Branches tab
- persist the chosen permission mode per provider so a brand-new chat
  keeps it once the session id arrives, instead of reverting to default
- replace cmdk's fuzzy model search with strict token matching so
  "chatgpt" no longer surfaces unrelated models
- unit tests for the git status parser

* feat(git): commit graph in history view and cross-spawn everywhere

- render a VSCode-style commit graph in the source control history:
  lane-assignment algorithm, SVG strip with colored rails, merge and
  branch curves, commit dots, and branch/tag badges per commit
- /commits returns parents and ref decorations across all branches
  (--branches --remotes --tags --topo-order) using unit-separator
  fields so pipes in commit subjects can't break parsing
- collect stats via a single --shortstat pass instead of one
  `git show --stat` call per commit; raise the history limit to 50
- replace child_process spawn with cross-spawn in all runtimes,
  routes, and services: it resolves .cmd shims and PATHEXT on
  Windows (fixing taskmaster's npx invocations) and delegates to
  native spawn elsewhere, removing the per-file win32 ternaries
- unit tests for the log parser and lane assignment

* fix: remove gemini support since google discontinued it

* fix: address code review findings

- validate chat image attachments server-side: only files inside the
  ~/.cloudcli/assets upload store may reach provider file reads
- harden asset serving with nosniff and attachment disposition for
  SVGs to prevent stored XSS
- show the timestamp on image-only user messages
- serialize git stage/unstage calls and defer the status re-sync so
  rapid toggles can't interleave or flicker
- use a literal hex fallback for commit ref badges (alpha suffix on a
  var() string produced invalid CSS)
- stop binding a URL session to a guening
  project instead
- add the missing attentionRequiredIndicator key to all sidebar locales
- clean up dangling conjunctions left the
  de/ru/tr/ja/zh-CN/zh-TW READMEs

* fix: address code scanning findings

- enforce a second image trust boundary in the provider builders:
  buildClaudeUserContent/buildCodexInputItems only accept files inside
  the upload store or the run's working directory (CodeQL path
  injection)
- drop an always-true selectedProject check in handleSubmit
- remove the unused resume option from spawnCursor

* fix: use CodeQL-recognized containment check for image reads

Replace the path.relative guard in isPathInsideDirectory with the
resolve + startsWith(root + sep) idiom so the path-injection barrier
is visible to code scanning; behavior is unchanged.

* fix(security): canonicalize Claude image attachment reads

Resolve image attachment paths with realpath before reading so symlinks
inside allowed roots cannot escape to arbitrary files. Preserve support for
symlinked project/upload roots and add focused coverage for both cases.

* fix: show agent subtask

* fix(sessions): title app-created sessions from the first user message

App-created sessions (started by sending a message from cloudcli) were
titled with placeholder names — "Untitled Codex Session" from the disk
indexer and, briefly, "New session" from the empty canonical upsert —
before a later sync finally settled on the right text, causing the
sidebar title to flicker.

- Codex/OpenCode synchronizers now title app-created sessions (distinct
  app id mapped to a provider id) from the first user message, while
  sessions found purely by indexing keep their existing setup. Claude
  keeps its AI-generated titles; Cursor already used the first message.
- Decode OpenCode's JSON-string-literal prompts so titles no longer
  surface wrapped in quotes; hoist unwrapJsonStringLiteral into
  shared/utils since it's now used by both the reader and synchronizer.
- Guard the sidebar upsert merge so an empty summary can never blank out
  a title that is already set.
- Add codex/opencode synchronizer tests for the app-created vs indexed
  naming paths.

* fix(chat): make message queuing reliable across sessions and turn boundaries

Queued messages had four related defects:

- A queued message flashed and then vanished at flush time: concurrent
  transcript refreshes (the `complete` handler racing the watcher-triggered
  update) could resolve out of order, letting a stale response overwrite
  newer server messages after the optimistic row was pruned. Session slots
  now carry a monotonic fetch ticket and discard stale fetch/refresh/
  fetchMore responses.

- Switching sessions flushed the previous session's queued draft into the
  newly viewed one (sending it with the wrong provider's settings, e.g. a
  Claude model into a Codex session). The composer flush is now scoped to
  its session, and a draft restored into an idle session sends after a
  short grace period so a live-run ack can cancel it.

- The thinking banner never appeared for a queued turn: the chat handler's
  session-keyed completeRun safety net could fire after a queued message
  had already started the session's next run, emitting a spurious
  `complete` that killed it. The safety net is now scoped to its own run
  via completeRunIfCurrent (with a regression test).

- Queued messages only sent while their session was being viewed. Drafts
  now persist their send options (model, effort, permissions) at queue
  time, and a new app-level useQueuedMessageAutoSend hook dispatches a
  non-viewed session's queued message as soon as its run completes, using
  the storage key as the claim ticket to prevent double sends.
2026-07-08 10:32:32 +02:00

493 lines
14 KiB
TypeScript

import fsSync from 'node:fs';
import Database from 'better-sqlite3';
import { parseImagesInputTag } from '@/shared/image-attachments.js';
import type { IProviderSessions } from '@/shared/interfaces.js';
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import {
createNormalizedMessage,
generateMessageId,
getOpenCodeDatabasePath,
normalizeProviderTimestamp,
readObjectRecord,
readJsonRecord,
readOptionalString,
sliceTailPage,
unwrapJsonStringLiteral,
} from '@/shared/utils.js';
const PROVIDER = 'opencode';
type OpenCodeHistoryRow = {
message_id: string;
message_time_created: number | null;
message_data: string | null;
part_id: string | null;
part_time_created: number | null;
part_data: string | null;
};
type OpenCodeTokenTotals = {
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
};
const openOpenCodeDatabase = (): Database.Database | null => {
const dbPath = getOpenCodeDatabasePath();
if (!fsSync.existsSync(dbPath)) {
return null;
}
return new Database(dbPath, { readonly: true, fileMustExist: true });
};
const formatToolContent = (value: unknown): string => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const extractText = (value: unknown): string => {
if (typeof value === 'string') {
return unwrapJsonStringLiteral(value);
}
const record = readObjectRecord(value);
const text = readOptionalString(record?.text)
?? readOptionalString(record?.content)
?? '';
return unwrapJsonStringLiteral(text);
};
const hasUserRole = (value: unknown): boolean => {
const record = readObjectRecord(value);
return readOptionalString(record?.role) === 'user';
};
const isUserTextEcho = (raw: AnyRecord): boolean => {
return readOptionalString(raw.role) === 'user'
|| hasUserRole(raw.message)
|| hasUserRole(raw.part);
};
const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | undefined => {
if (!totals) {
return undefined;
}
const inputTokens = totals.inputTokens;
const displayInputTokens = inputTokens + totals.cacheReadTokens;
const outputTokens = totals.outputTokens;
const used = inputTokens
+ outputTokens
+ totals.reasoningTokens
+ totals.cacheReadTokens
+ totals.cacheWriteTokens;
if (used <= 0) {
return undefined;
}
return {
used,
inputTokens: displayInputTokens,
outputTokens,
breakdown: {
input: displayInputTokens,
output: outputTokens,
},
};
};
const readOpenCodeSessionColumnTokenUsage = (
db: Database.Database,
sessionId: string,
): AnyRecord | undefined => {
const columns = db.prepare('PRAGMA table_info(session)').all() as { name: string }[];
const columnNames = new Set(columns.map((column) => column.name));
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
if (!requiredColumns.every((column) => columnNames.has(column))) {
return undefined;
}
const row = db.prepare(`
SELECT
tokens_input AS inputTokens,
tokens_output AS outputTokens,
tokens_reasoning AS reasoningTokens,
tokens_cache_read AS cacheReadTokens,
tokens_cache_write AS cacheWriteTokens
FROM session
WHERE id = ?
`).get(sessionId) as OpenCodeTokenTotals | undefined;
if (!row) {
return undefined;
}
return buildTokenUsage({
inputTokens: Number(row.inputTokens ?? 0),
outputTokens: Number(row.outputTokens ?? 0),
reasoningTokens: Number(row.reasoningTokens ?? 0),
cacheReadTokens: Number(row.cacheReadTokens ?? 0),
cacheWriteTokens: Number(row.cacheWriteTokens ?? 0),
});
};
/**
* OpenCode stores per-message token counts on assistant `message.data` objects
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
* matches current `opencode.db` layouts that only persist message JSON.
*/
const aggregateOpenCodeSessionTokenUsage = (
db: Database.Database,
sessionId: string,
): AnyRecord | undefined => {
const sessionColumnUsage = readOpenCodeSessionColumnTokenUsage(db, sessionId);
if (sessionColumnUsage) {
return sessionColumnUsage;
}
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
let inputTokens = 0;
let outputTokens = 0;
let reasoningTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
for (const row of rows) {
const info = readJsonRecord(row.data);
if (readOptionalString(info?.role) !== 'assistant') {
continue;
}
const tokens = readObjectRecord(info?.tokens);
if (!tokens) {
continue;
}
inputTokens += Number(tokens.input ?? 0);
outputTokens += Number(tokens.output ?? 0);
reasoningTokens += Number(tokens.reasoning ?? 0);
const cache = readObjectRecord(tokens.cache);
cacheReadTokens += Number(cache?.read ?? 0);
cacheWriteTokens += Number(cache?.write ?? 0);
}
return buildTokenUsage({
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
});
};
export class OpenCodeSessionsProvider implements IProviderSessions {
/**
* Normalizes live `opencode run --format json` events into frontend messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readObjectRecord(rawMessage);
if (!raw) {
return [];
}
const type = readOptionalString(raw.type) ?? readOptionalString(raw.event);
const eventSessionId = readOptionalString(raw.sessionID) ?? readOptionalString(raw.sessionId) ?? sessionId;
const timestamp = normalizeProviderTimestamp(raw.time ?? raw.timestamp);
const baseId = readOptionalString(raw.id)
?? readOptionalString(raw.messageID)
?? generateMessageId('opencode');
if (type === 'text') {
// The client already renders an optimistic user bubble, so provider user
// echoes must not be streamed back as assistant text.
if (isUserTextEcho(raw)) {
return [];
}
const content = extractText(raw.text ?? raw.delta ?? raw.message);
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_delta',
content,
})];
}
if (type === 'reasoning') {
const content = extractText(raw.text ?? raw.delta ?? raw.message);
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content,
})];
}
if (type === 'tool_use') {
const toolName = readOptionalString(raw.tool) ?? readOptionalString(raw.name) ?? 'Tool';
const toolId = readOptionalString(raw.callID) ?? readOptionalString(raw.toolCallId) ?? baseId;
const toolMessage = createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: raw.input ?? raw.arguments ?? {},
toolId,
});
if (raw.output !== undefined || raw.error !== undefined) {
toolMessage.toolResult = {
content: formatToolContent(raw.output ?? raw.error),
isError: raw.error !== undefined,
};
}
return [toolMessage];
}
if (type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'error',
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown OpenCode error',
})];
}
if (type === 'step_finish') {
return [createNormalizedMessage({
id: baseId,
sessionId: eventSessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_end',
})];
}
return [];
}
/**
* Loads OpenCode history from the shared SQLite session database.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
// OpenCode's shared sqlite database keys messages by the provider-native
// session id, not the app-facing id this method is addressed with.
const providerSessionId = options.providerSessionId ?? sessionId;
const db = openOpenCodeDatabase();
if (!db) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
try {
const rows = db.prepare(`
SELECT
m.id AS message_id,
m.time_created AS message_time_created,
m.data AS message_data,
p.id AS part_id,
p.time_created AS part_time_created,
p.data AS part_data
FROM message m
LEFT JOIN part p
ON p.session_id = m.session_id
AND p.message_id = m.id
WHERE m.session_id = ?
ORDER BY
COALESCE(m.time_created, 0),
m.id,
COALESCE(p.time_created, 0),
p.id
`).all(providerSessionId) as OpenCodeHistoryRow[];
const normalized = this.normalizeHistoryRows(rows, sessionId);
const tokenUsage = aggregateOpenCodeSessionTokenUsage(db, providerSessionId);
const normalizedOffset = Math.max(0, offset);
const normalizedLimit = limit === null ? null : Math.max(0, limit);
const total = normalized.length;
const { page, hasMore } = sliceTailPage(normalized, normalizedLimit, normalizedOffset);
return {
messages: page,
total,
hasMore,
offset: normalizedOffset,
limit: normalizedLimit,
tokenUsage,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[OpenCodeProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
} finally {
db.close();
}
}
private normalizeHistoryRows(rows: OpenCodeHistoryRow[], sessionId: string): NormalizedMessage[] {
const normalized: NormalizedMessage[] = [];
const emittedMessageErrors = new Set<string>();
for (const row of rows) {
const timestamp = normalizeProviderTimestamp(row.part_time_created ?? row.message_time_created);
const baseId = `${row.message_id}_${row.part_id ?? normalized.length}`;
const messageInfo = readJsonRecord(row.message_data);
const messageRole = readOptionalString(messageInfo?.role);
if (
messageInfo
&& messageRole === 'assistant'
&& messageInfo.error != null
&& !emittedMessageErrors.has(row.message_id)
) {
emittedMessageErrors.add(row.message_id);
normalized.push(createNormalizedMessage({
id: `${baseId}_error`,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'error',
content: formatToolContent(messageInfo.error),
}));
}
if (!row.part_id) {
continue;
}
const partData = readJsonRecord(row.part_data) ?? {};
const partType = readOptionalString(partData.type);
if (!partType) {
continue;
}
if (partType === 'text') {
const rawContent = extractText(partData);
// User prompts sent with attachments carry an <images_input> path
// list; strip it for display and surface the paths as images.
const { text: content, attachments } = messageRole === 'user'
? parseImagesInputTag(rawContent)
: { text: rawContent, attachments: [] };
if (content.trim() || attachments.length > 0) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'text',
role: messageRole === 'user' ? 'user' : 'assistant',
content,
images: attachments.length > 0 ? attachments : undefined,
}));
}
continue;
}
if (partType === 'reasoning') {
const content = extractText(partData);
if (content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'thinking',
content,
}));
}
continue;
}
if (partType === 'tool') {
const state = readObjectRecord(partData.state) ?? {};
const status = readOptionalString(state.status);
const toolMessage = createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: readOptionalString(partData.tool) ?? 'Tool',
toolInput: state.input ?? partData.input ?? {},
toolId: readOptionalString(partData.callID) ?? row.part_id,
});
if (status === 'completed' || status === 'error') {
toolMessage.toolResult = {
content: formatToolContent(state.output ?? state.error),
isError: status === 'error',
};
}
normalized.push(toolMessage);
continue;
}
if (partType === 'step-finish') {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'stream_end',
}));
continue;
}
if (partType === 'patch' || partType === 'agent') {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp,
provider: PROVIDER,
kind: 'tool_use',
toolName: partType === 'patch' ? 'Patch' : 'Agent',
toolInput: partData,
toolId: row.part_id,
}));
}
}
return normalized;
}
}