- 回退对 harness peek 缝隙的依赖:输出查看不再调用注册表,改为从属主 会话事件日志回放 task_output 的 tool/call+tool/result 对(模型已读内容) - read:false 时面板提示「模型尚未读取」;'(no new output)' 增量与错误 结果过滤;截断上限不变;kill 走注册表原生 API(缺席时 503) - AGENTS.md 新增 §0 硬约束:禁止修改 DSH 源码、挂载只走 cordis.patch.yml + profile 机制;设计文档同步实施偏差 - 测试:tasks-routes 重写为回放语义(拼接/过滤/read 标志/截断/503), 视图新增 read:false 提示用例;全量 369 通过
216 lines
9.6 KiB
TypeScript
216 lines
9.6 KiB
TypeScript
/**
|
|
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
|
|
* `/sidebar/api/<method>` 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 { BrowserProbeResult } from './browser.ts'
|
|
|
|
/** One wire failure. */
|
|
export class SidebarApiError extends Error {
|
|
constructor(
|
|
readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(message)
|
|
}
|
|
}
|
|
|
|
/** Explorer row (host fs-tree shape). */
|
|
export interface FsEntry {
|
|
name: string
|
|
path: string
|
|
isDir: boolean
|
|
hidden: 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[]
|
|
}
|
|
|
|
/** 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 }
|
|
|
|
/**
|
|
* One tasks.output response: the output the MODEL has read so far for the
|
|
* task (replayed from the owner session's event log — the model's
|
|
* task_output cursor is never touched, so the pane can never steal the
|
|
* agent's bytes). `read` is false until the model actually called
|
|
* task_output for the task.
|
|
*/
|
|
export interface TaskOutputResult {
|
|
text: string
|
|
/** True when the host capped the text at its output limit. */
|
|
truncated: boolean
|
|
/** Whether the model has read the task at least once. */
|
|
read: boolean
|
|
}
|
|
|
|
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
|
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))
|
|
}
|
|
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
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** Fold a scope into a JSON payload ({cwd} only when present). */
|
|
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
|
|
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
|
|
}
|
|
|
|
/** 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),
|
|
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
|
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
|
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
|
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
|
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
|
|
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
|
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
|
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
|
|
gitStage: (scope: SessionScope, path?: string) =>
|
|
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
|
gitUnstage: (scope: SessionScope, path?: string) =>
|
|
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
|
|
gitCommit: (scope: SessionScope, message: string) =>
|
|
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
|
|
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
|
|
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
|
|
gitCheckout: (scope: SessionScope, branch: string) =>
|
|
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
|
|
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
|
|
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
|
|
call<GitLogEntry[]>('git.log', scopePayload(scope, {
|
|
...(count !== undefined ? { count } : {}),
|
|
...(skip !== undefined ? { skip } : {}),
|
|
}), signal),
|
|
/** Full patch text of one commit (diff display for the history rows). */
|
|
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
|
|
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
|
|
/** Discard the worktree changes of one file (the index is untouched). */
|
|
gitDiscard: (scope: SessionScope, path: string) =>
|
|
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
|
|
/** Revert one commit onto the current branch. */
|
|
gitRevert: (scope: SessionScope, hash: string) =>
|
|
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
|
|
/** Cherry-pick one commit onto the current branch. */
|
|
gitCherryPick: (scope: SessionScope, hash: string) =>
|
|
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
|
|
/** Release a terminal's process immediately (tab closed; the WS close frame
|
|
* may be unreachable while the socket is down, so the host also accepts
|
|
* this explicit route). */
|
|
ptyClose: (scope: SessionScope, tab: string) =>
|
|
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
|
|
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
|
agentPtyClose: (uuid: string) =>
|
|
call<{ ok: true }>('agent-pty.close', { uuid }),
|
|
/**
|
|
* The output the model has read so far for one background task (replayed
|
|
* from the owner session's event log — never the model's task_output
|
|
* cursor). The scope MUST be the task's OWNER session.
|
|
*/
|
|
taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
|
call<TaskOutputResult>('tasks.output', scopePayload(scope, { id }), signal),
|
|
/** Request cancellation of one background task (live tasks flip to stopping). */
|
|
taskKill: (scope: SessionScope, id: string, reason?: string) =>
|
|
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('tasks.kill', scopePayload(scope, {
|
|
id,
|
|
...(reason !== undefined ? { reason } : {}),
|
|
})),
|
|
/** Read the side card preferences (plugin-global, no session scope). */
|
|
settingsGet: () =>
|
|
call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
|
/** Merge a patch into the side card preferences (revision-guarded). */
|
|
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
|
call<{ value?: unknown; revision?: number }>('settings.update', {
|
|
patch,
|
|
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
|
}),
|
|
/** Probe a URL's response headers (the sidebar browser's embeddability
|
|
* check; see the host's browser.probe route). */
|
|
browserProbe: (url: string, signal?: AbortSignal) =>
|
|
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
|
}
|
|
|
|
/** 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. */
|
|
export function htmlUrl(scope: SessionScope, path: string): string {
|
|
return encodeHtmlUrl(scope.sessionId, path)
|
|
}
|