/** * Typed fetch wrapper over the /sidebar JSON API. Every call posts to * `/sidebar/api/` with the sessionId and — when known — the session's * cwd from the client's own list summary. The host prefers its attached * session header and uses the summary cwd only while the session is still * hydrating at page load (a detached session would otherwise fail the * request). Failures surface as {@link SidebarApiError} with the wire code. */ import { encodeHtmlUrl } from '../html-route.ts' import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts' import type { SidebarCreateTeamTaskRequest, SidebarChildLiveView, SidebarSessionEvent, SidebarTeamMemberView, SidebarTeamTaskMutationResult, SidebarTeamTaskView, SidebarUpdateTeamTaskRequest, } from '../context-types.ts' import type { WorkflowRunView } from '../workflow-runs.ts' /** One wire failure. */ export class SidebarApiError extends Error { constructor( readonly code: string, message: string, ) { super(message) } } /** * Whether a wire failure is the workspace fence refusing a path outside the * session workspace (the host message reads `path "..." is outside * workspace`). The request-trust fence answers code `forbidden` with the * bare message 'forbidden', so the message fragment — not the code alone — * identifies this case. */ export function isOutsideWorkspaceError(error: unknown): boolean { return error instanceof SidebarApiError && isOutsideWorkspaceMessage(error.message) } /** Message-level variant for surfaces that stored the raw text (file-tree level errors). */ export function isOutsideWorkspaceMessage(message: string): boolean { return message.includes('outside workspace') } /** Explorer row (host fs-tree shape). */ export interface FsEntry { name: string path: string isDir: boolean hidden: boolean /** Whether the row is a symlink; `isDir` then describes the link's target. */ isSymlink: boolean /** For symlinks: the target is missing or unreadable (stat failed). */ broken: boolean } /** Git status entry (host git shape). */ export interface GitStatusEntry { path: string xy: string } /** Git status snapshot. */ export interface GitStatusResult { isRepo: boolean branch?: string entries: GitStatusEntry[] /** True when the host capped `entries` (huge untracked set); the panel * shows a truncation notice instead of freezing (#369). */ truncated?: boolean root?: string repositories?: string[] } /** One linked Git checkout. */ export interface GitWorktree { path: string branch: string current: boolean changes: number } /** One git log row. */ export interface GitLogEntry { /** Short hash (7+ chars, display). */ hash: string /** Full 40-char hash (advanced operations). */ hashFull: string subject: string author: string /** ISO 8601 author date (`%ai`). */ date: string /** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */ refs: string } /** Text read result. */ export interface FsTextResult { kind: 'text'; content: string; truncated: boolean } /** Binary read result (no content; images load through the media route). * `head` carries the first bytes (base64) for viewer detect sniffing. */ export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string } /** The `subagents.live` response: one row per tree child (and the root). */ export type SubagentLiveResult = { live: Record } /** The `workflows.list` response: the tree's folded workflow runs. */ export type WorkflowsListResult = { runs: WorkflowRunView[] } /** The `teams.view` response (structural degradation contract). */ export type TeamsViewResult = | { available: false } | { available: true; team: { members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] } | null } /** The `teams.taskCreate` request payload (minus the rootSessionId). */ export type TeamsTaskCreateRequest = SidebarCreateTeamTaskRequest /** The `teams.taskUpdate` request payload (minus the rootSessionId). */ export type TeamsTaskUpdateRequest = SidebarUpdateTeamTaskRequest /** The task-mutation result union (CAS conflict stays distinct). */ export type TeamsTaskMutationResult = SidebarTeamTaskMutationResult /** * Parse one `/sidebar` JSON response envelope into its value. A non-ok * status, an unparseable body, or any shape other than `{ok: true, value}` * surfaces as {@link SidebarApiError} carrying the wire code (falling back * to the HTTP status). Shared by the JSON api route and the raw upload * route, whose envelopes are identical. */ async function readEnvelope(response: Response): Promise { const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null = await response.json().catch(() => null) if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) { throw new SidebarApiError( parsed?.error?.code ?? 'http', parsed?.error?.message ?? `HTTP ${response.status}`, ) } return parsed.value as T } async function call(method: string, payload: Record, signal?: AbortSignal): Promise { let response: Response try { response = await fetch(`/sidebar/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal, }) } catch (error) { throw new SidebarApiError('network', error instanceof Error ? error.message : String(error)) } return readEnvelope(response) } /** * Upload one file to the sidebar's raw upload route: the File goes straight * into the POST body (no JSON/base64 re-encoding — the host streams it into * the workspace). Failure surfaces as {@link SidebarApiError} with the wire * code, exactly like every `/sidebar/api` call. An aborted `signal` rejects * with the DOMException as-is (the caller decides whether that is an error). */ async function fetchUpload( scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal, ): Promise { const params = new URLSearchParams({ sessionId: scope.sessionId, dir, relativePath }) if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd) let response: Response try { response = await fetch(`/sidebar/upload?${params.toString()}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body, signal, }) } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') throw error throw new SidebarApiError('network', error instanceof Error ? error.message : String(error)) } return readEnvelope(response) } /** One request's session scope: the conversation id plus its cwd when known. */ export interface SessionScope { sessionId: string /** The session's working directory from the client list summary (optional). */ cwd?: string /** Selected Git repository when cwd is a workspace container. */ repoRoot?: string } /** Fold a scope into a JSON payload ({cwd} only when present). */ function scopePayload(scope: SessionScope, extra: Record): Record { return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...(scope.repoRoot !== undefined && scope.repoRoot !== '' ? { repoRoot: scope.repoRoot } : {}), ...extra, } } /** Add a linked-worktree selection to a scoped Git request. The host validates * membership before using it as a command cwd. */ function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Record): Record { return scopePayload(scope, { ...(worktree !== undefined && worktree !== '' ? { worktree } : {}), ...extra }) } /** One external-open request from the file tree. */ type OpenExternalPayload = | { action: 'reveal'; path: string } | { action: 'url'; url: string } /** The host route's success shape. */ type OpenExternalResult = { started: boolean } /** * Remote VSCode-family URLs must be consumed on the browser/client machine: * the DSH host can be a headless remote server with no editor or DISPLAY. * Local editor URLs and reveal actions still belong to the host opener. */ function shouldOpenExternalOnClient(payload: OpenExternalPayload): payload is { action: 'url'; url: string } { if (payload.action !== 'url') return false let parsed: URL try { parsed = new URL(payload.url) } catch { return false } return parsed.protocol !== 'http:' && parsed.protocol !== 'https:' && parsed.hostname === 'vscode-remote' && parsed.pathname.startsWith('/ssh-remote+') } /** * Dispatch an external-open request to the correct machine. SSH remote-editor * URLs stay in the synchronous user-click chain and navigate the client so * its registered vscode:// / cursor:// handler can launch. Everything else * keeps using the DSH host route. */ function openExternal(payload: OpenExternalPayload): Promise { if (!shouldOpenExternalOnClient(payload)) { return call('open.external', payload) } try { window.location.assign(payload.url) return Promise.resolve({ started: true }) } catch (error) { return Promise.reject(error) } } /** The sidebar API surface (session scope threaded through every call). */ export const api = { sessionCwd: (scope: SessionScope, signal?: AbortSignal) => call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal), fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) => call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal), /** Global recursive file-name search rooted at the session cwd (the editor * side panel's search box); matches are cwd-relative '/'-separated paths. */ fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) => call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal), fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) => call('fs.read', scopePayload(scope, { path }), signal), fsWrite: (scope: SessionScope, path: string, content: string) => call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })), /** Rename one tree row within its directory (single-segment name; the * server refuses existing destinations, the workspace root, and — while * the fence is armed — anything resolving outside the workspace). */ fsRename: (scope: SessionScope, path: string, name: string) => call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })), /** Permanently delete one tree row (recursive for directories; a symlink * row unlinks the link only). The UI confirms before calling this. */ fsRemove: (scope: SessionScope, path: string) => call<{ path: string }>('fs.remove', scopePayload(scope, { path })), /** Upload one file's raw bytes into `dir` (keeps the folder tree via * `relativePath`); the host streams it under the session workspace. */ uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) => fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal), gitWorktrees: (scope: SessionScope, signal?: AbortSignal) => call('git.worktrees', scopePayload(scope, {}), signal), gitStatus: (scope: SessionScope, worktree?: string, signal?: AbortSignal) => call('git.status', gitPayload(scope, worktree, {}), signal), gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, worktree?: string, signal?: AbortSignal) => call<{ diff: string }>('git.diff', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}), staged }), signal), gitStage: (scope: SessionScope, path?: string, worktree?: string) => call<{ ok: true }>('git.stage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })), gitUnstage: (scope: SessionScope, path?: string, worktree?: string) => call<{ ok: true }>('git.unstage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })), gitCommit: (scope: SessionScope, message: string, worktree?: string) => call<{ ok: true }>('git.commit', gitPayload(scope, worktree, { message })), gitBranch: (scope: SessionScope, worktree?: string, signal?: AbortSignal) => call<{ current: string; names: string[] }>('git.branch', gitPayload(scope, worktree, {}), signal), gitCheckout: (scope: SessionScope, branch: string, worktree?: string) => call<{ ok: true }>('git.checkout', gitPayload(scope, worktree, { branch })), /** Recent commit history, lazily pageable (skip/count; defaults 0/30). */ gitLog: (scope: SessionScope, count?: number, skip?: number, worktree?: string, signal?: AbortSignal) => call('git.log', gitPayload(scope, worktree, { ...(count !== undefined ? { count } : {}), ...(skip !== undefined ? { skip } : {}), }), signal), /** Full patch text of one commit (diff display for the history rows). */ gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) => call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal), /** One file's content at a revision (`git show :`); null when the * revision has no such path. The diff views' on-demand hunk-fold expansion * reads both sides' full contents through this. */ gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) => call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal), /** The session's file-tool events for the changes tab's session lens: the * `tool/call` + `tool/result` rows past `afterSeq` (0 = whole window), * capped to the recent window host-side. The client runtime exposes no * event-log face, so the lens polls this delta route. */ changesOps: (scope: SessionScope, afterSeq?: number, signal?: AbortSignal) => call<{ events: SidebarSessionEvent[]; lastSeq: number }>('changes.ops', scopePayload(scope, { ...(afterSeq !== undefined && afterSeq > 0 ? { afterSeq } : {}), }), signal), /** Discard the worktree changes of one file (the index is untouched). */ gitDiscard: (scope: SessionScope, path: string, worktree?: string) => call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })), /** Revert one commit onto the current branch. */ gitRevert: (scope: SessionScope, hash: string, worktree?: string) => call<{ ok: true }>('git.revert', gitPayload(scope, worktree, { hash })), /** Cherry-pick one commit onto the current branch. */ gitCherryPick: (scope: SessionScope, hash: string, worktree?: string) => call<{ ok: true }>('git.cherry-pick', gitPayload(scope, worktree, { hash })), /** * One batch live-preview fetch for the whole Subagent tree. The payload is * the already-resolved topology ROOT (not a session scope); the host * enumerates descendants once and folds every child's newest process range * into the main agent's merged-activity summary. */ subagentsLive: (rootSessionId: string, signal?: AbortSignal) => call('subagents.live', { rootSessionId }, signal), /** * The workflow runs of the whole tree, folded host-side from the * `tool-workflow/*` session events (the same four types the official * workflow-run panel folds). Empty when the tree never ran a workflow — * absence is the normal case, never an error. */ workflowsList: (rootSessionId: string, signal?: AbortSignal) => call('workflows.list', { rootSessionId }, signal), /** * The team led by the tree's root agent (experimental Agent Teams layer): * `{available:false}` hides the whole block; `{team:null}` means the root * leads no team. Poll-driven (the official panel is manual-refresh too). */ teamsView: (rootSessionId: string, signal?: AbortSignal) => call('teams.view', { rootSessionId }, signal), /** Create one shared task on the root-led team. */ teamsTaskCreate: (rootSessionId: string, req: TeamsTaskCreateRequest) => call('teams.taskCreate', { rootSessionId, ...req }), /** CAS-mutate one shared task; conflicts ride the returned union. */ teamsTaskUpdate: (rootSessionId: string, req: TeamsTaskUpdateRequest) => call('teams.taskUpdate', { rootSessionId, ...req }), /** Create a Side Chat thread: a child session seeded with the parent's * full log up to now. Empty question = immediate create (Codex-style): * the thread opens empty, the first prompt carries the boundary. */ sidechatStart: (sessionId: string, question?: string) => call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }), /** Deliver one follow-up message to a Side Chat thread. */ sidechatPrompt: (childId: string, text: string) => call<{ accepted: true }>('sidechat.prompt', { childId, text }), /** Abort a Side Chat thread's running turn (queued work is preserved). */ sidechatCancel: (childId: string) => call<{ accepted: true }>('sidechat.cancel', { childId }), /** Release a Side Chat thread's live agent (history stays persisted). */ sidechatDispose: (childId: string) => call<{ accepted: true }>('sidechat.dispose', { childId }), /** Live state + agent identity (provider/model/preset) of a thread. */ sidechatInfo: (childId: string) => call('sidechat.info', { childId }), /** One transcript pull of a Side Chat thread: the thread's OWN events * (the inherited seed is cut host-side and never crosses the wire). * `afterSeq` narrows the response to the delta beyond it (poll tail). * `live` is the thread's in-flight model deltas (DSH 0.1.5 publishes them * outside the session log) — the CURRENT attempt on every pull, never a * delta, so the caller replaces its live set instead of appending. */ sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) => call<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>('sidechat.events', { childId, ...(afterSeq !== undefined ? { afterSeq } : {}), }, signal), /** Read the side card preferences (plugin-global, no session scope). */ settingsGet: () => call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}), /** Merge a patch into the side card preferences (revision-guarded). */ settingsUpdate: (patch: Record, expectedRevision?: number) => call<{ value?: unknown; revision?: number }>('settings.update', { patch, ...(expectedRevision !== undefined ? { expectedRevision } : {}), }), /** External open for the file tree's "open with" menu. Remote SSH editor * URLs are launched on the browser/client machine; reveal and local URLs * keep using the host's platform opener. */ openExternal, } /** Absolute URL of the media route for one path (images only). */ export function mediaUrl(scope: SessionScope, path: string): string { return fileUrl(scope, path, false) } /** Absolute URL of the download route: serves raw bytes (binary-safe) with * `Content-Disposition: attachment`, so the browser saves the file. */ export function downloadUrl(scope: SessionScope, path: string): string { return fileUrl(scope, path, true) } /** Shared URL builder for the /sidebar/file route (media vs download). */ function fileUrl(scope: SessionScope, path: string, download: boolean): string { const params = new URLSearchParams({ sessionId: scope.sessionId, path }) if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd) if (download) params.set('download', '1') return `/sidebar/file?${params.toString()}` } /** * Absolute URL of the HTML preview route (see html-route.ts): the path is * fully encoded so the previewed page's relative assets resolve back into * the same route with the session scope intact. The UNC marker is * platform-neutral — the host's requireAbsolute resolves the decoded * forward-slash `//server/share/...` form on both win32 and POSIX — so no * client-side platform signal is needed. */ export function htmlUrl(scope: SessionScope, path: string): string { return encodeHtmlUrl(scope.sessionId, path) }