mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
- add GET /api/v1/workspaces/{workspace_id}/skills backed by a listWorkspaceSkills core RPC and ISkillService.listForWorkDir, reusing the session skill-loading path so results match a new session
- web: populate the composer slash menu from workspace skills before a session exists, then fall back to session skills once one is active
1406 lines
47 KiB
TypeScript
1406 lines
47 KiB
TypeScript
// apps/kimi-web/src/api/daemon/client.ts
|
|
// DaemonKimiWebApi — implements KimiWebApi using the daemon REST + WS APIs.
|
|
|
|
import type { KimiApiConfig } from '../config';
|
|
import { buildRestUrl, buildWsUrl } from '../config';
|
|
import type {
|
|
AppConfig,
|
|
AppMessage,
|
|
AppMessageRole,
|
|
AppModel,
|
|
AppProvider,
|
|
ProviderRefreshResult,
|
|
AppSession,
|
|
AppSkill,
|
|
AppSessionCursor,
|
|
AppSessionRuntimeStatus,
|
|
AppSessionSnapshot,
|
|
AppSessionStatus,
|
|
AppTask,
|
|
AppTaskStatus,
|
|
AppTerminal,
|
|
AppWorkspace,
|
|
ApprovalResponse,
|
|
FsBrowseResult,
|
|
FsEntry,
|
|
KimiEventConnection,
|
|
KimiEventHandlers,
|
|
KimiWebApi,
|
|
Page,
|
|
PageRequest,
|
|
PromptSubmission,
|
|
PromptSubmitResult,
|
|
QuestionResponse,
|
|
} from '../types';
|
|
import { createAgentProjector } from './agentEventProjector';
|
|
import { DaemonHttpClient } from './http';
|
|
import {
|
|
toAppApprovalRequest,
|
|
toAppConfig,
|
|
toAppEvent,
|
|
toAppFsEntry,
|
|
toAppMessage,
|
|
toAppModel,
|
|
toAppProvider,
|
|
toAppQuestionRequest,
|
|
toAppSession,
|
|
toAppTask,
|
|
toWireApprovalResponse,
|
|
toWirePromptSubmission,
|
|
toWireQuestionResponse,
|
|
toWireSessionStatus,
|
|
toAppWorkspace,
|
|
wireEventSeq,
|
|
wireEventSessionId,
|
|
} from './mappers';
|
|
import type {
|
|
WireAuthResult,
|
|
WireBackgroundTask,
|
|
WireConfig,
|
|
WireEvent,
|
|
WireFileMeta,
|
|
WireFsBrowseResult,
|
|
WireFsEntry,
|
|
WireFsHomeResult,
|
|
WireMessage,
|
|
WireModel,
|
|
WireOAuthCancelResult,
|
|
WireOAuthLoginPollResult,
|
|
WireOAuthLoginStartResult,
|
|
WirePage,
|
|
WirePromptSubmitResult,
|
|
WirePromptSteerResult,
|
|
WireProvider,
|
|
WireProviderRefreshResult,
|
|
WireSession,
|
|
WireSessionAbortResult,
|
|
WireSessionWarning,
|
|
WireSessionWarningsResponse,
|
|
WireSessionRuntimeStatus,
|
|
WireSessionSnapshot,
|
|
WireWorkspace,
|
|
WireLogoutResult,
|
|
} from './wire';
|
|
import { DaemonEventSocket } from './ws';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wire response shapes for endpoints not in shared wire.ts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface WireHealth {
|
|
status: 'ok';
|
|
uptime_sec: number;
|
|
}
|
|
|
|
interface WireMeta {
|
|
server_version: string;
|
|
server_id: string;
|
|
started_at: string;
|
|
capabilities: Record<string, boolean>;
|
|
open_in_apps?: string[];
|
|
dangerous_bypass_auth?: boolean;
|
|
}
|
|
|
|
interface WireAbortResult {
|
|
aborted: boolean;
|
|
at_seq?: number;
|
|
}
|
|
|
|
interface WireDismissResult {
|
|
dismissed: boolean;
|
|
dismissed_at: string;
|
|
}
|
|
|
|
interface WireApprovalResolveResult {
|
|
resolved: true;
|
|
resolved_at: string;
|
|
}
|
|
|
|
interface WireQuestionResolveResult {
|
|
resolved: true;
|
|
resolved_at: string;
|
|
}
|
|
|
|
interface WireCancelResult {
|
|
cancelled: true;
|
|
}
|
|
|
|
interface WireSkillDescriptor {
|
|
name: string;
|
|
description: string;
|
|
path: string;
|
|
source: string;
|
|
type?: string;
|
|
disable_model_invocation?: boolean;
|
|
}
|
|
|
|
interface WireArchiveResult {
|
|
archived: true;
|
|
}
|
|
|
|
interface WireListDirectoryResult {
|
|
items: WireFsEntry[];
|
|
children_by_path?: Record<string, WireFsEntry[]>;
|
|
truncated: boolean;
|
|
}
|
|
|
|
interface WireReadFileResult {
|
|
path: string;
|
|
content: string;
|
|
encoding: 'utf-8' | 'base64';
|
|
size: number;
|
|
truncated: boolean;
|
|
etag: string;
|
|
mime: string;
|
|
language_id?: string;
|
|
line_count?: number;
|
|
is_binary: boolean;
|
|
}
|
|
|
|
interface WireSearchFilesResult {
|
|
items: Array<{
|
|
path: string;
|
|
name: string;
|
|
kind: 'file' | 'directory' | 'symlink';
|
|
score: number;
|
|
match_positions: number[];
|
|
}>;
|
|
truncated: boolean;
|
|
}
|
|
|
|
interface WireGrepFilesResult {
|
|
files: Array<{
|
|
path: string;
|
|
matches: Array<{
|
|
line: number;
|
|
col: number;
|
|
text: string;
|
|
before: string[];
|
|
after: string[];
|
|
}>;
|
|
}>;
|
|
files_scanned: number;
|
|
truncated: boolean;
|
|
elapsed_ms: number;
|
|
}
|
|
|
|
interface WireGitStatusResult {
|
|
branch: string;
|
|
ahead: number;
|
|
behind: number;
|
|
entries: Record<string, string>;
|
|
additions: number;
|
|
deletions: number;
|
|
pullRequest?: { number: number; state: string; url: string } | null;
|
|
}
|
|
|
|
interface WireDiffResult {
|
|
path: string;
|
|
diff: string;
|
|
}
|
|
|
|
interface WireTerminal {
|
|
id: string;
|
|
session_id: string;
|
|
cwd: string;
|
|
shell: string;
|
|
cols: number;
|
|
rows: number;
|
|
status: 'running' | 'exited';
|
|
created_at: string;
|
|
exited_at?: string;
|
|
exit_code?: number | null;
|
|
}
|
|
|
|
function toAppTerminal(data: WireTerminal): AppTerminal {
|
|
return {
|
|
id: data.id,
|
|
sessionId: data.session_id,
|
|
cwd: data.cwd,
|
|
shell: data.shell,
|
|
cols: data.cols,
|
|
rows: data.rows,
|
|
status: data.status,
|
|
createdAt: data.created_at,
|
|
exitedAt: data.exited_at,
|
|
exitCode: data.exit_code,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* historyCompacted reasons caused by compaction itself. These do NOT trigger a
|
|
* snapshot reload: the client keeps the visible scrollback and renders a
|
|
* divider marker instead. Every other reason (delta_gap, history_rewrite, …)
|
|
* still means "cached messages are stale" and goes through onResync.
|
|
*/
|
|
function isCompactionReason(reason: string): boolean {
|
|
return reason === 'auto_compact' || reason === 'manual_compact';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DaemonKimiWebApi
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class DaemonKimiWebApi implements KimiWebApi {
|
|
private readonly http: DaemonHttpClient;
|
|
private readonly config: KimiApiConfig;
|
|
|
|
constructor(config: KimiApiConfig) {
|
|
this.config = config;
|
|
this.http = new DaemonHttpClient(config.serverHttpUrl, {
|
|
clientId: config.clientId,
|
|
clientName: config.clientName,
|
|
clientVersion: config.clientVersion,
|
|
clientUiMode: config.clientUiMode,
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Health / Meta
|
|
// -------------------------------------------------------------------------
|
|
|
|
async getHealth(): Promise<{ status: 'ok'; uptimeSec: number }> {
|
|
// Real daemon returns { ok: true }; the older shape was { status, uptime_sec }.
|
|
const data = await this.http.get<Partial<WireHealth>>('/healthz');
|
|
return { status: 'ok', uptimeSec: data.uptime_sec ?? 0 };
|
|
}
|
|
|
|
async getMeta(): Promise<{
|
|
serverVersion: string;
|
|
serverId: string;
|
|
startedAt: string;
|
|
capabilities: Record<string, boolean>;
|
|
openInApps: string[];
|
|
dangerousBypassAuth: boolean;
|
|
}> {
|
|
const data = await this.http.get<WireMeta>('/meta');
|
|
return {
|
|
serverVersion: data.server_version,
|
|
serverId: data.server_id,
|
|
startedAt: data.started_at,
|
|
capabilities: data.capabilities,
|
|
openInApps: Array.isArray(data.open_in_apps) ? data.open_in_apps : [],
|
|
dangerousBypassAuth: data.dangerous_bypass_auth === true,
|
|
};
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Sessions
|
|
// -------------------------------------------------------------------------
|
|
|
|
async listSessions(
|
|
input?: PageRequest & {
|
|
status?: AppSessionStatus;
|
|
workspaceId?: string;
|
|
includeArchive?: boolean;
|
|
excludeEmpty?: boolean;
|
|
},
|
|
): Promise<Page<AppSession>> {
|
|
const query: Record<string, string | number | boolean | undefined> = {
|
|
before_id: input?.beforeId,
|
|
after_id: input?.afterId,
|
|
page_size: input?.pageSize,
|
|
status: input?.status ? toWireSessionStatus(input.status) : undefined,
|
|
include_archive: input?.includeArchive,
|
|
exclude_empty: input?.excludeEmpty,
|
|
// PRESUMED — daemon supports ?workspace_id= once the registry ships; it
|
|
// ignores unknown query params until then, so this is safe to always send.
|
|
workspace_id: input?.workspaceId,
|
|
};
|
|
const data = await this.http.get<WirePage<WireSession>>('/sessions', query);
|
|
return {
|
|
items: data.items.map(toAppSession),
|
|
hasMore: data.has_more,
|
|
};
|
|
}
|
|
|
|
async createSession(input: {
|
|
title?: string;
|
|
cwd?: string;
|
|
model?: string;
|
|
workspaceId?: string;
|
|
}): Promise<AppSession> {
|
|
// The real daemon requires `metadata` to be an object (rejects a missing
|
|
// metadata with 40001), so always send it — with cwd when provided.
|
|
const body: Record<string, unknown> = {
|
|
metadata: input.cwd !== undefined ? { cwd: input.cwd } : {},
|
|
};
|
|
// PRESUMED — daemon resolves cwd from workspace_id once the registry ships.
|
|
// We ALSO send metadata.cwd (above) as the fallback so today's daemon, which
|
|
// only understands cwd, still creates the session in the right folder.
|
|
if (input.workspaceId !== undefined) body['workspace_id'] = input.workspaceId;
|
|
if (input.title !== undefined) body['title'] = input.title;
|
|
if (input.model !== undefined) body['agent_config'] = { model: input.model };
|
|
const data = await this.http.post<WireSession>('/sessions', body);
|
|
return toAppSession(data);
|
|
}
|
|
|
|
// GET /sessions/{id} — fetch one session (deep links to sessions outside the
|
|
// first listSessions page).
|
|
async getSession(sessionId: string): Promise<AppSession> {
|
|
const data = await this.http.get<WireSession>(
|
|
`/sessions/${encodeURIComponent(sessionId)}`,
|
|
);
|
|
return toAppSession(data);
|
|
}
|
|
|
|
// The daemon has no PATCH on sessions; mutating title/metadata/agent_config
|
|
// (model + runtime controls) goes through POST /sessions/{id}/profile with a
|
|
// SessionUpdate body { title?, metadata?, agent_config? }. Runtime controls in
|
|
// agent_config are dispatched to the matching core RPCs (setModel/setThinking/
|
|
// setPermission/enterPlan|cancelPlan); the live values are read back from
|
|
// GET /sessions/{id}/status (the profile echo's agent_config can be stale/"").
|
|
async updateSession(
|
|
sessionId: string,
|
|
input: {
|
|
title?: string;
|
|
cwd?: string;
|
|
model?: string;
|
|
permissionMode?: string;
|
|
planMode?: boolean;
|
|
swarmMode?: boolean;
|
|
goalObjective?: string;
|
|
goalControl?: 'pause' | 'resume' | 'cancel';
|
|
thinking?: string;
|
|
},
|
|
): Promise<AppSession> {
|
|
const body: Record<string, unknown> = {};
|
|
if (input.title !== undefined) body['title'] = input.title;
|
|
if (input.cwd !== undefined) body['metadata'] = { cwd: input.cwd };
|
|
const agentConfig: Record<string, unknown> = {};
|
|
if (input.model !== undefined) agentConfig['model'] = input.model;
|
|
if (input.permissionMode !== undefined) agentConfig['permission_mode'] = input.permissionMode;
|
|
if (input.planMode !== undefined) agentConfig['plan_mode'] = input.planMode;
|
|
if (input.swarmMode !== undefined) agentConfig['swarm_mode'] = input.swarmMode;
|
|
if (input.goalObjective !== undefined) agentConfig['goal_objective'] = input.goalObjective;
|
|
if (input.goalControl !== undefined) agentConfig['goal_control'] = input.goalControl;
|
|
if (input.thinking !== undefined) agentConfig['thinking'] = input.thinking;
|
|
if (Object.keys(agentConfig).length > 0) body['agent_config'] = agentConfig;
|
|
const data = await this.http.post<WireSession>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/profile`,
|
|
body,
|
|
);
|
|
return toAppSession(data);
|
|
}
|
|
|
|
/**
|
|
* GET /sessions/{id}/status — the session's live runtime state (current model,
|
|
* thinking level, permission mode, plan flag, and context-window usage). This
|
|
* is the source of truth for the status line; Session.agent_config.model can
|
|
* be "" on the read path.
|
|
*/
|
|
async getSessionStatus(sessionId: string): Promise<AppSessionRuntimeStatus> {
|
|
const data = await this.http.get<WireSessionRuntimeStatus>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/status`,
|
|
);
|
|
return {
|
|
model: data.model && data.model.length > 0 ? data.model : null,
|
|
thinkingEffort: data.thinking_level,
|
|
permission: data.permission,
|
|
planMode: data.plan_mode === true,
|
|
swarmMode: data.swarm_mode === true,
|
|
contextTokens: data.context_tokens ?? 0,
|
|
maxContextTokens: data.max_context_tokens ?? 0,
|
|
contextUsage: data.context_usage ?? 0,
|
|
};
|
|
}
|
|
|
|
async getSessionWarnings(sessionId: string): Promise<WireSessionWarning[]> {
|
|
const data = await this.http.get<WireSessionWarningsResponse>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/warnings`,
|
|
);
|
|
return data.warnings ?? [];
|
|
}
|
|
|
|
async archiveSession(sessionId: string): Promise<{ archived: true }> {
|
|
const data = await this.http.post<WireArchiveResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}:archive`,
|
|
{},
|
|
);
|
|
return data;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Messages
|
|
// -------------------------------------------------------------------------
|
|
|
|
async listMessages(
|
|
sessionId: string,
|
|
input?: PageRequest & { role?: AppMessageRole },
|
|
): Promise<Page<AppMessage>> {
|
|
const query: Record<string, string | number | boolean | undefined> = {
|
|
before_id: input?.beforeId,
|
|
after_id: input?.afterId,
|
|
page_size: input?.pageSize,
|
|
role: input?.role,
|
|
};
|
|
const data = await this.http.get<WirePage<WireMessage>>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/messages`,
|
|
query,
|
|
);
|
|
return {
|
|
items: data.items.map(toAppMessage),
|
|
hasMore: data.has_more,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* v2 initial sync: atomic session state at an `as_of_seq` watermark.
|
|
* Rebuild flow: getSessionSnapshot() → seedSnapshot() → subscribe(cursor).
|
|
*/
|
|
async getSessionSnapshot(sessionId: string): Promise<AppSessionSnapshot> {
|
|
const data = await this.http.get<WireSessionSnapshot>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/snapshot`,
|
|
);
|
|
return {
|
|
asOfSeq: data.as_of_seq,
|
|
epoch: data.epoch,
|
|
session: toAppSession(data.session),
|
|
// Snapshot messages are already chronological ascending.
|
|
messages: data.messages.items.map(toAppMessage),
|
|
hasMoreMessages: data.messages.has_more,
|
|
inFlightTurn:
|
|
data.in_flight_turn === null
|
|
? null
|
|
: {
|
|
turnId: data.in_flight_turn.turn_id,
|
|
assistantText: data.in_flight_turn.assistant_text,
|
|
thinkingText: data.in_flight_turn.thinking_text,
|
|
runningTools: data.in_flight_turn.running_tools.map((t) => ({
|
|
toolCallId: t.tool_call_id,
|
|
name: t.name,
|
|
args: t.args,
|
|
description: t.description,
|
|
lastProgress: t.last_progress,
|
|
})),
|
|
promptId: data.in_flight_turn.current_prompt_id,
|
|
},
|
|
pendingApprovals: data.pending_approvals.map(toAppApprovalRequest),
|
|
pendingQuestions: data.pending_questions.map(toAppQuestionRequest),
|
|
};
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Prompt
|
|
// -------------------------------------------------------------------------
|
|
|
|
async submitPrompt(
|
|
sessionId: string,
|
|
input: PromptSubmission,
|
|
): Promise<PromptSubmitResult> {
|
|
const data = await this.http.post<WirePromptSubmitResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/prompts`,
|
|
toWirePromptSubmission(input),
|
|
);
|
|
return {
|
|
promptId: data.prompt_id,
|
|
userMessageId: data.user_message_id,
|
|
status: data.status,
|
|
};
|
|
}
|
|
|
|
// POST /sessions/{id}/prompts:steer — steer daemon-queued prompts into the
|
|
// active turn (TUI ctrl+s). Throws PROMPT_NOT_FOUND when there is no active
|
|
// turn anymore (the queued prompt then starts its own turn — callers may
|
|
// treat that as success).
|
|
async steerPrompts(
|
|
sessionId: string,
|
|
promptIds: string[],
|
|
): Promise<{ steered: boolean; promptIds: string[] }> {
|
|
const data = await this.http.post<WirePromptSteerResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/prompts:steer`,
|
|
{ prompt_ids: promptIds },
|
|
);
|
|
return { steered: data.steered, promptIds: data.prompt_ids };
|
|
}
|
|
|
|
async abortPrompt(
|
|
sessionId: string,
|
|
promptId: string,
|
|
): Promise<{ aborted: boolean; atSeq?: number }> {
|
|
const data = await this.http.post<WireAbortResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/prompts/${encodeURIComponent(promptId)}:abort`,
|
|
undefined,
|
|
{ allowCodes: [40903] },
|
|
);
|
|
// data.aborted is false when 40903 (prompt already completed) — that's correct
|
|
return { aborted: data.aborted, atSeq: data.at_seq };
|
|
}
|
|
|
|
// POST /sessions/{id}:abort — cancel whatever is running in the session,
|
|
// including skill activations that bypass IPromptService.
|
|
async abortSession(sessionId: string): Promise<{ aborted: boolean }> {
|
|
const data = await this.http.post<WireSessionAbortResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}:abort`,
|
|
{},
|
|
);
|
|
return { aborted: data.aborted };
|
|
}
|
|
|
|
// POST /sessions/{id}:compact — request history compaction. Returns {};
|
|
// progress and completion arrive via the WS compaction.* events (the
|
|
// transcript itself is not reloaded — a divider marker is appended).
|
|
async compactSession(sessionId: string, instruction?: string): Promise<void> {
|
|
await this.http.post(
|
|
`/sessions/${encodeURIComponent(sessionId)}:compact`,
|
|
instruction ? { instruction } : {},
|
|
);
|
|
}
|
|
|
|
// POST /sessions/{id}:undo — remove the last `count` turns from history. The
|
|
// response carries the resulting messages + status, but we re-sync the session
|
|
// afterwards for the authoritative (un-paginated) transcript, so we only need
|
|
// the call to succeed here.
|
|
async undoSession(sessionId: string, count = 1): Promise<void> {
|
|
await this.http.post(
|
|
`/sessions/${encodeURIComponent(sessionId)}:undo`,
|
|
{ count },
|
|
);
|
|
}
|
|
|
|
// POST /sessions/{id}:fork — fork the session into a new child session.
|
|
async forkSession(sessionId: string, input?: { title?: string }): Promise<AppSession> {
|
|
const body: Record<string, unknown> = {};
|
|
if (input?.title !== undefined) body['title'] = input.title;
|
|
const data = await this.http.post<WireSession>(
|
|
`/sessions/${encodeURIComponent(sessionId)}:fork`,
|
|
body,
|
|
);
|
|
return toAppSession(data);
|
|
}
|
|
|
|
// POST /sessions/{id}/children — create a child ("side chat") session. The
|
|
// daemon forks the parent (so the child inherits its context) and tags it with
|
|
// parent_session_id + child_session_kind.
|
|
async createChildSession(sessionId: string, input?: { title?: string }): Promise<AppSession> {
|
|
const body: Record<string, unknown> = {};
|
|
if (input?.title !== undefined) body['title'] = input.title;
|
|
const data = await this.http.post<WireSession>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/children`,
|
|
body,
|
|
);
|
|
return toAppSession(data);
|
|
}
|
|
|
|
// GET /sessions/{id}/children — list a session's child sessions.
|
|
async listChildSessions(sessionId: string): Promise<AppSession[]> {
|
|
const data = await this.http.get<WirePage<WireSession>>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/children`,
|
|
);
|
|
return data.items.map(toAppSession);
|
|
}
|
|
|
|
// POST /sessions/{id}:btw — start a TUI-style side-channel agent. Follow-up
|
|
// prompts use the returned agent_id on the normal /prompts route.
|
|
async startBtw(sessionId: string): Promise<{ agentId: string }> {
|
|
const data = await this.http.post<{ agent_id: string }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}:btw`,
|
|
{},
|
|
);
|
|
return { agentId: data.agent_id };
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Approval / Question
|
|
// -------------------------------------------------------------------------
|
|
|
|
async respondApproval(
|
|
sessionId: string,
|
|
approvalId: string,
|
|
response: ApprovalResponse,
|
|
): Promise<{ resolved: true; resolvedAt: string }> {
|
|
const data = await this.http.post<WireApprovalResolveResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(approvalId)}`,
|
|
toWireApprovalResponse(response),
|
|
);
|
|
return { resolved: data.resolved, resolvedAt: data.resolved_at };
|
|
}
|
|
|
|
async respondQuestion(
|
|
sessionId: string,
|
|
questionId: string,
|
|
response: QuestionResponse,
|
|
): Promise<{ resolved: true; resolvedAt: string }> {
|
|
const data = await this.http.post<WireQuestionResolveResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}`,
|
|
toWireQuestionResponse(response),
|
|
);
|
|
return { resolved: data.resolved, resolvedAt: data.resolved_at };
|
|
}
|
|
|
|
async dismissQuestion(
|
|
sessionId: string,
|
|
questionId: string,
|
|
): Promise<{ dismissed: true; dismissedAt: string }> {
|
|
const data = await this.http.post<WireDismissResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}:dismiss`,
|
|
undefined,
|
|
{ allowCodes: [40909] },
|
|
);
|
|
// 40909 means question.dismissed — that's the success path per spec
|
|
return { dismissed: true, dismissedAt: data.dismissed_at };
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Tasks
|
|
// -------------------------------------------------------------------------
|
|
|
|
async listTasks(sessionId: string, status?: AppTaskStatus): Promise<AppTask[]> {
|
|
const query: Record<string, string | undefined> = {
|
|
status: status,
|
|
};
|
|
const data = await this.http.get<{ items: WireBackgroundTask[] }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/tasks`,
|
|
query,
|
|
);
|
|
return data.items.map(toAppTask);
|
|
}
|
|
|
|
async getTask(
|
|
sessionId: string,
|
|
taskId: string,
|
|
input?: { withOutput?: boolean; outputBytes?: number },
|
|
): Promise<AppTask> {
|
|
const query: Record<string, string | number | boolean | undefined> = {
|
|
with_output: input?.withOutput,
|
|
output_bytes: input?.outputBytes,
|
|
};
|
|
const data = await this.http.get<WireBackgroundTask>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}`,
|
|
query,
|
|
);
|
|
return toAppTask(data);
|
|
}
|
|
|
|
async cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }> {
|
|
const data = await this.http.post<WireCancelResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}:cancel`,
|
|
);
|
|
return data;
|
|
}
|
|
|
|
async listTerminals(sessionId: string): Promise<AppTerminal[]> {
|
|
const data = await this.http.get<{ items: WireTerminal[] }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/terminals`,
|
|
);
|
|
return data.items.map(toAppTerminal);
|
|
}
|
|
|
|
async createTerminal(
|
|
sessionId: string,
|
|
input: { cwd?: string; shell?: string; cols?: number; rows?: number } = {},
|
|
): Promise<AppTerminal> {
|
|
const body: Record<string, unknown> = {
|
|
cwd: input.cwd,
|
|
shell: input.shell,
|
|
cols: input.cols,
|
|
rows: input.rows,
|
|
};
|
|
const data = await this.http.post<WireTerminal>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/terminals`,
|
|
body,
|
|
);
|
|
return toAppTerminal(data);
|
|
}
|
|
|
|
async getTerminal(sessionId: string, terminalId: string): Promise<AppTerminal> {
|
|
const data = await this.http.get<WireTerminal>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}`,
|
|
);
|
|
return toAppTerminal(data);
|
|
}
|
|
|
|
async closeTerminal(sessionId: string, terminalId: string): Promise<{ closed: true }> {
|
|
return this.http.post<{ closed: true }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/terminals/${encodeURIComponent(terminalId)}:close`,
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Skills — slash-invocable skills (session- or workspace-scoped)
|
|
// GET /sessions/{id}/skills → { skills: WireSkillDescriptor[] }
|
|
// GET /workspaces/{id}/skills → { skills: WireSkillDescriptor[] } (no session)
|
|
// POST /sessions/{id}/skills/{name}:activate body { args? } → { activated, skill_name }
|
|
// -------------------------------------------------------------------------
|
|
|
|
async listSkills(sessionId: string): Promise<AppSkill[]> {
|
|
const data = await this.http.get<{ skills: WireSkillDescriptor[] }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/skills`,
|
|
);
|
|
return (data.skills ?? []).map((s) => ({
|
|
name: s.name,
|
|
description: s.description,
|
|
source: s.source,
|
|
}));
|
|
}
|
|
|
|
async listSkillsForWorkspace(workspaceId: string): Promise<AppSkill[]> {
|
|
const data = await this.http.get<{ skills: WireSkillDescriptor[] }>(
|
|
`/workspaces/${encodeURIComponent(workspaceId)}/skills`,
|
|
);
|
|
return (data.skills ?? []).map((s) => ({
|
|
name: s.name,
|
|
description: s.description,
|
|
source: s.source,
|
|
}));
|
|
}
|
|
|
|
async activateSkill(
|
|
sessionId: string,
|
|
skillName: string,
|
|
args?: string,
|
|
): Promise<{ activated: true; skillName: string }> {
|
|
const data = await this.http.post<{ activated: true; skill_name: string }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/skills/${encodeURIComponent(skillName)}:activate`,
|
|
args !== undefined && args.length > 0 ? { args } : {},
|
|
);
|
|
return { activated: data.activated, skillName: data.skill_name };
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// File System
|
|
// -------------------------------------------------------------------------
|
|
|
|
async listDirectory(
|
|
sessionId: string,
|
|
input: { path?: string; depth?: number; includeGitStatus?: boolean },
|
|
): Promise<{
|
|
items: FsEntry[];
|
|
childrenByPath?: Record<string, FsEntry[]>;
|
|
truncated: boolean;
|
|
}> {
|
|
const body: Record<string, unknown> = {};
|
|
if (input.path !== undefined) body['path'] = input.path;
|
|
if (input.depth !== undefined) body['depth'] = input.depth;
|
|
if (input.includeGitStatus !== undefined) body['include_git_status'] = input.includeGitStatus;
|
|
const data = await this.http.post<WireListDirectoryResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:list`,
|
|
body,
|
|
);
|
|
const childrenByPath = data.children_by_path
|
|
? Object.fromEntries(
|
|
Object.entries(data.children_by_path).map(([k, v]) => [k, v.map(toAppFsEntry)]),
|
|
)
|
|
: undefined;
|
|
return {
|
|
items: data.items.map(toAppFsEntry),
|
|
childrenByPath,
|
|
truncated: data.truncated,
|
|
};
|
|
}
|
|
|
|
async readFile(
|
|
sessionId: string,
|
|
input: { path: string; offset?: number; length?: number },
|
|
): Promise<{
|
|
path: string;
|
|
content: string;
|
|
encoding: 'utf-8' | 'base64';
|
|
size: number;
|
|
truncated: boolean;
|
|
etag: string;
|
|
mime: string;
|
|
languageId?: string;
|
|
lineCount?: number;
|
|
isBinary: boolean;
|
|
}> {
|
|
const body: Record<string, unknown> = { path: input.path };
|
|
if (input.offset !== undefined) body['offset'] = input.offset;
|
|
if (input.length !== undefined) body['length'] = input.length;
|
|
const data = await this.http.post<WireReadFileResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:read`,
|
|
body,
|
|
);
|
|
return {
|
|
path: data.path,
|
|
content: data.content,
|
|
encoding: data.encoding,
|
|
size: data.size,
|
|
truncated: data.truncated,
|
|
etag: data.etag,
|
|
mime: data.mime,
|
|
languageId: data.language_id,
|
|
lineCount: data.line_count,
|
|
isBinary: data.is_binary,
|
|
};
|
|
}
|
|
|
|
async searchFiles(
|
|
sessionId: string,
|
|
input: { query: string; limit?: number },
|
|
): Promise<{
|
|
items: Array<{
|
|
path: string;
|
|
name: string;
|
|
kind: 'file' | 'directory' | 'symlink';
|
|
score: number;
|
|
matchPositions: number[];
|
|
}>;
|
|
truncated: boolean;
|
|
}> {
|
|
const body: Record<string, unknown> = { query: input.query };
|
|
if (input.limit !== undefined) body['limit'] = input.limit;
|
|
const data = await this.http.post<WireSearchFilesResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:search`,
|
|
body,
|
|
);
|
|
return {
|
|
items: data.items.map((item) => ({
|
|
path: item.path,
|
|
name: item.name,
|
|
kind: item.kind,
|
|
score: item.score,
|
|
matchPositions: item.match_positions,
|
|
})),
|
|
truncated: data.truncated,
|
|
};
|
|
}
|
|
|
|
async grepFiles(
|
|
sessionId: string,
|
|
input: { pattern: string; regex?: boolean; caseSensitive?: boolean },
|
|
): Promise<{
|
|
files: Array<{
|
|
path: string;
|
|
matches: Array<{
|
|
line: number;
|
|
col: number;
|
|
text: string;
|
|
before: string[];
|
|
after: string[];
|
|
}>;
|
|
}>;
|
|
filesScanned: number;
|
|
truncated: boolean;
|
|
elapsedMs: number;
|
|
}> {
|
|
const body: Record<string, unknown> = { pattern: input.pattern };
|
|
if (input.regex !== undefined) body['regex'] = input.regex;
|
|
if (input.caseSensitive !== undefined) body['case_sensitive'] = input.caseSensitive;
|
|
const data = await this.http.post<WireGrepFilesResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:grep`,
|
|
body,
|
|
);
|
|
return {
|
|
files: data.files,
|
|
filesScanned: data.files_scanned,
|
|
truncated: data.truncated,
|
|
elapsedMs: data.elapsed_ms,
|
|
};
|
|
}
|
|
|
|
async getGitStatus(
|
|
sessionId: string,
|
|
paths?: string[],
|
|
): Promise<{ branch: string; ahead: number; behind: number; entries: Record<string, string>; additions: number; deletions: number; pullRequest: { number: number; state: string; url: string } | null }> {
|
|
const body: Record<string, unknown> = {};
|
|
if (paths !== undefined) body['paths'] = paths;
|
|
const data = await this.http.post<WireGitStatusResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:git_status`,
|
|
body,
|
|
);
|
|
return {
|
|
branch: data.branch,
|
|
ahead: data.ahead,
|
|
behind: data.behind,
|
|
entries: data.entries,
|
|
additions: data.additions,
|
|
deletions: data.deletions,
|
|
pullRequest: data.pullRequest ?? null,
|
|
};
|
|
}
|
|
|
|
async getFileDiff(
|
|
sessionId: string,
|
|
path: string,
|
|
): Promise<{ path: string; diff: string }> {
|
|
const data = await this.http.post<WireDiffResult>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:diff`,
|
|
{ path },
|
|
);
|
|
return { path: data.path, diff: data.diff };
|
|
}
|
|
|
|
getFileDownloadUrl(sessionId: string, path: string): string {
|
|
const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/');
|
|
return buildRestUrl(
|
|
this.config.serverHttpUrl,
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs/${encodedPath}:download`,
|
|
);
|
|
}
|
|
|
|
async openFile(
|
|
sessionId: string,
|
|
input: { path: string; line?: number },
|
|
): Promise<{ opened: true }> {
|
|
const body: Record<string, unknown> = { path: input.path };
|
|
if (input.line !== undefined) body['line'] = input.line;
|
|
return this.http.post<{ opened: true }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:open`,
|
|
body,
|
|
);
|
|
}
|
|
|
|
async revealFile(
|
|
sessionId: string,
|
|
input: { path: string },
|
|
): Promise<{ revealed: true }> {
|
|
return this.http.post<{ revealed: true }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:reveal`,
|
|
{ path: input.path },
|
|
);
|
|
}
|
|
|
|
async openInApp(
|
|
sessionId: string,
|
|
appId: string,
|
|
path: string,
|
|
line?: number,
|
|
): Promise<void> {
|
|
const body: Record<string, unknown> = { app_id: appId, path };
|
|
if (line !== undefined) body['line'] = line;
|
|
await this.http.post<{ opened: true }>(
|
|
`/sessions/${encodeURIComponent(sessionId)}/fs:open-in`,
|
|
body,
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Workspaces + daemon folder browser
|
|
// PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home.
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* List the registered workspaces.
|
|
* PRESUMED — GET /api/v1/workspaces. On 404/empty/error this returns [] and
|
|
* the composable DERIVES workspaces from the current sessions' cwds. So the
|
|
* switcher + grouping work immediately off existing sessions until the daemon
|
|
* ships the registry.
|
|
*/
|
|
async listWorkspaces(): Promise<AppWorkspace[]> {
|
|
try {
|
|
const data = await this.http.get<WirePage<WireWorkspace>>('/workspaces');
|
|
return (data.items ?? []).map(toAppWorkspace);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a workspace by folder path.
|
|
* PRESUMED — POST /api/v1/workspaces { root, name? }. Throws on error (e.g.
|
|
* path not found) so the caller can surface it to the user.
|
|
*/
|
|
async addWorkspace(input: { root: string; name?: string }): Promise<AppWorkspace> {
|
|
const body: Record<string, unknown> = { root: input.root };
|
|
if (input.name !== undefined) body['name'] = input.name;
|
|
const data = await this.http.post<WireWorkspace>('/workspaces', body);
|
|
return toAppWorkspace(data);
|
|
}
|
|
|
|
/**
|
|
* Remove a registered workspace.
|
|
* PRESUMED — DELETE /api/v1/workspaces/:id. On error this throws.
|
|
*/
|
|
async deleteWorkspace(id: string): Promise<void> {
|
|
await this.http.delete(`/workspaces/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
/**
|
|
* Rename a workspace (display name only).
|
|
* PATCH /api/v1/workspaces/:id { name }. On error this throws.
|
|
*/
|
|
async updateWorkspace(id: string, input: { name: string }): Promise<AppWorkspace> {
|
|
const data = await this.http.patch<WireWorkspace>(
|
|
`/workspaces/${encodeURIComponent(id)}`,
|
|
{ name: input.name },
|
|
);
|
|
return toAppWorkspace(data);
|
|
}
|
|
|
|
/**
|
|
* Browse directories under `path` (defaults to $HOME on the daemon).
|
|
* PRESUMED — GET /api/v1/fs:browse?path=. On error returns an empty path so
|
|
* the picker can distinguish "browse failed" from "directory has no children".
|
|
*/
|
|
async browseFs(path?: string): Promise<FsBrowseResult> {
|
|
try {
|
|
const data = await this.http.get<WireFsBrowseResult>('/fs:browse', { path });
|
|
return {
|
|
path: data.path,
|
|
parent: data.parent,
|
|
entries: (data.entries ?? []).map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
isDir: e.is_dir,
|
|
isGitRepo: e.is_git_repo,
|
|
branch: e.branch,
|
|
})),
|
|
};
|
|
} catch {
|
|
return { path: '', parent: null, entries: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the picker start directory + recently-used roots.
|
|
* PRESUMED — GET /api/v1/fs:home. On error returns empty defaults.
|
|
*/
|
|
async getFsHome(): Promise<{ home: string; recentRoots: string[] }> {
|
|
try {
|
|
const data = await this.http.get<WireFsHomeResult>('/fs:home');
|
|
return { home: data.home, recentRoots: data.recent_roots ?? [] };
|
|
} catch {
|
|
return { home: '', recentRoots: [] };
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Models + Providers
|
|
// PRESUMED — not in current daemon docs; isolated here, swap when backend defines them.
|
|
// -------------------------------------------------------------------------
|
|
|
|
async listModels(): Promise<AppModel[]> {
|
|
// PRESUMED endpoint: GET /v1/models → { items: WireModel[] }
|
|
const data = await this.http.get<{ items: WireModel[] }>('/models');
|
|
return data.items.map(toAppModel);
|
|
}
|
|
|
|
async listProviders(): Promise<AppProvider[]> {
|
|
// PRESUMED endpoint: GET /v1/providers → { items: WireProvider[] }
|
|
const data = await this.http.get<{ items: WireProvider[] }>('/providers');
|
|
return data.items.map(toAppProvider);
|
|
}
|
|
|
|
async addProvider(input: {
|
|
type: string;
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
defaultModel?: string;
|
|
}): Promise<AppProvider> {
|
|
// PRESUMED endpoint: POST /v1/providers → WireProvider
|
|
const body: Record<string, unknown> = { type: input.type };
|
|
if (input.apiKey !== undefined) body['api_key'] = input.apiKey;
|
|
if (input.baseUrl !== undefined) body['base_url'] = input.baseUrl;
|
|
if (input.defaultModel !== undefined) body['default_model'] = input.defaultModel;
|
|
const data = await this.http.post<WireProvider>('/providers', body);
|
|
return toAppProvider(data);
|
|
}
|
|
|
|
async deleteProvider(id: string): Promise<{ deleted: true }> {
|
|
// PRESUMED endpoint: DELETE /v1/providers/{id} → { deleted: true }
|
|
return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
async refreshProvider(id: string): Promise<ProviderRefreshResult> {
|
|
const data = await this.http.post<WireProviderRefreshResult>(
|
|
`/providers/${encodeURIComponent(id)}:refresh`,
|
|
);
|
|
return toProviderRefreshResult(data);
|
|
}
|
|
|
|
async refreshAllProviders(): Promise<ProviderRefreshResult> {
|
|
const data = await this.http.post<WireProviderRefreshResult>('/providers:refresh');
|
|
return toProviderRefreshResult(data);
|
|
}
|
|
|
|
async refreshOAuthProviderModels(): Promise<ProviderRefreshResult> {
|
|
const data = await this.http.post<WireProviderRefreshResult>('/providers:refresh_oauth');
|
|
return toProviderRefreshResult(data);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Config — REAL endpoints
|
|
// -------------------------------------------------------------------------
|
|
|
|
async getConfig(): Promise<AppConfig> {
|
|
const data = await this.http.get<WireConfig>('/config');
|
|
return toAppConfig(data);
|
|
}
|
|
|
|
async setConfig(patch: Partial<AppConfig>): Promise<AppConfig> {
|
|
const wirePatch: Record<string, unknown> = {};
|
|
const keyMap: Record<keyof AppConfig, string> = {
|
|
providers: 'providers',
|
|
defaultProvider: 'default_provider',
|
|
defaultModel: 'default_model',
|
|
models: 'models',
|
|
thinking: 'thinking',
|
|
planMode: 'plan_mode',
|
|
yolo: 'yolo',
|
|
defaultPermissionMode: 'default_permission_mode',
|
|
defaultPlanMode: 'default_plan_mode',
|
|
permission: 'permission',
|
|
hooks: 'hooks',
|
|
services: 'services',
|
|
mergeAllAvailableSkills: 'merge_all_available_skills',
|
|
extraSkillDirs: 'extra_skill_dirs',
|
|
loopControl: 'loop_control',
|
|
background: 'background',
|
|
experimental: 'experimental',
|
|
telemetry: 'telemetry',
|
|
raw: 'raw',
|
|
};
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
const wireKey = keyMap[key as keyof AppConfig];
|
|
if (wireKey !== undefined) {
|
|
wirePatch[wireKey] = value;
|
|
}
|
|
}
|
|
const data = await this.http.post<WireConfig>('/config', wirePatch);
|
|
return toAppConfig(data);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Auth — REAL endpoints
|
|
// -------------------------------------------------------------------------
|
|
|
|
async getAuth(): Promise<{
|
|
ready: boolean;
|
|
providersCount: number;
|
|
defaultModel: string | null;
|
|
managedProvider: { status: string } | null;
|
|
}> {
|
|
const data = await this.http.get<WireAuthResult>('/auth');
|
|
return {
|
|
ready: data.ready,
|
|
providersCount: data.providers_count,
|
|
defaultModel: data.default_model,
|
|
managedProvider: data.managed_provider
|
|
? { status: data.managed_provider.status }
|
|
: null,
|
|
};
|
|
}
|
|
|
|
async startOAuthLogin(): Promise<{
|
|
flowId: string;
|
|
provider: string;
|
|
verificationUri: string;
|
|
verificationUriComplete: string;
|
|
userCode: string;
|
|
expiresIn: number;
|
|
interval: number;
|
|
status: 'pending';
|
|
expiresAt: string;
|
|
}> {
|
|
const data = await this.http.post<WireOAuthLoginStartResult>('/oauth/login', {});
|
|
return {
|
|
flowId: data.flow_id,
|
|
provider: data.provider,
|
|
verificationUri: data.verification_uri,
|
|
verificationUriComplete: data.verification_uri_complete,
|
|
userCode: data.user_code,
|
|
expiresIn: data.expires_in,
|
|
interval: data.interval,
|
|
status: data.status,
|
|
expiresAt: data.expires_at,
|
|
};
|
|
}
|
|
|
|
async pollOAuthLogin(): Promise<{
|
|
flowId: string;
|
|
status: 'pending' | 'authenticated' | 'expired' | 'cancelled';
|
|
resolvedAt?: string;
|
|
} | null> {
|
|
// data may be null if no flow is active
|
|
const data = await this.http.get<WireOAuthLoginPollResult | null>('/oauth/login');
|
|
if (!data) return null;
|
|
return {
|
|
flowId: data.flow_id,
|
|
status: data.status,
|
|
resolvedAt: data.resolved_at,
|
|
};
|
|
}
|
|
|
|
async cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }> {
|
|
const data = await this.http.delete<WireOAuthCancelResult>('/oauth/login');
|
|
return { cancelled: data.cancelled, status: data.status };
|
|
}
|
|
|
|
async logout(): Promise<{ loggedOut: boolean }> {
|
|
const data = await this.http.post<WireLogoutResult>('/oauth/logout', {});
|
|
return { loggedOut: data.logged_out };
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// File upload
|
|
// -------------------------------------------------------------------------
|
|
|
|
async uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }> {
|
|
const formData = new FormData();
|
|
formData.append('file', input.file, input.name ?? (input.file instanceof File ? input.file.name : 'upload'));
|
|
if (input.name !== undefined) {
|
|
formData.append('name', input.name);
|
|
}
|
|
const data = await this.http.postForm<WireFileMeta>('/files', formData);
|
|
return {
|
|
id: data.id,
|
|
name: data.name,
|
|
mediaType: data.media_type,
|
|
size: data.size,
|
|
};
|
|
}
|
|
|
|
getFileUrl(fileId: string): string {
|
|
return buildRestUrl(this.config.serverHttpUrl, `/files/${encodeURIComponent(fileId)}`);
|
|
}
|
|
|
|
/** Fetch a file's bytes with the Bearer credential attached. Use this (not
|
|
* getFileUrl) when the bytes feed a <video>/<img> src: the browser loads
|
|
* those natively without the Authorization header, so the URL alone 401s. */
|
|
async getFileBlob(fileId: string): Promise<Blob> {
|
|
return this.http.getBlob(`/files/${encodeURIComponent(fileId)}`);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// WebSocket events
|
|
// -------------------------------------------------------------------------
|
|
|
|
connectEvents(handlers: KimiEventHandlers): KimiEventConnection {
|
|
const wsUrl = buildWsUrl(this.config.serverHttpUrl, this.config.clientId);
|
|
|
|
// Per-session projector for raw agent-core events.
|
|
// Keyed by session_id; reset when a session is re-subscribed or resynced.
|
|
const projector = createAgentProjector();
|
|
|
|
const socket = new DaemonEventSocket(wsUrl, this.config.clientId, {
|
|
// -----------------------------------------------------------------------
|
|
// Projected "event.*" frames — existing path (kept working for stub / spec)
|
|
// -----------------------------------------------------------------------
|
|
onWireEvent: (wireEvent: WireEvent) => {
|
|
const sessionId = wireEventSessionId(wireEvent);
|
|
const seq = wireEventSeq(wireEvent);
|
|
const appEvent = toAppEvent(wireEvent);
|
|
|
|
// Route history_compacted to onResync so the client reloads messages —
|
|
// EXCEPT for compaction itself: the transcript keeps the scrollback and
|
|
// the reducer appends a divider marker instead (reloading would replace
|
|
// the visible conversation with the compacted model context).
|
|
if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) {
|
|
handlers.onResync(appEvent.sessionId, appEvent.beforeSeq);
|
|
// Still dispatch the event to onEvent so the reducer can update lastSeqBySession
|
|
}
|
|
|
|
// Deliver the AppEvent together with wire-level seq/session so the
|
|
// reducer can advance lastSeqBySession[sessionId] = seq.
|
|
handlers.onEvent(appEvent, { sessionId, seq });
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Raw agent-core frames — client-side projection path (real daemon)
|
|
// -----------------------------------------------------------------------
|
|
onRawAgentEvent: (frame) => {
|
|
const { type, seq, session_id: sessionId, payload, offset } = frame;
|
|
const appEvents = projector.project(type, payload, sessionId, { offset });
|
|
for (const appEvent of appEvents) {
|
|
// historyCompacted from the projector is either a compaction signal
|
|
// (reason auto_compact — no reload, the divider marker handles it) or
|
|
// a delta-gap recovery (reason delta_gap — a real resync, routed to
|
|
// onResync with the real frame.seq, mirroring the protocol path).
|
|
if (appEvent.type === 'historyCompacted' && !isCompactionReason(appEvent.reason)) {
|
|
handlers.onResync(sessionId, seq);
|
|
}
|
|
handlers.onEvent(appEvent, { sessionId, seq });
|
|
}
|
|
},
|
|
|
|
onResync: (sessionId: string, currentSeq: number, epoch?: string) => {
|
|
// Reset per-session projector state on resync
|
|
projector.reset(sessionId);
|
|
handlers.onResync(sessionId, currentSeq, epoch);
|
|
},
|
|
|
|
onConnectionState: (connected: boolean) => {
|
|
handlers.onConnectionChange(connected);
|
|
},
|
|
|
|
onError: (code: number, msg: string, fatal: boolean) => {
|
|
handlers.onError(code, msg, fatal);
|
|
},
|
|
|
|
onTerminalOutput: (sessionId, terminalId, data, seq) => {
|
|
handlers.onTerminalOutput?.(sessionId, terminalId, data, seq);
|
|
},
|
|
|
|
onTerminalExit: (sessionId, terminalId, exitCode) => {
|
|
handlers.onTerminalExit?.(sessionId, terminalId, exitCode);
|
|
},
|
|
});
|
|
|
|
socket.connect();
|
|
|
|
return {
|
|
subscribe(sessionId: string, cursor?: AppSessionCursor): void {
|
|
// Do NOT reset projector state here: every sidebar click re-subscribes
|
|
// the (possibly running) session, and a reset wipes the turn/prompt
|
|
// bindings — the remainder of an in-flight turn would be dropped on
|
|
// the floor. The projector starts sessions fresh on first sight, and
|
|
// onResync (below) resets explicitly before messages are reloaded.
|
|
socket.subscribe(sessionId, cursor ?? { seq: 0 });
|
|
},
|
|
unsubscribe(sessionId: string): void {
|
|
socket.unsubscribe(sessionId);
|
|
},
|
|
seedSnapshot(sessionId: string, snapshot: AppSessionSnapshot): void {
|
|
// Rebuild the projector's mid-turn state from the snapshot. The
|
|
// resulting AppEvents (running status + partially-streamed assistant
|
|
// message) flow through the SAME onEvent path as live events, so the
|
|
// rendering layer needs no special handling. When there is no
|
|
// in-flight turn we only reset, so stale turn state can't leak into
|
|
// the freshly-loaded message list.
|
|
if (snapshot.inFlightTurn === null) {
|
|
projector.reset(sessionId);
|
|
return;
|
|
}
|
|
const appEvents = projector.seedInFlight(sessionId, snapshot.inFlightTurn);
|
|
for (const appEvent of appEvents) {
|
|
handlers.onEvent(appEvent, { sessionId, seq: snapshot.asOfSeq });
|
|
}
|
|
},
|
|
bindNextPromptId(sessionId: string, promptId: string): void {
|
|
// Wire the real daemon prompt_id into the projector so turn.started
|
|
// uses it instead of a synthetic ulid('pr_'). Without this, the
|
|
// synthetic id propagates to session.currentPromptId and the REST
|
|
// :abort endpoint never matches the daemon's real prompt_id.
|
|
projector.bindNextPromptId(sessionId, promptId);
|
|
},
|
|
abort(sessionId: string, promptId: string): void {
|
|
socket.abort(sessionId, promptId);
|
|
},
|
|
terminalAttach(sessionId: string, terminalId: string, sinceSeq?: number): void {
|
|
socket.terminalAttach(sessionId, terminalId, sinceSeq);
|
|
},
|
|
terminalInput(sessionId: string, terminalId: string, data: string): void {
|
|
socket.terminalInput(sessionId, terminalId, data);
|
|
},
|
|
terminalResize(sessionId: string, terminalId: string, cols: number, rows: number): void {
|
|
socket.terminalResize(sessionId, terminalId, cols, rows);
|
|
},
|
|
terminalDetach(sessionId: string, terminalId: string): void {
|
|
socket.terminalDetach(sessionId, terminalId);
|
|
},
|
|
terminalClose(sessionId: string, terminalId: string): void {
|
|
socket.terminalClose(sessionId, terminalId);
|
|
},
|
|
markSideChannelAgent(agentId: string): void {
|
|
projector.markSideChannelAgent(agentId);
|
|
},
|
|
close(): void {
|
|
socket.close();
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function toProviderRefreshResult(data: WireProviderRefreshResult): ProviderRefreshResult {
|
|
return {
|
|
changed: data.changed.map((item) => ({
|
|
providerId: item.provider_id,
|
|
providerName: item.provider_name,
|
|
added: item.added,
|
|
removed: item.removed,
|
|
})),
|
|
unchanged: data.unchanged,
|
|
failed: data.failed,
|
|
};
|
|
}
|