blob: af8350742b750615dfe91027731c8a936c8c81e5 [file]
/**
* Backend → UI unified event stream.
*
* Runtime backends normalize their provider-native streams to
* this `SessionEvent` union. The UI never imports SDK types directly.
*
* Connection-setup events live in ./connections.ts (separate channel).
*/
import type {
AdditionalPermissionRequest,
PermissionMode,
PermissionRequest,
PermissionResponse,
SandboxEscalationRequest,
} from './permission.js';
import type { SandboxBoundaryExpansion, SandboxBoundaryRequestStatus } from './sandbox-boundary.js';
import type { UserQuestionRequest } from './user-question.js';
import type {
PipeShellOutput,
PtyShellOutput,
ShellOutput,
ShellRunOperation,
ShellRunStatus,
ShellRunTerminalStatus,
} from './shell-run.js';
export { SHELL_RUN_SOURCE_TOOL_CALL_ID_MAX_BYTES } from './shell-run.js';
import { type TokenUsageFields } from './usage-record-schema.js';
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';
export const TOOL_OUTPUT_STREAMS = ['stdout', 'stderr'] as const;
export const TOOL_OUTPUT_DELTA_MAX_CHARS = 8192;
export const TOOL_ACTIVITY_KINDS = [
// Driving the user's own machine is not "a tool call". It has its own risk,
// its own approval classes and its own place in a transcript, and reading it
// under the same gear icon as everything else hides the one activity a person
// most wants to pick out at a glance.
'computer',
'read',
'search',
'websearch',
'webfetch',
'edit',
'command',
'explore',
'browser',
'tool',
] as const;
export type ToolActivityKind = (typeof TOOL_ACTIVITY_KINDS)[number];
type TerminalToolResultStatus = Exclude<ShellRunTerminalStatus, 'orphaned'>;
// ============================================================================
// Storage refs (shared by attachments, image tool results, etc.)
// ============================================================================
export type StorageRef =
| { kind: 'session_file'; sessionId: string; relativePath: string }
| { kind: 'workspace_file'; relativePath: string }
| { kind: 'external_file'; absolutePath: string };
export interface AttachmentRef {
kind: 'image' | 'pdf' | 'doc' | 'code' | 'other';
name: string;
mimeType: string;
bytes: number;
ref: StorageRef;
}
/**
* An inline quoted excerpt attached to a user message — e.g. text selected in
* the transcript and carried into a follow-up. Unlike {@link AttachmentRef}
* (file-backed), the quoted text lives inline: it renders as a chip on the user
* bubble (never as raw body text) and is folded into the model-facing content.
*/
export interface QuoteRef {
text: string;
/** Optional label shown on the chip (e.g. the source turn's role/preview). */
label?: string;
/** Provenance: the transcript turn the excerpt was selected from. */
sourceTurnId?: string;
}
/**
* Frozen display metadata for one token embedded in a sent message's visible
* text. The model-facing authority remains {@link MessageContent.text}; this
* record only lets clients replay the token the user actually sent without
* consulting a mutable Skill catalog or guessing file-path boundaries.
*/
export interface InlineReference {
kind: 'skill' | 'workspace_file';
/** Exact serialized token value present in `displayText ?? text`. */
value: string;
/** Display label captured when the message was accepted. */
label: string;
/** UTF-16 offset of this exact occurrence in `displayText ?? text`. */
start: number;
}
/** Canonical user-authored content shared by storage, runtime, and Host wire. */
export interface MessageContent {
/**
* Authoritative model-facing input. This may be a composed envelope when a
* client injects context such as explicit skill instructions.
*/
text: string;
/** Human-facing text when it differs from `text`; omit when equal. */
displayText?: string;
/** Ordered attachment references; omit when empty. Attachment bytes never travel here. */
attachments?: AttachmentRef[];
/** Ordered inline excerpts; omit when empty. Provenance remains part of content identity. */
quotes?: QuoteRef[];
/** Sent inline tokens; an empty array marks a current-format plain message. Never model-visible. */
inlineReferences?: InlineReference[];
}
const MESSAGE_CONTENT_SHAPE = defineObjectShape<MessageContent>()(
['text'],
['displayText', 'attachments', 'quotes', 'inlineReferences'],
);
const ATTACHMENT_REF_SHAPE = defineObjectShape<AttachmentRef>()(
['kind', 'name', 'mimeType', 'bytes', 'ref'],
[],
);
const QUOTE_REF_SHAPE = defineObjectShape<QuoteRef>()(['text'], ['label', 'sourceTurnId']);
const INLINE_REFERENCE_SHAPE = defineObjectShape<InlineReference>()(
['kind', 'value', 'label', 'start'],
[],
);
const INLINE_SKILL_REFERENCE_VALUE = /^\/skill:[A-Za-z0-9._-]+$/;
export const INLINE_REFERENCE_MAX_COUNT = 32;
const MAX_INLINE_REFERENCE_VALUE_LENGTH = 4_096;
export const INLINE_REFERENCE_LABEL_MAX_LENGTH = 200;
const SESSION_FILE_REF_SHAPE = defineObjectShape<Extract<StorageRef, { kind: 'session_file' }>>()(
['kind', 'sessionId', 'relativePath'],
[],
);
const WORKSPACE_FILE_REF_SHAPE = defineObjectShape<
Extract<StorageRef, { kind: 'workspace_file' }>
>()(['kind', 'relativePath'], []);
const EXTERNAL_FILE_REF_SHAPE = defineObjectShape<Extract<StorageRef, { kind: 'external_file' }>>()(
['kind', 'absolutePath'],
[],
);
export function normalizeMessageContent(content: MessageContent): MessageContent {
return {
text: content.text,
...(content.displayText !== undefined && content.displayText !== content.text
? { displayText: content.displayText }
: {}),
...(content.attachments !== undefined && content.attachments.length > 0
? {
attachments: content.attachments.map((attachment) => ({
...attachment,
bytes: Object.is(attachment.bytes, -0) ? 0 : attachment.bytes,
ref: { ...attachment.ref },
})),
}
: {}),
...(content.quotes !== undefined && content.quotes.length > 0
? {
quotes: content.quotes.map((quote) => ({
text: quote.text,
...(quote.label !== undefined ? { label: quote.label } : {}),
...(quote.sourceTurnId !== undefined ? { sourceTurnId: quote.sourceTurnId } : {}),
})),
}
: {}),
...(content.inlineReferences !== undefined
? {
inlineReferences: content.inlineReferences.map((reference) => ({ ...reference })),
}
: {}),
};
}
export function aggregateMessageContents(contents: readonly MessageContent[]): MessageContent {
const text = contents.map((content) => content.text).join('\n\n');
const displayText = contents.map((content) => content.displayText ?? content.text).join('\n\n');
const attachments = contents.flatMap((content) => content.attachments ?? []);
const quotes = contents.flatMap((content) => content.quotes ?? []);
const inlineReferences: InlineReference[] = [];
const hasInlineReferenceMarker = contents.some(
(content) => content.inlineReferences !== undefined,
);
let displayOffset = 0;
for (const content of contents) {
for (const reference of content.inlineReferences ?? []) {
if (inlineReferences.length === INLINE_REFERENCE_MAX_COUNT) break;
inlineReferences.push({ ...reference, start: displayOffset + reference.start });
}
displayOffset += (content.displayText ?? content.text).length + 2;
}
return normalizeMessageContent({
text,
...(displayText !== text ? { displayText } : {}),
...(attachments.length > 0 ? { attachments } : {}),
...(quotes.length > 0 ? { quotes } : {}),
...(hasInlineReferenceMarker ? { inlineReferences } : {}),
});
}
export function decodeMessageContent(value: unknown): MessageContent {
if (!isMessageContent(value)) throw new TypeError('Invalid MessageContent');
return normalizeMessageContent(value);
}
export function isMessageContent(value: unknown): value is MessageContent {
return (
isRecord(value) &&
hasExactShape(value, MESSAGE_CONTENT_SHAPE) &&
typeof value.text === 'string' &&
(value.displayText === undefined || typeof value.displayText === 'string') &&
(value.attachments === undefined ||
(Array.isArray(value.attachments) && value.attachments.every(isAttachmentRef))) &&
(value.quotes === undefined ||
(Array.isArray(value.quotes) && value.quotes.every(isQuoteRef))) &&
(value.inlineReferences === undefined ||
(Array.isArray(value.inlineReferences) &&
value.inlineReferences.length <= INLINE_REFERENCE_MAX_COUNT &&
value.inlineReferences.every(isInlineReference) &&
inlineReferencesMatchText(value.inlineReferences, value.displayText ?? value.text)))
);
}
function inlineReferencesMatchText(references: readonly InlineReference[], text: string): boolean {
let previousEnd = 0;
for (const reference of references) {
if (
reference.start < previousEnd ||
text.slice(reference.start, reference.start + reference.value.length) !== reference.value
) {
return false;
}
previousEnd = reference.start + reference.value.length;
}
return true;
}
export function isInlineReference(value: unknown): value is InlineReference {
return (
isRecord(value) &&
hasExactShape(value, INLINE_REFERENCE_SHAPE) &&
(value.kind === 'skill' || value.kind === 'workspace_file') &&
typeof value.value === 'string' &&
value.value.length > 0 &&
value.value.length <= MAX_INLINE_REFERENCE_VALUE_LENGTH &&
typeof value.label === 'string' &&
value.label.length > 0 &&
value.label.length <= INLINE_REFERENCE_LABEL_MAX_LENGTH &&
typeof value.start === 'number' &&
Number.isSafeInteger(value.start) &&
value.start >= 0 &&
(value.kind === 'skill'
? INLINE_SKILL_REFERENCE_VALUE.test(value.value)
: value.value.startsWith('@') &&
isCanonicalStorageRef({
kind: 'workspace_file',
relativePath: value.value.slice(1),
}))
);
}
export function isQuoteRef(value: unknown): value is QuoteRef {
return (
isRecord(value) &&
hasExactShape(value, QUOTE_REF_SHAPE) &&
typeof value.text === 'string' &&
(value.label === undefined || typeof value.label === 'string') &&
(value.sourceTurnId === undefined || typeof value.sourceTurnId === 'string')
);
}
export function isAttachmentRef(value: unknown): value is AttachmentRef {
return (
isRecord(value) &&
hasExactShape(value, ATTACHMENT_REF_SHAPE) &&
(value.kind === 'image' ||
value.kind === 'pdf' ||
value.kind === 'doc' ||
value.kind === 'code' ||
value.kind === 'other') &&
typeof value.name === 'string' &&
typeof value.mimeType === 'string' &&
typeof value.bytes === 'number' &&
Number.isSafeInteger(value.bytes) &&
value.bytes >= 0 &&
isStorageRef(value.ref)
);
}
/** A structurally valid attachment whose metadata and locator are canonical at durable boundaries. */
export function isCanonicalAttachmentRef(value: unknown): value is AttachmentRef {
return (
isAttachmentRef(value) &&
value.name.length > 0 &&
value.mimeType.length > 0 &&
isCanonicalStorageRef(value.ref)
);
}
export function isStorageRef(value: unknown): value is StorageRef {
if (!isRecord(value)) return false;
if (value.kind === 'session_file') {
return (
hasExactShape(value, SESSION_FILE_REF_SHAPE) &&
typeof value.sessionId === 'string' &&
typeof value.relativePath === 'string'
);
}
if (value.kind === 'workspace_file') {
return hasExactShape(value, WORKSPACE_FILE_REF_SHAPE) && typeof value.relativePath === 'string';
}
return (
value.kind === 'external_file' &&
hasExactShape(value, EXTERNAL_FILE_REF_SHAPE) &&
typeof value.absolutePath === 'string'
);
}
export function isCanonicalStorageRef(value: unknown): value is StorageRef {
if (!isStorageRef(value)) return false;
if (value.kind === 'external_file') return isCanonicalAbsolutePath(value.absolutePath);
if (value.kind === 'session_file' && !/^[A-Za-z0-9_-]{1,128}$/.test(value.sessionId)) {
return false;
}
return isCanonicalRelativePath(value.relativePath);
}
function isCanonicalRelativePath(path: string): boolean {
return (
path.length > 0 &&
!path.includes('\0') &&
!path.includes('\\') &&
!path.startsWith('/') &&
!/^[A-Za-z]:/.test(path) &&
path.split('/').every((segment) => segment.length > 0 && segment !== '.' && segment !== '..')
);
}
function isCanonicalAbsolutePath(path: string): boolean {
if (path.length === 0 || path.includes('\0')) return false;
if (path.startsWith('/')) return true;
if (/^[A-Za-z]:[\\/]/.test(path)) return true;
return /^\\\\[^\\/]+[\\/][^\\/]+/.test(path);
}
export function messageContentsEqual(left: MessageContent, right: MessageContent): boolean {
const leftDisplayText = left.displayText === left.text ? undefined : left.displayText;
const rightDisplayText = right.displayText === right.text ? undefined : right.displayText;
const leftAttachments = left.attachments?.length ? left.attachments : undefined;
const rightAttachments = right.attachments?.length ? right.attachments : undefined;
const leftQuotes = left.quotes?.length ? left.quotes : undefined;
const rightQuotes = right.quotes?.length ? right.quotes : undefined;
const leftInlineReferences = left.inlineReferences;
const rightInlineReferences = right.inlineReferences;
return (
left.text === right.text &&
leftDisplayText === rightDisplayText &&
((leftAttachments === undefined && rightAttachments === undefined) ||
(leftAttachments !== undefined &&
rightAttachments !== undefined &&
leftAttachments.length === rightAttachments.length &&
leftAttachments.every((attachment, index) =>
attachmentRefsEqual(attachment, rightAttachments[index]!),
))) &&
((leftQuotes === undefined && rightQuotes === undefined) ||
(leftQuotes !== undefined &&
rightQuotes !== undefined &&
leftQuotes.length === rightQuotes.length &&
leftQuotes.every((quote, index) => quoteRefsEqual(quote, rightQuotes[index]!)))) &&
((leftInlineReferences === undefined && rightInlineReferences === undefined) ||
(leftInlineReferences !== undefined &&
rightInlineReferences !== undefined &&
leftInlineReferences.length === rightInlineReferences.length &&
leftInlineReferences.every((reference, index) =>
inlineReferencesEqual(reference, rightInlineReferences[index]!),
)))
);
}
function inlineReferencesEqual(left: InlineReference, right: InlineReference): boolean {
return (
left.kind === right.kind &&
left.value === right.value &&
left.label === right.label &&
left.start === right.start
);
}
function quoteRefsEqual(left: QuoteRef, right: QuoteRef): boolean {
return (
left.text === right.text &&
left.label === right.label &&
left.sourceTurnId === right.sourceTurnId
);
}
function attachmentRefsEqual(left: AttachmentRef, right: AttachmentRef): boolean {
if (
left.kind !== right.kind ||
left.name !== right.name ||
left.mimeType !== right.mimeType ||
left.bytes !== right.bytes ||
left.ref.kind !== right.ref.kind
) {
return false;
}
switch (left.ref.kind) {
case 'session_file':
return (
right.ref.kind === 'session_file' &&
left.ref.sessionId === right.ref.sessionId &&
left.ref.relativePath === right.ref.relativePath
);
case 'workspace_file':
return (
right.ref.kind === 'workspace_file' && left.ref.relativePath === right.ref.relativePath
);
case 'external_file':
return right.ref.kind === 'external_file' && left.ref.absolutePath === right.ref.absolutePath;
}
}
// ============================================================================
// Event union
// ============================================================================
interface BaseEvent {
/** Event uuid — used for dedup on reconnect/replay. */
id: string;
/** Groups all events from one agent turn. */
turnId: string;
/** Unix ms timestamp. */
ts: number;
}
interface ToolActivityIdentity {
/** Execution surface that produced this tool activity. */
origin?: 'provider' | 'code_mode';
/** Provider-history projection policy for this activity. */
modelVisibility?: 'visible' | 'hidden';
/** Enclosing exec provider call, for nested CodeMode activity. */
parentToolCallId?: string;
/** Enclosing exec durable operation, for nested CodeMode activity. */
parentOperationId?: string;
}
export type SessionEvent =
| TextDeltaEvent
| TextCompleteEvent
| ThinkingDeltaEvent
| ThinkingCompleteEvent
| ToolStartEvent
| ToolOutputDeltaEvent
| ToolProgressEvent
| ToolResultPreviewEvent
| ToolResultEvent
| AnyPermissionRequestEvent
| SandboxBoundaryRequestEvent
| SandboxBoundaryDecisionAckEvent
| PermissionAnswerAckEvent
| PermissionClosureAckEvent
| PermissionDecisionAckEvent
| UserQuestionRequestEvent
| UserQuestionAnswerAckEvent
| PlanSubmittedEvent
| TokenUsageEvent
| SteeringMessageEvent
| QueueUpdateEvent
| ProviderRetryEvent
| ErrorEvent
| CompleteEvent
| AbortEvent;
export interface TextDeltaEvent extends BaseEvent {
type: 'text_delta';
messageId: string;
/** Absolute UTF-16 offset for replay-safe streams; absent for append-only backends. */
startOffset?: number;
text: string;
}
export interface TextCompleteEvent extends BaseEvent {
type: 'text_complete';
messageId: string;
text: string;
/** Provider-owned text metadata such as Responses URL citations. */
providerOptions?: Record<string, unknown>;
}
export interface ThinkingDeltaEvent extends BaseEvent {
type: 'thinking_delta';
messageId: string;
/** Absolute UTF-16 offset for replay-safe streams; absent for append-only backends. */
startOffset?: number;
text: string;
}
export interface ThinkingCompleteEvent extends BaseEvent {
type: 'thinking_complete';
messageId: string;
text: string;
/** Anthropic signed thinking — MUST be re-sent on replay. */
signature?: string;
/** Provider-owned replay metadata that must survive backend recreation. */
providerOptions?: Record<string, unknown>;
}
export interface ToolStartEvent extends BaseEvent, ToolActivityIdentity {
type: 'tool_start';
toolUseId: string;
toolName: string;
/** Runtime-owned durable tool-operation identity (Phase 2). */
operationId?: string;
/** Stable semantic category for presentation; absent on legacy events. */
activityKind?: ToolActivityKind;
args: unknown;
/** Provider-owned opaque call metadata that must survive model replay. */
providerOptions?: Record<string, unknown>;
/** True when the provider executed the tool inside the model request. */
providerExecuted?: boolean;
displayName?: string;
intent?: string;
/**
* Id of the assistant step this tool call belongs to (equals the step's
* AssistantMessage id / the step's text+thinking messageId). Lets model
* replay group a step's reasoning + text + tool calls into one provider
* assistant message. Absent on legacy events; consumers treat a missing
* stepId as un-pairable (degraded, per-turn) history.
*/
stepId?: string;
}
export type ToolOutputStream = (typeof TOOL_OUTPUT_STREAMS)[number];
/**
* Live output side-channel for long-running tools.
*
* This is intentionally separate from ToolResultEvent: deltas are transient UI
* updates, while tool_result remains the terminal persisted result. `seq` is
* monotonic per toolCallId/toolUseId so renderers can de-dupe and repair
* event/result races without relying on arrival order.
*/
export interface ToolOutputDeltaEvent extends BaseEvent, ToolActivityIdentity {
type: 'tool_output_delta';
sessionId: string;
toolCallId: string;
/** Existing UI/runtime name for the same identifier. */
toolUseId: string;
seq: number;
stream: ToolOutputStream;
chunk: string;
redacted: boolean;
createdAt: number;
}
export interface ToolProgressEvent extends BaseEvent, ToolActivityIdentity {
type: 'tool_progress';
toolUseId: string;
chunk: string | { kind: 'stdout' | 'stderr'; text: string };
}
/**
* Live-only open-facts for a tool that is still running (e.g. agent_spawn child ready).
* Not a durable transcript commit and not model-visible function_response.
* Terminal outcome remains a later tool_result.
*/
export type ToolResultPreviewContent = {
kind: 'subagent';
/** Required: the sole purpose of this preview is mid-flight Open. */
childSessionId: string;
agentId?: string;
agentName: string;
turnId: string;
runId?: string;
status: 'running';
permissionMode: PermissionMode;
};
export interface ToolResultPreviewEvent extends BaseEvent, ToolActivityIdentity {
type: 'tool_result_preview';
toolUseId: string;
isError: boolean;
content: ToolResultPreviewContent;
}
export interface ToolResultEvent extends BaseEvent, ToolActivityIdentity {
type: 'tool_result';
toolUseId: string;
/** Runtime-owned durable tool-operation identity (Phase 2). */
operationId?: string;
/** True when the provider executed the tool inside the model request. */
providerExecuted?: boolean;
/** Raw provider result retained for provider-native replay; never rendered directly. */
providerOutput?: unknown;
isError: boolean;
content: ToolResultContent;
durationMs?: number;
}
type ShellRunResultMetadata = {
kind: 'shell_run';
ref: string;
status: ShellRunStatus;
cwd: string;
cmd: string;
startedAt: number;
updatedAt: number;
completedAt?: number;
exitCode?: number;
failureMessage?: string;
revision: number;
timeoutMs?: number;
sandboxDenial?: SandboxDenialSignal | SandboxDenialRecovery;
};
export interface SandboxDenialSignal {
likely: true;
backend?: 'macos-seatbelt' | 'linux';
}
export interface SandboxDenialRecovery extends SandboxDenialSignal {
recovery: 'require_escalated';
}
export interface SandboxBoundaryFailureSignal {
reason: 'sandbox_boundary_required' | 'requires_bypass';
requiredExpansion?: SandboxBoundaryExpansion;
}
export interface ToolUncertainOutcomeSignal {
code: 'outcome_unknown';
retrySafe: false;
}
export class ToolOutcomeUnknownError extends Error {
readonly code = 'outcome_unknown';
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'ToolOutcomeUnknownError';
}
}
export type ShellRunCompactResult = ShellRunResultMetadata &
({ mode: 'pipes'; output?: never } | { mode: 'pty'; output?: never });
export type ShellRunSnapshotResult = ShellRunResultMetadata &
({ mode: 'pipes'; output: PipeShellOutput } | { mode: 'pty'; output: PtyShellOutput });
export type ShellRunStateResult = ShellRunCompactResult | ShellRunSnapshotResult;
type ShellRunStopOperation = Extract<ShellRunOperation, { kind: 'stop' }>;
type ShellRunPtyControlOperation = Extract<ShellRunOperation, { kind: 'pty_control' }>;
type ShellRunToolResultContent =
| (ShellRunCompactResult & { operation?: never })
| (ShellRunSnapshotResult &
(
| { operation?: never }
| { operation: ShellRunStopOperation }
| { mode: 'pty'; output: PtyShellOutput; operation: ShellRunPtyControlOperation }
));
export type ToolResultContent =
| {
kind: 'text';
text: string;
sandboxDenial?: SandboxDenialSignal;
sandboxFailure?: SandboxBoundaryFailureSignal;
uncertainOutcome?: ToolUncertainOutcomeSignal;
}
| { kind: 'json'; value: unknown }
| { kind: 'file_diff'; paths: string[]; diff: string }
| { kind: 'file_write'; path: string; bytes: number }
| {
kind: 'archived_tool_result';
status: 'not_loaded' | 'missing' | 'corrupt';
runtimeEventId: string;
toolCallId: string;
toolName: string;
artifactId?: string;
bodySha256?: string;
originalEstimatedTokens: number;
originalBytes: number;
rewriteVersion: number;
reason: 'stale_tool_result_pruned_before_compact';
}
| {
kind: 'terminal';
cwd: string;
cmd: string;
status: TerminalToolResultStatus;
exitCode?: number;
failureMessage?: string;
output: ShellOutput;
sandboxDenial?: SandboxDenialSignal | SandboxDenialRecovery;
}
| ShellRunToolResultContent
| { kind: 'image'; mimeType: string; ref: StorageRef }
| { kind: 'summary'; original: string; summarized: string; reason: 'too_large' }
/**
* PR-CHAT-WEB-SEARCH-RENDER-0: structured tool-result for the gated
* WebSearch agent tool. The chat renderer surfaces these as plain
* text cards (title + url + snippet + source); never markdown, never
* HTML, matching the Settings → 联网搜索 live-query verification surface.
*
* Rows are an opaque `unknown[]` here so the storage layer does not
* need to import the `@maka/core/web-search` row type; the renderer
* narrows each row at render time.
*/
| {
kind: 'web_search';
provider: string;
query: string;
rows: ReadonlyArray<{
title: string;
url: string;
snippet: string;
source: string;
}>;
}
| {
kind: 'web_search_error';
ok: false;
provider: string;
query?: string;
reason: string;
message: string;
credentialSource?: string;
}
| {
kind: 'explore_agent';
ok: boolean;
partial?: boolean;
terminalStatus?: 'completed' | 'completed_empty' | 'failed' | 'canceled' | 'canceled_partial';
mode: 'read_only';
objective: string;
roots: string[];
queries: string[];
ignoredPaths?: string[];
stoppingCondition?: string;
limitReasons?: ReadonlyArray<
'candidate_budget' | 'file_budget' | 'match_budget' | 'byte_budget'
>;
filesDiscovered?: number;
filesInspected: number;
filesSkipped: number;
sensitiveFilesSkipped?: number;
bytesRead: number;
startedAt?: number;
completedAt?: number;
durationMs?: number;
progress: string[];
recentEvents?: ReadonlyArray<{ type: string; at: number; message: string }>;
evidence?: ReadonlyArray<{
type: 'match' | 'candidate';
path: string;
line?: number;
label: string;
score?: number;
}>;
summary?: string;
report?: string;
candidateFiles: ReadonlyArray<{ path: string; score: number; reasons: string[] }>;
matches: ReadonlyArray<{ path: string; line: number; query: string; snippet: string }>;
notes: string[];
reason?: 'invalid_objective' | 'invalid_root' | 'no_readable_roots' | 'aborted';
message?: string;
}
| {
kind: 'subagent';
childSessionId?: string;
agentId?: string;
agentName: string;
turnId: string;
runId?: string;
status: 'completed' | 'failed' | 'cancelled' | 'running' | 'waiting_for_user';
permissionMode: PermissionMode;
summary: string;
artifactIds: readonly string[];
startedAt?: number;
completedAt?: number;
durationMs?: number;
eventCount?: number;
failureClass?: string;
}
| {
kind: 'agent_swarm';
status: 'completed' | 'partial' | 'failed' | 'cancelled';
items: ReadonlyArray<{
itemId: string;
index: number;
profile: string;
started: boolean;
childSessionId?: string;
agentId?: string;
agentName?: string;
turnId?: string;
runId?: string;
resumedFromRunId?: string;
status: 'completed' | 'failed' | 'cancelled';
summary: string;
artifactIds: readonly string[];
startedAt?: number;
completedAt?: number;
durationMs?: number;
failureClass?: string;
}>;
startedAt: number;
completedAt: number;
durationMs: number;
}
| {
kind: 'rive_workflow';
ok: boolean;
action: string;
command: string[];
state?: string;
ids: {
workflowRunId?: string;
schedulerRunId?: string;
rootWorkNodeId?: string;
};
summary: string;
projection?: {
templateId?: string;
version?: number;
templateHash?: string;
idempotencyStatus?: string;
workflowRunId?: string;
schedulerRunId?: string;
rootWorkNodeId?: string;
state?: string;
schedulerState?: string;
rootState?: string;
};
nodes?: ReadonlyArray<{
id?: string;
templateId?: string;
title?: string;
state?: string;
runner?: string;
worker?: string;
}>;
stdoutTail?: string;
stderrTail?: string;
error?: {
reason: string;
message: string;
code?: string;
suggestedAction?: string;
};
};
/** Durable ShellRun state updates use a separate observer channel from model turns. */
export type ShellRunUpdateOwnership =
| { kind: 'local' }
| { kind: 'source_owned'; sourceSessionId: string; ownerSessionId: string }
| { kind: 'source_unavailable'; sourceSessionId: string };
export interface ShellRunUpdate {
/** Session whose conversation view should consume this projection. */
sessionId: string;
/** Whether the process is local or inherited, and whether its real owner is still resolvable. */
ownership: ShellRunUpdateOwnership;
sourceTurnId: string;
sourceToolCallId: string;
result: ShellRunStateResult;
}
export interface PermissionRequestEvent extends BaseEvent, PermissionRequest {
type: 'permission_request';
}
export interface AdditionalPermissionRequestEvent extends BaseEvent, AdditionalPermissionRequest {
type: 'permission_request';
/** Additional-permission prompts deliberately do not expose raw tool arguments. */
args: undefined;
rememberForTurnAllowed?: false;
}
export interface SandboxEscalationRequestEvent extends BaseEvent, SandboxEscalationRequest {
type: 'permission_request';
/** Escalation prompts expose only bounded command and justification fields. */
args: undefined;
rememberForTurnAllowed?: false;
}
export type AnyPermissionRequestEvent =
| PermissionRequestEvent
| AdditionalPermissionRequestEvent
| SandboxEscalationRequestEvent;
export interface UserQuestionRequestEvent extends BaseEvent, UserQuestionRequest {
type: 'user_question_request';
}
export interface SandboxBoundaryRequestEvent extends BaseEvent {
type: 'sandbox_boundary_request';
requestId: string;
toolUseId: string;
justification: string;
expansion: SandboxBoundaryExpansion;
}
/**
* The requests a session can park on while it waits for the user. Both are
* registered by RuntimeKernel while unanswered, so a surface that missed the
* live event can rehydrate the prompt instead of stranding the run.
*/
export type ActiveInteractionRequestEvent = SandboxBoundaryRequestEvent | UserQuestionRequestEvent;
export interface SandboxBoundaryDecisionAckEvent extends BaseEvent {
type: 'sandbox_boundary_decision_ack';
requestId: string;
toolUseId: string;
decision: 'allow' | 'deny';
status: Exclude<SandboxBoundaryRequestStatus, 'pending'>;
revision: number;
}
/**
* Echo that the backend accepted a user-question answer.
* The canonical answer remains owned by InteractionStore.
*/
export interface UserQuestionAnswerAckEvent extends BaseEvent {
type: 'user_question_answer_ack';
requestId: string;
toolUseId: string;
}
/**
* Echo that the hosted runtime accepted a permission answer.
* The canonical decision remains owned by the Interaction outcome.
*/
export interface PermissionAnswerAckEvent extends BaseEvent {
type: 'permission_answer_ack';
requestId: string;
toolUseId: string;
}
export type PermissionClosureReason = 'timed_out';
/**
* Echo that the hosted runtime durably closed an unanswered permission request.
* This acknowledgement carries identity and closure reason only.
*/
export interface PermissionClosureAckEvent extends BaseEvent {
type: 'permission_closure_ack';
requestId: string;
toolUseId: string;
reason: PermissionClosureReason;
}
/**
* Embedded/legacy echo of a permission decision. Hosted execution uses the
* identity-only PermissionAnswerAckEvent instead.
*/
export interface PermissionDecisionAckEvent extends BaseEvent {
type: 'permission_decision_ack';
requestId: string;
toolUseId: string;
decision: 'allow' | 'deny';
rememberForTurn?: boolean;
reviewer?: import('./permission.js').ApprovalsReviewer;
rationale?: string;
riskLevel?: import('./permission.js').ApprovalRiskLevel;
}
export interface PlanSubmittedEvent extends BaseEvent {
type: 'plan_submitted';
planId: string;
proposalId?: string;
revision?: number;
title: string;
overview?: string;
risks?: string[];
/** Legacy file-backed proposal representation. */
markdownPath?: string;
steps?: PlanStep[];
}
export interface PlanStep {
id: string;
title: string;
description: string;
status: 'pending' | 'in_progress' | 'completed' | 'skipped';
files?: string[];
complexity?: 'low' | 'medium' | 'high';
}
export interface TokenUsageEvent extends BaseEvent, TokenUsageFields {
type: 'token_usage';
}
/**
* A user message injected into a running turn at a step boundary (steering).
* The runtime persists it as a user event in the ledger and echoes it through
* the stream so the transcript renders the interjection in place. `text` is the
* raw user text; the backend wraps it in a steering envelope for the model.
*/
export interface SteeringMessageEvent extends BaseEvent {
type: 'steering_message';
messageId: string;
content: MessageContent;
submittedContentDigest?: `sha256:${string}`;
}
/**
* Result of enqueuing a steering / followup message. `fallback` means there was
* no active run to attach to (the turn just ended) and the caller should open a
* fresh turn with the text instead, so a message is never silently dropped.
* Queue contents travel on ONE path only: the `queue_update` event.
*/
export type QueueEnqueueOutcome = { kind: 'queued' } | { kind: 'fallback' };
/**
* Authoritative queue snapshot pushed into the active turn's event stream
* whenever either pending queue changes (enqueue, step-boundary consumption, or
* interrupt clear). UI observers mirror it; the runtime owns the source of truth.
*/
export interface QueueUpdateEvent extends BaseEvent {
type: 'queue_update';
steering: string[];
followup: string[];
}
export type ProviderRetryReason =
| 'network'
| 'provider_unavailable'
| 'rate_limit'
| 'timeout'
| 'unknown';
/**
* Transient progress for a provider request that Runtime will retry.
*
* This event is intentionally not a durable conversation fact. `attempt`
* names the next/current physical request (2–10), while `maxAttempts`
* includes the first request.
*/
export type ProviderRetryEvent = ProviderRetryScheduledEvent | ProviderRetryStartedEvent;
export interface ProviderRetryScheduledEvent extends BaseEvent {
type: 'provider_retry';
phase: 'scheduled';
attempt: number;
maxAttempts: number;
delayMs: number;
reason: ProviderRetryReason;
}
export interface ProviderRetryStartedEvent extends BaseEvent {
type: 'provider_retry';
phase: 'started';
attempt: number;
maxAttempts: number;
reason: ProviderRetryReason;
}
export interface ErrorEvent extends BaseEvent {
type: 'error';
recoverable: boolean;
code?: string;
/** Stable machine-readable reason for UI / telemetry routing. */
reason?: string;
message: string;
/** Adapter MUST scrub secrets before populating this field. */
details?: string[] | Record<string, unknown>;
}
export interface CompleteEvent extends BaseEvent {
type: 'complete';
stopReason:
| 'end_turn'
| 'user_stop'
| 'error'
| 'plan_handoff'
| 'graph_yield'
| 'permission_handoff'
| 'step_limit'
| 'max_tokens'
| 'context_budget_exhausted';
/**
* Detail for `stopReason: 'context_budget_exhausted'` — the runtime could not
* produce a provider-safe request even after mid-turn compaction. A first-class
* outcome, not a provider context-length error.
*/
contextBudgetExhaustedDetail?: ContextBudgetExhaustedDetail;
}
export type ContextBudgetExhaustedDetail =
| 'no_safe_completed_span'
| 'summarizer_failed'
| 'head_anchor_exceeds_capacity';
export type CompleteStopReason = CompleteEvent['stopReason'];
/** Stable failure taxonomy for complete events that did not finish the turn. */
export function failureClassFromCompleteStopReason(
reason: CompleteStopReason,
): 'runtime_error' | 'tool_step_cap_reached' | 'context_budget_exhausted' | undefined {
if (reason === 'error') return 'runtime_error';
if (reason === 'step_limit') return 'tool_step_cap_reached';
if (reason === 'context_budget_exhausted') return 'context_budget_exhausted';
return undefined;
}
export interface AbortEvent extends BaseEvent {
type: 'abort';
reason: 'user_stop' | 'redirect' | 'timeout' | 'crash';
}
// ============================================================================
// UI → Backend commands
// ============================================================================
/**
* SessionCommand: commands that target a specific session.
*
* Connection-management commands live in ConnectionCommand (./connections.ts).
*
* `permission_response` composes PermissionResponse rather than flattening
* its fields, so there is exactly ONE shape for a permission decision in
* the codebase.
*/
export type AttachmentIngestItem =
| { approvalId: string; name: string; mimeType?: string }
| { name: string; mimeType?: string; base64: string };
export type SessionCommand =
| {
type: 'send';
turnId: string;
text: string;
attachmentItems?: AttachmentIngestItem[];
}
| { type: 'stop' }
| { type: 'permission_response'; response: PermissionResponse }
| {
type: 'plan_response';
planId: string;
action: 'approve' | 'refine';
feedback?: string;
};