所有端点都挂在 /session-navigator/api 前缀下,由 lib/index.js:624 的 ROUTES 表定义,
lib/index.js:2195 的 handle() 分发。共 77 条。
// 成功
{ "ok": true, "result": <任意 JSON> }
// 失败
{ "ok": false, "error": "给人看的消息", "code": "给机器分支的稳定码" }
code 是契约,error 的文案随时可以改。Cache-Control: no-store。bad-request(lib/http.js:57-79)。{}。四条路由不写信封,它们自己接管响应:GET /bridge/events(SSE)、GET /export(文件)、
GET /ui*(静态资源)。handle() 在 headersSent 后不再追加信封(lib/index.js:2218)。
四份侧车文档各自带单调递增的 revision。写入时客户端声明”我基于哪个版本”:
| 写法 | 例子 |
|---|---|
| HTTP 头 | If-Match: "12"(引号可有可无,空标签 "" 表示不表态) |
| body 字段 | { "revision": 12, ... } |
弱标签 W/"12" 按 12 读——它声明的就是那个版本,只是允许内容被等价改写;* 与空标签
一样表示不表态,回落到 body。给了不是版本号的值(If-Match: abc、1, 2)是 400
bad-request,而不是悄悄当没给——否则一个写错的头会变成”最后写入者赢”。
两者都不给 = 最后写入者赢(单窗口用户的预期)。给了但落后 = 409 revision-conflict,
客户端应重新拉取后重试。实现见 lib/index.js:1932(读期望值)与 lib/store/sidecar.js(比对)。
数据目录迁移期间(api.migration !== null),除 /datadir* 之外的所有非 GET/HEAD 请求
一律 409 migration-in-progress(lib/index.js:2208)。否则写入会落进正在被复制的旧目录。
| 前缀 | 含义 | 例子 |
|---|---|---|
f- |
固定节点 | f-root / f-all / f-favorites / f-todos / f-trash / f-ungrouped |
w- |
DSH workspace 的投影节点 | w-<workspaceId> |
d- |
用户文件夹 | d-k3f9ab12 |
s- |
快捷方式 | s-k3f9ab34 |
| 无前缀 | 原生 id(会话 / workspace) | 01J… |
f- 与 w- / d- 由 host 生成;快捷方式的 targetId 永远是目标自己那套 id 空间里的 id
(文件夹 d-…、workspace 去掉 w-、会话原样),这样解析时不用猜。
lib/index.js:2310(statusFor)。DSH 原生的命名空间码原样透出,不翻译。
| code | 状态 | 含义 |
|---|---|---|
bad-request / gateway/bad-request |
400 | 参数缺失或形状不对 |
session/title-invalid / workspace/name-conflict |
400 | DSH 拒绝的标题 / 重名 |
subagent-unsupported |
400 | 子智能体会话不能单独操作:它跟随父会话(见 §3.2) |
session/not-found / workspace/not-found / trash-not-found |
404 | 目标不存在 |
component-not-installed |
404 | 浏览器组件未就绪 |
target-exists / session-active / session-corrupt |
409 | 目标已存在 / 会话在跑 / 日志损坏 |
workspace/session-active |
409 | workspace 里还有在跑的会话 |
revision-conflict |
409 | 乐观锁失败 |
datadir-same / datadir-not-empty / migration-in-progress |
409 | 数据目录迁移的三种拒绝 |
component-busy / component-in-use |
409 | 组件正在被别的操作占用 / 窗口开着不能删 |
nothing-pending |
409 | 一键补齐时所有会话都已经有描述和标签了(这不是错误,是”没活干”) |
ai/no-fields |
409 | 模型没被要求写任何字段——三个开关全关了,调用没有意义 |
nothing-to-apply |
409 | 点了「应用」但记录里那个字段是空的 |
too-many |
413 | 一次要求生成的会话超过 MAX_BATCH = 500 |
llm/MISSING_CREDENTIAL / llm/INVALID_CREDENTIAL / llm/AUTH |
401 | 模型服务拒绝了这个 profile 的凭据 |
llm/RATE_LIMIT / llm/QUOTA / llm/ACCOUNT_QUOTA |
429 | 被限速或额度用完 |
llm/CONTEXT_WINDOW_EXCEEDED / llm/empty / llm/tool-only / llm/no-finish |
422 | 会话太长 / 模型两次都没吐字 / 模型只想调用工具(请求里并没有工具) / 流没有以 stop 结束 |
ai/bad-answer |
422 | 两次尝试都没给出要的 JSON 对象(或缺少要的字段),回答已丢弃、没有写半成品 |
nothing-to-summarize |
422 | 这个会话没有可读的对话(只剩注入上下文或空日志) |
llm-unavailable |
501 | 该 profile 没有 llm 服务或没有可用模型 |
confirm-required |
400 | 清除数据没拿到 DELETE,一个字节都没动(§4.12) |
reset-refused |
409 | 数据目录不可用(根目录 / ~/.dsh 或它的祖先),既然是清空就更不能动(§4.12) |
component-disk-full |
507 | 磁盘不足 |
disk-full |
507 | 同上的通用码(写文件时 ENOSPC) |
io-error |
500 | 数据目录读不了(ENOENT/EACCES/EIO… 被翻译成这个,不再冒充 bad-request) |
move-incomplete / move-rollback-failed |
500 | 跨卷拷贝不完整、回滚没做完——会话没有被删,见 move.js |
update-unreachable |
502 | 连不上 GitHub,或答复不是 release JSON(更新检查,见 §4.11) |
open-failed |
502 | 系统浏览器拉不起来(Star / Issue 链接) |
capability-missing |
501 | 该 profile 缺这个服务 |
cross-site |
403 | 跨站写请求(Sec-Fetch-Site: cross-site) |
unsupported-media-type |
415 | 写请求没用 application/json |
其它(含 ui-asset-missing) |
500 | 未预期的服务端故障 |
路由挂在 DSH 自己的端口上,没有 token,所以浏览器里任何一个页面都能往这里 POST——
响应它读不到,但 POST /trash/empty 已经生效了。lib/index.js 的 assertSameOrigin()
用两个浏览器自己加、脚本伪造不了的信号堵住这条路(只对 GET/HEAD 以外的请求生效):
Sec-Fetch-Site: cross-site → 403 cross-site;Content-Type 不是 application/json → 415 unsupported-media-type。JSON 不是
CORS 的”简单内容类型”,跨站带这个头发请求会先触发预检,而 DSH 不应答预检。插件自己的前端一直带 content-type: application/json; charset=utf-8,所以不受影响。
用 curl 手工调写接口要显式加这个头:
curl -X POST -H 'content-type: application/json' -d '{"ids":["s1"]}' \
http://127.0.0.1:63334/session-navigator/api/trash
没有 Sec-Fetch-Site 的请求(curl、脚本、本机工具)照常放行——它们不是跨站浏览器请求。
子智能体是父会话一次委派的产物,DSH 仍然把它写成完整的会话目录,但它不是用户可以
单独处置的会话。规则集中在 lib/domain/subagents.js,路由侧只有一个入口
assertStandalone(api, ids)(lib/index.js:1828):
| 操作 | 子智能体 | 为什么 |
|---|---|---|
| 读 / 打开 / 导出 / 显示位置 | 允许 | 只读 |
| 删除、还原、清空 | 拒绝;由父会话级联 | 父会话删除时子会话一起走(withDescendants),单独删会留下孤儿委派 |
| 剪切 / 复制 / 移动 | 拒绝 | 搬走子会话等于把它提升成独立会话,父会话的委派历史就断了 |
| 收藏 / 待办 / 重命名 | 拒绝 | 这些字段描述”用户的会话”,而子会话属于父会话的那次运行 |
| 归档 | 拒绝;由父会话派生 | sessions.js 的 archivedUp 让子会话跟着祖先的归档态,写一条 per-child 记录会让”这次运行归档了”变成 N 条事实 |
| 派生(fork) | 拒绝 | 分叉是给会话开副本,子会话没有可开的东西 |
isSubagent 由 header 决定(origin === "subagent",或 origin === null && delegationDepth > 0),
不是 parentSession 非空:fork 也带 parentSession,但它是用户要的独立会话,所有操作都正常。
客户端在 session-state 的响应里拿到同一个 subagent 位,据此隐藏按钮、只读行菜单,并给行
加 bound-row/chk-bound 标记——隐藏是礼貌,上面这张表才是规则。
| 路由 | body / query | 返回 |
|---|---|---|
GET /bootstrap |
?refresh=1 跳过 TTL |
见下 |
GET /settings |
— | { settings, revision, capabilities, dataDir, version } |
PUT /settings |
局部设置文档 | { settings, revision } |
GET /theme |
— | null 或 { mode, tokens, at } |
POST /theme |
{ mode?, tokens? } |
同 GET /theme |
GET /bootstrap(lib/index.js:631)是面板唯一的一次往返,字段:
{
"hello": { "plugin": "dsh-session-navigator", "schema": 1 },
"capabilities":{ /* 见 lib/capabilities.js */ },
"workspaces": { "workspaces": [ { id, title, path, sessionIds, createdAt, updatedAt } ] },
"sessions": [ /* 合并视图 + home + homeOverride */ ],
"trashed": [ /* 回收站条目,按删除时间倒序 */ ],
"tree": { /* 节点表 + sessionHome + sessionPlacement + pins + quickAccess + revision */ },
"tags": { /* 使用索引:tag → 会话 id 列表 */ },
"tagLibrary": [ /* 共享标签库(创建序)+ 仅被使用的标签 */ ],
"favorites": [ /* 会话 id */ ],
"todos": [ /* 会话 id */ ],
"settings": { /* 归一化后的完整设置文档 */ },
"revisions": { "tree": 7, "annotations": 3, "settings": 1 },
"stats": { /* 扫描统计 */ },
"scannedAt": 1730000000000
}
sessions[] 的 home 是它显示在哪个容器节点里,homeOverride 表示这是侧车的虚拟放置而非
DSH 自己的归属(lib/index.js:1808)。两者都由 host 算好,客户端不重放规则。
一个字段不是 DSH 给的,是插件自己从日志里读出来的(lib/sources/logfacts.js):
| 字段 | 含义 | 从哪来 |
|---|---|---|
blank |
从未开始过一轮对话的会话 | 日志里没有 turn/start。DSH 的 projcache 也记这个,但它只覆盖 DSH 自己列过的会话;插件走目录扫描,所以以日志为准,读不到时回落到 projcache,两者都没有就是 null(未知,不隐藏) |
读取成本:blank 只在日志较小时读全文(≤256 KB),较大的日志只读头部 64 KB 的几帧,
读不到就报未知而不是报空。结果按 path|size|mtime 缓存,所以只有正在写的那个会话会被重读。
日志只回答这一个问题。会话是否正在跑、某一轮是否被切断,插件都不推断——DSH 自己在内存里
只有一个 running(packages/api/session-controller/src/list.ts:120),面板报同一个答案
(列表的状态阶梯见 §5.1),没有 interrupted 这种自造字段。
更新检查、缓存清理和三个项目链接在设置页的「关于」组里,单独记在 §4.11。
GET /settings 单独存在,是因为设置页挂在面板之外,不该为渲染一个表单付一次全量扫描。
PUT /settings 收局部文档({ shell: { modal: { dim: 0.2 } } } 合法),未知键被
mergeSettings 丢掉而不是落盘。
| 路由 | body | 返回 |
|---|---|---|
GET /tags |
— | { tags, library } |
POST /tags |
{ tag } |
{ tag, library, revision } |
DELETE /tags/:tag |
— | { tag, library, revision } |
删除库里的标签不会把它从会话上摘掉——那些会话仍然带着它,只是不再出现在候选列表的库段。
| 路由 | body | 返回 |
|---|---|---|
PATCH /annotations/:id |
{ tags?, note?, favorite?, todo? } |
{ id, annotation, revision, tags, favorites, todos } |
GET /session-state/:id |
— | { id, favorite, todo, archived, trashed } |
PATCH 的 annotation 在该条目已被清空时是 null——注解不存”空壳”,一旦没有任何信息就整条删除。
:id 必须是合法会话 id(isAnnotationId:非空、≤ 上限、不在 __proto__ / constructor /
prototype 里、只含 [A-Za-z0-9._:-]),否则 400 session id is not a valid id。注解文档用会话 id
当键,__proto__ 会写原型而不是条目——补丁静默消失、接口却回报成功,所以这一层在路由和
patchAnnotation 里各挡一次。
GET /session-state/:id 是给 DSH 自己的会话标题栏和行菜单用的:它们没有 bootstrap 快照,
只想知道三个布尔。trashed 有两个来源(本插件的回收站索引,或 DSH 报告的 trashed),
任一为真即”不在”(lib/domain/session-state.js:32)。
| 路由 | body / query | 返回 |
|---|---|---|
POST /folders |
{ name, parent? } |
{ id, folder, revision } |
PATCH /folders/:id |
{ name } |
{ id, folder, revision } |
DELETE /folders/:id |
?lift=0 拒绝而非上提 |
{ ...结果, revision } |
POST /shortcuts |
{ kind, targetId, parent?, label? } |
{ id, shortcut, revision } |
DELETE /shortcuts/:id |
— | { removed, revision } |
POST /nodes/:id/pin |
{ pinned?: boolean } |
{ nodeId, pinned, revision } |
POST /nodes/:id/quick-access |
{ quickAccess?: boolean } |
{ nodeId, quickAccess, revision } |
POST /quick-access |
{ ids }(整条顺序,首行在前) |
{ order, quickAccess, revision } |
POST /nodes/:id/move |
{ parent } |
{ nodeId, parent, revision } |
POST /sessions/placement |
{ ids, parent? } |
{ ids, parent, revision, sessionPlacement } |
DELETE /folders/:id 默认把内容上提到父节点(lift 默认 1);?lift=0 时若文件夹非空
则拒绝。同时会连带删掉指向该文件夹的快捷方式——指向已删节点的行比没有这行更糟。POST /shortcuts 的 kind ∈ {folder, workspace, session},永远是引用,绝不移动目标。POST /nodes/:id/move 先过 canMove()(lib/domain/tree.js),不合法直接带规则码抛错。POST /sessions/placement 只写侧车的 sessionPlacement,不动文件:DSH 从 header 的 cwd
推导会话归属,改它意味着搬 payload。parent 省略 = 清除覆盖。POST /nodes/:id/quick-access 只写侧车的 quickAccess[id](quickAccess: false 删掉它),只有真实容器能当书签
(isBookmarkable:文件夹还在的 d-…,或 w-…)。视图、收藏 / 待办、快捷方式都不是「地方」——它们在左栏
已经是一行。它和 POST /nodes/:id/pin 是两件事:pin 是在本来的带子里排前面,quick-access 是同一容器多一个
入口(共用一张表时,置顶一个文件夹会把它从 目录 里挪走)。POST /quick-access 一次写整条带子。加一个(把容器拖到带上)和重排(把一个书签拖到另一个上)是同一句话
「现在就是这个顺序」,所以没有「移到第 N 位」这种单行接口。不合法或已消失的 id 被跳过而不是 400(手势来自
一棵活的树,一条过期行不该作废整个动作),返回的 order 只含真正写进去的行;已经在带里但没出现在 ids 里的
书签保留原序号,排到新写的下面。| 路由 | body / query | 返回 |
|---|---|---|
POST /sessions/:id/rename |
{ title } |
{ sessionId, title, seq } |
POST /sessions/:id/archive |
{ stopActivity?: boolean } |
{ archivedSessionIds } |
POST /sessions/:id/unarchive |
— | { archivedSessionIds } |
POST /sessions/:id/pin |
— | { pinnedSessionIds } |
POST /sessions/:id/unpin |
— | { pinnedSessionIds } |
POST /sessions/:id/fork |
{ atSeq?: number } |
{ sessionId, parentSessionId } |
POST /sessions/:id/log-path |
— | { path, name } |
POST /sessions/:id/reveal-file |
— | { path, opened } |
POST /move |
{ ids, target: { workspaceId }, copy? } |
{ results } |
POST /trash |
{ ids } |
{ trashed, skipped, errors } |
POST /restore |
{ ids } |
{ restored, skipped, errors } |
POST /purge |
{ ids } |
{ purged, skipped } |
POST /trash/empty |
— | { purged } |
GET /export |
?sessionId&format=md\|jsonl&download=1 |
文件本体 |
前六个都是DSH 自己的概念,走 DSH 的服务(lib/host/actions.js),插件不落盘:
session/title 事件并压制自动标题生成;随后显式 flush 投影缓存,
否则新标题最多要等 5 秒(缓存默认 200 事件 / 5 秒节流)。stopActivity: true。fork 省略 atSeq 时与官方默认一致:从最后一个完成的回合继续。subagent-unsupported。/trash、/restore、/purge、/move、/copy、/annotate、
/archive、/title、/fork)先过 assertStandalone:ids 里只要有一个子智能体就整单拒绝
subagent-unsupported,不做”跳过后继续”。规则在 lib/domain/subagents.js,理由在
§3.2;/trash、/restore、/purge 接受父会话后由 withDescendants 把整条委派链一起带走。
POST /move(lib/host/move.js)是唯一会改会话文件的操作:重写 header 的 cwd + 搬目录copy: true 写第二份独立会话(新 id)而不是移动。有会话在跑时拒绝(session-active)——
DSH 还握着旧路径的写句柄,从底下挪走会损坏尾部。POST /sessions/:id/log-path 是”复制文件路径”:只解析不打开,把会话日志的绝对路径交给面板写剪贴板。
回收站里的会话也答得出来(走索引),所以它和 reveal-file 共用同一套解析、却不碰操作系统。
POST /sessions/:id/reveal-file 是”跳转至文件位置”:在资源管理器/Finder 里选中该会话的日志文件本身,
不是它所在的目录。客户端只送 sessionId,路径由 host 解析(sessionLogFile):活着的会话取快照里的
logPath,已进回收站的会话取 <dataDir>/trash/<trashId>/<原文件名>(回收站索引记着
originalLogPath)。两处都查不到就 session-not-found,不做猜测。它和 POST /reveal-path 分开,
是因为后者的白名单只认目录。
Windows 上还多两件事,都是踩过的坑(lib/host/actions.js):
C:/x/y 交给资源管理器的 Explore 找不到窗口,交给 explorer.exe 会打开”文档”。
所以只在实际调系统时把 / 换成 \(nativePath),返回值仍是调用方给的原样路径。ShowWindow+SwitchToThisWindow+
SetForegroundWindow,再用 GetForegroundWindow() 比对句柄,最后输出一个状态词:
opened(新建并到前台)/ focused(复用已有窗口并到前台)/ behind(存在但没抢到前台)/
activated(只能按标题激活,未验证)/ none(根本没窗口)。面板按这个词决定提示文案,
behind 会明说”窗口没有到前台”,不再拿”已打开”糊弄。explorer.exe /select,:这条路在本机不通——正斜杠被 shell 直接拒绝(E_INVALIDARG),
而命令行交接走 DDE,DSH 进程是提权的(WindowsPrincipal.IsInRole(Administrator) 为真)驱动不了
非提权的资源管理器,于是它退回去打开”文档”。原生 API 把这两个坑一起绕开。三级顺序(lib/host/actions.js 的 revealOnWindows,前一级成功就不再往下走):
lib/host/native-reveal.js):SHOpenFolderAndSelectItems(shell32)。这就是
资源管理器”在文件夹中显示”和 Electron shell.showItemInFolder 用的那个调用——由 shell 自己开窗
并置前,实测 42–57 ms(进程内第一次 155 ms),不产生任何外部进程。Node 没有内置 FFI,所以经
koffi(Node-API 预编译绑定,不需要编译器)调用;绑定缺失、平台不是 Windows、或调用失败时
该模块返回 null/{ok:false},自动落到下一级——缺绑定只损失速度,不损失功能。lib/host/reveal-agent.js):Shell.Application.Explore + 前台验证,冷启动
约 1.8 s,之后每次 130–180 ms。只有在第 1 级不可用时才会在面板打开时预热。openWorkspacePath:最后兜底。反过来做过(先 DSH),结果是没有桌面宿主的构建里它会正常
resolve 而什么也不做——面板提示”已打开”,桌面上却没有窗口。DSH 的返回值不能证明窗口出现过。POST /sessions/:id/reveal-dir 是”在文件管理器中显示”:打开会话的工作目录 cwd;会话没记过
cwd 时退到日志所在目录。同样只收 sessionId,不让调用方自己给路径。
move 是逐条的:一条搬不动(session-active、目标目录被占、payload 中途消失)不会取消
其余,results 里每条是 { sessionId, ok, code?, message?, ... }。两种例外是整单错误:
目标 workspace 解析不了时,一条都不动(bad-request / workspace-not-found);
全部失败时把第一条错误抛出来,避免回一个全是 ok: false 的 200——客户端只看信封的
code,看不了逐条结果。
POST /trash 是真移动:会话目录离开 DSH 的 sessions 树,所以 DSH 自己的侧栏里也会消失。
同样是逐条的:trashed 是搬走的,skipped 是本来就在回收站或没有日志的,errors 是搬失败
的(session-active 等),三者合起来正好是请求的 id 集合。并发删除同一会话时,先在文档锁里
认领条目(pending: true)再搬文件,后到者看见条目已存在就记 skipped,不会各写一个 trashId
再把对方的条目录掉。
恢复按 original → workspace → ungrouped 三级阶梯落地(见 data-directory.md)。
POST /restore 是逐条的:某一条恢复不了(目标路径又被占了、payload 中途消失)不会取消整批,
失败的那条进 errors: [{ sessionId, code, message }] 并同时计入 skipped,其余照常恢复。
GET /export 对回收站里的会话返回 session/not-found——payload 已经不在 DSH 的树里了,
回收站只留一份描述。导出容忍正在写入的尾部(readArtifact(…, { tolerateTail: true })):
最后一个不完整的帧或半行被丢掉,前面的完整事件照常导出。读来重写的路径(move/trash)不能
容忍——它必须保留每一个字节的去向。
文件名按码点截断到 80(Array.from(...).slice(0, 80)),不是 slice(0, 80):后者按 UTF-16
切,标题末尾是 emoji 时会切出孤立代理项,encodeURIComponent 随即抛 URIError,一次导出变成 500。
导出的是说过的全部内容:用户的消息、助手的回复、工具调用与它们的输出。三类东西被当成
“没人说过的话”去掉,并在文件头部逐类写出条数,方便读者对账:DSH 每轮注入的上下文(user/message
里 data.source.kind 不是 user 的那些:时间戳、运行上下文、技能目录、审批提示……)、系统提示、
developer/message 的工具清单变更。用户消息没有 source 字段时按老日志处理,算用户说的。
surfaceOp 与压缩只报告,不过滤。 真实日志里 surfaceOp 是字符串 "append" 或
{op: "replace", startSeq, endSeq}(含端点),DSH 压缩一次长会话会把整段——实测一次 4094 个、
一次 4638 个事件,其中 480/499 次工具调用——换成一个 source.kind = "compact-checkpoint" 的摘要
消息。若按掩码把这段丢掉,一次 26 条用户消息、1142 次工具调用的会话只导出 5 条消息与 163 次调用,
正是”导出不全”的来源。所以导出保留被替换的片段、在片段起点写一行 ⚠ 注记、把摘要按
## 上下文压缩摘要(标注”由 DSH 自动生成,不是用户原话”)单独成段,并在头部报出被替换与被剪
(compaction/prune 的 shadowedSeqs)的事件数。maskedSeqs 因此只用来打注记,不再当过滤器;
它的范围有上界(MAX_MASKED_RANGE = 100_000),日志里写 to: 1000000000 不会循环十亿次。
助手消息的内容在 data.message.content 里(不是 data.content),工具调用既有 block 又有独立的
tool/call 事件,按 callId 去重;时间戳读顶层 time(不是 at)。读错任何一条都会让整类
内容静默消失。
Markdown 默认带工具调用与输出,单条输出超过 toolResultLimit(4000 字符)时截断并注明”完整输出见
JSONL 导出”;JSONL 一条回合一行,参数与输出全量。思考过程(reasoning)两种格式都默认不写。
文件名按码点截断到 80(Array.from(...).slice(0, 80)),不是 slice(0, 80):后者按 UTF-16
切,标题末尾是 emoji 时会切出孤立代理项,encodeURIComponent 随即抛 URIError,一次导出变成 500。
渲染时不认识的 block 类型不会让整条消息消失:它被写成一个可读的占位块,所以一条只含图片或 只含工具结果的助手消息仍然出现在 md 与 jsonl 里。
| 路由 | body | 返回 |
|---|---|---|
POST /workspaces/:id/rename |
{ title } |
{ workspaceId, title } |
POST /workspaces/:id/delete |
— | { workspaceId, deleted } |
POST /workspaces/:id/path |
— | { path, name } |
POST /workspaces/:id/reveal |
— | { path, opened } |
POST /reveal-path |
{ path } |
{ path, opened } |
删除 workspace 只删登记:目录和所有会话日志都在,会话回落到未分组(与 DSH 原生删除语义一致)。
reveal 在资源管理器里定位目录;workspace 没有记录路径时 bad-request。
POST /workspaces/:id/path 是”复制目录地址”:只解析路径、不碰操作系统,和
POST /sessions/:id/log-path 成对——一个给文件,一个给目录。面板因此可以把地址粘到编辑器、
终端或另一台机器的对话框里,而不必在宿主所在的那台机器上开窗。
POST /reveal-path 只接受本插件已经列出的目录:某个会话的 cwd,或某个 workspace 自己的
path(lib/index.js 的 knownDirectory)。其他路径一律 400 path is not a directory this plugin
knows about——客户端只会送它正在显示的会话的 cwd,所以这条路由不该是一个”打开任意路径”的通用原语
(\\host\share 交给资源管理器是凭据泄露面,不是便利)。子目录不算:比对的是完整路径,不是前缀。
| 路由 | body | 返回 |
|---|---|---|
GET /datadir |
— | { current, recorded, fromEnv, exists, sizeBytes, contents } |
POST /datadir/inspect |
{ target } |
{ target, current, exists, writable, sameVolume, mode, sizeBytes, currentSizeBytes } |
POST /datadir/migrate |
{ target, mode? } |
{ target, mode, steps, revision } |
inspect 是预检,不碰任何东西:目标必须是绝对路径、不是文件系统根、不是当前目录、可写、
且不含另一套安装。mode 由是否同卷决定(同卷 move,跨卷 copy),move 是唯一会删旧目录的模式。
详见 data-directory.md。
| 路由 | body | 返回 |
|---|---|---|
GET /component/status |
— | 见下 |
POST /component/install |
{ mirror? } |
同 status |
POST /component/cancel |
— | 同 status |
POST /component/remove |
— | 同 status |
POST /component/retry |
— | 同 status(续传已有 .part) |
status 的形状(lib/component/manager.js:345):
{
"phase": "absent | downloading | verifying | extracting | ready | error",
"receivedBytes": 0, "sizeBytes": 0,
"approxDownloadBytes": 157455369, "approxDiskBytes": 383650096,
"canRemove": false, "pendingCleanup": 0,
"error": "上一次失败的原因(phase=error 时)"
}
轮询而不是 SSE:唯一会变的数字是下载百分比,而看它的那个页面只开几秒。
remove 会先关掉独立窗口(component-in-use 的替代路径),并在删不掉时把路径记进
pending-cleanup.v1.json,下次启动 sweep。
| 路由 | body / query | 返回 |
|---|---|---|
POST /bridge/commands |
{ kind, payload? } |
{ seq, kind } |
GET /bridge/events |
?since=<cursor> |
SSE 流 |
POST /detached/open |
— | { open: true, ... } |
POST /detached/close |
— | { closed: boolean } |
kind ∈ {open-session, new-session, reveal, refresh, open-settings}(lib/host/bridge.js:148),
payload 只接受 sessionId / workspaceId / scope 三个字符串字段,各自截断到 200 字符。
其它 kind 一律 bad-request——桥是一根窄管子,不是插件的遥控器。
GET /bridge/events 先发 event: ready(带当前 cursor 和允许的 kinds),再补发
since 之后的命令,之后是实时的 event: command;每 15 秒一条 : keep-alive 注释帧。
命令帧带 id: <seq>,重连时服务端优先读 Last-Event-ID 头而不是 URL 里的 since;订阅者
上限 32,满了回 503;res.write() 返回 false 时直接结束流,让客户端带 Last-Event-ID
重连,而不是让缓冲涨下去。细节见 bridge-and-theme.md。
POST /detached/open 在组件未 ready 时返回 404 component-not-installed。
| 路由 | 文件 | Content-Type |
|---|---|---|
GET /ui |
assets/detached/index.html |
text/html |
GET /ui/window.css |
assets/detached/window.css |
text/css |
GET /ui/shim.js |
assets/detached/shim.js |
text/javascript |
GET /ui/client.js |
lib/client.js |
text/javascript |
GET /ui/react.js |
assets/vendor/react.production.min.js |
text/javascript |
GET /ui/react-dom.js |
assets/vendor/react-dom.production.min.js |
text/javascript |
路径是常量,不由请求决定读哪个文件,因此没有穿越面(lib/index.js:97)。
全部 no-store:面板和窗口必须跑同一份代码,缓存一份旧的正是这套安排要消除的漂移。
文件缺失时 ui-asset-missing(500)。
设置页「关于」组的三条后端(它后面还有一组「危险操作」,另记在 §4.12)。
| 路由 | body / query | 返回 |
|---|---|---|
GET /update |
?force=1 跳过 5 分钟缓存 |
{ current, latest, hasUpdate, url, name, publishedAt, notes, checkedAt } |
POST /about/open |
{ target } |
{ target, url, opened: true } |
POST /cache/clear |
— | { freedBytes, caches, tmp, downloads, staging, skipped, tookMs } |
GET /release-notes |
— | { title, blocks, bytes, updatedAt, version, seen } |
POST /release-notes/seen |
{ version } |
{ version }(写进去的记录;空版本是 no-op) |
GET /update 在 host 侧访问 api.github.com(lib/host/update.js):客户端是个网页,
直连会撞 CORS;独立窗口里 window.open 又被主进程拒掉,两条路都不通,所以这件事只能
host 做。仓库地址只在 lib/constants.js 的 REPO_URL 写一次,API 地址由它拼出来。
结果缓存 5 分钟:设置页每开一次就问一次 GitHub 的话,未登录的 60 次/小时会很快用完。
latest 为 null 不是错误——仓库还没有 release,和”连不上”是两回事(后者是
502 update-unreachable)。版本比较只按数字段走:v1.10.0 > v1.9.0,1.0 与 1.0.0
相等,预发布版低于同号正式版。current 读的是 package.json,不是第二份写死的数字。
POST /about/open 收的是名字不是 URL,白名单 { repo, star, issues, releases }
(lib/host/open-link.js 的 LINK_TARGETS)。这条 API 没有 token,任何页面都能调;
如果它接受任意 URL,就等于给每个网页一个”用系统浏览器打开这个地址”的按钮。所以只认
这四个键,别的(包括 "https://…"、"STAR")一律 bad-request,且什么都不拉。
打开本身走一条降级链(同一个文件):Windows 先 rundll32 url.dll,FileProtocolHandler,这是
一次裸 ShellExecute,没有控制台宿主、不继承调用方的窗口状态;失败再退回
powershell -Command Start-Process;macOS open,Linux xdg-open → gio open。
判据是子进程的退出码,不是”起来了吗”:只报 spawn 成功的话,一个起得来却打不开任何东西的
壳子也会被当成成功,用户看到的就是”点了没反应”。全链失败才是 502 open-failed,且消息里
带上那个 URL,面板把它原样显示出来,至少还能复制。
POST /cache/clear 只清插件自己的缓存(lib/host/maintenance.js):
reader.invalidate()、clearLogFacts()、各侧车的 invalidate()。.tmp(cleanTmp,被中断的写会被搬进 quarantine/ 而不是删掉)、
组件的 download/(含续传用的 .part)与 .component-staging/。组件正在 downloading / verifying / extracting,或状态文件读不出来时,后两项跳过,
skipped 里报 component-busy——那正是它在用的目录,删一半等于让它下次从断点装坏。
quarantine/(用户自己的坏数据)、DSH 的 storages/session_projcache 和会话本身
从不在这里被碰。浏览器缓存也不在其中:面板的 /ui/* 一律 no-store(§4.10),
其余页面缓存属于 DSH 自己的渲染进程和独立窗口,不在插件手里。
GET /release-notes 是「更新说明」弹窗的正文,而正文是文档,不是代码里的字符串
(lib/domain/notes.js + lib/host/changelog.js)。改一次文案就是改一个 markdown 文件,
不用动这个插件的源码,更不用等它被重新构建。
两个候选文件,先到先用:仓库里是包目录上一层的 更新说明.md,装好的包里只有随包发出去的
CHANGELOG.md(包不可能带上自己的父目录)。checkout 优先,所以维护者改完下一次打开就能看到;
打包用的那份就是发布版用户读到的内容,scripts/sync-notes.mjs 在 prepack 时把它覆盖过去,
所以打出来的包里永远是维护者刚写完的那一份,不需要谁记得手动复制。
解析只认一个很小的子集:标题、无序/有序列表、分隔线、**粗体**、\代码`;认不出的行
原样当段落显示,不会从弹窗里消失。文件超过 200 KB 直接 too-large——那不是一份更新说明;
两个文件都没有是 not-found`,弹窗照样打开并把原因写在正文位置,而不是显示一张空卡片。
按 大小 + mtime 记住上一次的解析结果:文件几 KB,重新解析一次也不贵,但”改了文案弹窗还是旧的”
正是这套安排要避免的事,所以键里必须带 mtime。
读这份文档的有两个入口,读的是同一条路由:
NotesGate,与面板一起卸载不了),DSH 起来约 3 秒后问一次
/release-notes,把响应里的 version 与同一个响应里的 seen 比对(notesDecision):版本不同、或这台机器从没记过版本,
就打开;相同就什么也不做。第一次装也弹——第一次启动正是这个版本唯一一次”新”的时候,
更新说明说的就是它;弹在启动而不是等面板打开,则是”用户可能根本不打开面板,但插件已经更新了”。
version 随这份响应一起回来,所以判断要不要弹不需要第二次请求,也不需要 /bootstrap 去扫会话。
“已读”记在宿主的 <dataDir>/notes-seen.v1.json(lib/host/notes-seen.js),不是 localStorage:
DSH web GUI 每次启动换一个端口,而浏览器存储按 origin(含端口)隔离,换端口就是空存储——
记在那里等于”每次启动都弹”。写回走 POST /release-notes/seen(最新的一次覆盖旧的,不需要 revision);
也没写进设置文档,因为”哪个版本看过”不需要一次合并和一条冲突规则。
关闭时才算送达(close 里写版本);读失败(not-found / too-large)在启动路径上静默且不写记录——
启动时弹一张”你的文件坏了”的卡片比沉默更糟——下次启动再试一次。一次页面加载只尝试一次
(notesLayer.asked),免得侧栏重挂载就再弹一次。| 路由 | body | 返回 |
|---|---|---|
POST /data/reset |
{ confirm: "DELETE" } |
{ freedBytes, documents, trash, quarantine, tmp, keptLocation, dataDir, tookMs } |
插件里唯一一个不可逆的操作(lib/host/reset.js),因此有两道门,且都在 host 侧:
confirm 去掉首尾空白后必须恰好是 DELETE(RESET_CONFIRMATION),否则
400 confirm-required——在读到任何文件之前就返回,一个字节都不动。门装在这里而不是
只装在页面上,是因为这条路由没有 token:只靠一颗红按钮拦的话,一次 fetch 就绕过去了。api.dataDir 必须过 isUsableDataDir()(不是文件系统根,不是 ~/.dsh 也不是它的祖先),
否则 409 reset-refused。越是要清空一个目录,越要先确认那是它自己的目录。过了门,它删的是插件自己的记录:
tree.v1.json / annotations.v1.json / settings.v1.json /
trash/index.v1.json:虚拟树(文件夹、工作区摆放与顺序)、标签、备注、设置、回收站索引;trash/ 里的净内容。回收站是 move 不是 copy(lib/host/trash.js),里面的目录
曾经就是 DSH 的会话目录,删掉它们等于把这些会话永久删掉——这就是它必须被 DELETE 拦住的原因;quarantine/(解析不了的文档,是用户自己的字节)与 .tmp/(写了一半的原子写残留)。content-index.v1.json:会话内容索引的缓存(§4.14)。派生文件,删了只是下次重建一次;
返回里的 contentIndex 就是它那一项,{ removed, bytes } 与 documents 同形状。它不动的东西:
component/、.component-staging/):那是一次安装,不是用户数据。
要清它按 POST /cache/clear(§4.11)。迁移过的安装(settings.dataDir.current 有记录)会在删之前先记下这个路径,删完立刻用一份
新文档写回去(新文档 revision 从 1 开始),否则下次启动会退回默认目录,面板就指向一个空位置。
keptLocation 是写回去的那个值,没记录时为 null。写不回去也不报错:退回默认目录比
拒绝执行好,那本来就是没迁移过的安装的位置。
清完必须 invalidateMemory(api):面板上每张列表都是 memo 过的,只删文件的话,被删掉的
文件夹和标签还会从内存里一直答到进程重启为止。
documents 每项是 { removed, bytes }(第五份被删的是派生缓存 contentIndex)。文件被扫描器或索引器占着删不掉时 removed: false
而不是抛错——摘要要描述真实发生的事,freedBytes 也只累加真的回来了的字节。
| 路由 | body | 返回 |
|---|---|---|
GET /search/content |
?q=<needle> |
{ query, ready, indexed, total, hits } |
面板里 会话名 / 标签 / 备注 三个字段本来就在列表行上,读一遍内存就有答案;会话内容只在会话日志里
——本机 63 份日志、解压后约 100 万字符正文,全量读一次 6.5 秒。所以这里不是每次按键读文件,而是一个
索引(lib/sources/content.js):内存里按 logPath | sizeBytes | activityAt 判定某份日志要不要重读,
逐份构建、每份之间让出事件循环(一次最多 budgetMs = 700),并且同一时刻只允许一个构建在跑。
索引里装的是说的话:用户消息与助手回复的文字(collectTurns 的 role 为 user 与
assistant 两种回合)。工具调用的参数与输出、思考过程、DSH 每轮注入的上下文、压缩摘要都不进
索引——它们不是”谁说的”,而是”系统做了什么”,混进来会让搜一个函数名命中一百个无关会话。
ready: true 之后搜不到,就是这份日志里没人说过。
ready: false = 索引还没建完,indexed / total 是进度;hits 是已经建到的那部分的答案。
冷启动的第一次搜索先给一半答案,比转 6 秒钟好;客户端 240ms 防抖,未就绪时每 700ms 续查一次。hits 每项 { id, count, snippet }:count 数的是出现次数(上限 999),最多 200 个会话,
snippet 取命中前后 30 / 70 字符、折成一行、两端带省略号。索引总是落盘一份(content-index.v1.json),进程重启后不必重读日志;它没有开关(m01916 去掉了),重建与删除见 §4.14。
缓存 payload 带 version(当前 2):v1 只存了用户那一半正文,签名(logPath|size|activityAt)却一样,
留着它就会永远回答”没搜到”而不自愈,所以版本一升旧条目即被忽略并就地覆盖。文件名里的 .v1 是槽位
不是版本号,改名只会把旧文件变成垃圾。
搜索的位置与字段是两件事,两者都在客户端的「搜索范围」弹窗里选:位置是「所有位置」(缺省) 或「当前目录」,字段是会话名 / 标签 / 备注 / 会话内容。缺省搜整个库——站在任何目录里搜,答案都 不该是”这个目录里没有”;只有用户明确选了「当前目录」,才收窄到脚下这一层(文件夹列表也一起收窄, 不再把命中的兄弟目录列出来)。回收站按自己的行回答,不受位置影响。
| 路由 | body | 返回 |
|---|---|---|
GET /search/index |
— | { ...state, cache, building } |
POST /search/index/rebuild |
— | { ...state, cache, building } |
POST /search/index/clear |
— | { freedBytes, sessions, cache, building } |
对应设置文档(lib/domain/settings.js)里的一个开关,缺省值就在 defaultSettings():
| 键 | 缺省 | 含义 |
|---|---|---|
search.indexIdle |
false |
允许空闲时自动建立:bootstrap 之后等 idleDelayMs = 1200,再按 idleSliceMs = 250 的小步推进 |
索引文件本身没有开关。1.0.6 及更早的文档里有个 search.indexCache,它控制的缓存现在恒开:
派生文件删掉只花一次重建,而”不写缓存”这条路只会让每次启动都重读一百多 MB 日志。旧文档带来的
indexCache 在 normalizeSettings 里按未知键丢弃,不报错也不生效。
state 是 { indexed, total, pending, ready },与 GET /search/content 同源;cache 是
{ enabled, bytes, exists, sessions }(bytes 是磁盘文件的真实大小,exists 只说明文件在不在);
building 是后台构建器的忙闲。rebuild 先清空内存与磁盘条目(旧缓存不再命中任何日志),再从头构建;构建期间搜索照常可用,
只是答案逐步变全。clear 让构建器停下、删文件、清内存,freedBytes 只累加真正回来了的字节。PUT /settings 只在 search.indexIdle 为真时 indexBuilder.resume():那是”现在就慢慢建”的
意愿表达,关掉不代表要删掉已经建好的文件——删文件是 POST /search/index/clear 与清除数据的事。path\u0000sizeBytes\u0000activityAt 组成的签名判失效,文件里写着 version: 2;
版本不符就当没有,不做迁移。文件损坏、临时目录不可写都只让 flush 返回 { written: false },
搜索仍然可用——缓存是加速手段,不是正确性的前提。
版本 2 之前只存了用户说的话(turnsOf 那时读不到 data.message,助手那一半从来没进过索引),
签名不变会让”搜不到助手回复”永远不愈合,所以版本号本身就是那一刀的开关。产物是标题、描述、标签三个字段,不是一段摘要文字(用户 m01823:「总结就是让你总结标题 描述 标签」;
m01826:「不输出摘要,摘要不是产物」)。三个字段各自独立,settings.summary.fields 决定要哪几个。
| 路由 | body / query | 返回 |
|---|---|---|
GET /summaries |
— | { sessions, spend, pending, revision, choice } |
GET /summarize/catalog |
?refresh=1 跳过 60 秒的目录缓存 |
{ providers, failures, at } |
GET /summarize/state |
— | 队列状态,见下 |
POST /summarize |
{ ids, titles, lang } |
队列状态 |
POST /summarize/pending |
{ lang } |
队列状态 + { candidates, skipped };没活干时 409 nothing-pending |
POST /summarize/cancel |
— | 队列状态(已完成的保留) |
POST /summaries/:id/apply |
{ field: "title" \| "description" \| "tags", tags?: string[] } |
{ revision } |
DELETE /summaries/:id |
— | { revision } |
state(lib/host/summarize.js 的 state())是客户端唯一要知道的东西:
{
"running": true, "total": 12, "done": 3, "failed": 0, "summarized": 41,
"current": { "id": "session-…", "title": "重构导出", "startedAt": 1793000000000 },
"lastError": "", "lastFailureId": null, "startedAt": 1793000000000, "finishedAt": null,
"spend": { /* 同 summaries.v1.json 的 spend */ }
}
catalogFromController() 读 sessionController.modelCatalog()
(与 dsh-orb 同一个来源、同一份 groups 形状),profile 没有这个服务时回退到直连
llm.listProviders() / listModels() / resolveModelInfo()。单条 provider 失败只进 failures,
不整体失败;缓存 60 秒,?refresh=1 跳过。POST /summarize 在入队之前先解析模型路由(pickRoute):profile 里没有 llm 服务、或选中的
模型已经不在目录里而目录又空着,一律 501 llm-unavailable,不会先答应再一个个失败。已经在队列里
或正在写的 id 被跳过,所以重复点不会重复计费。pump() 里的 while),失败的会话记进 lastError 后继续下一个,
一条失败不拖垮整批。cancel 清掉未开始的部分并 abort 手上那一个,已经写好的字段保留。recordSpend),包括没有 usage 的失败:它在 runs 里而不在 totalTokens 里,
但少了它,累计数就成了伪装成总数的下限。422 ai/bad-answer,一个半成品都不写(半成品会让这个会话永远不再重试)。transcriptOf() 拼的对话正文——只有用户与助手的发言,思考过程(turn.reasoning)与
工具调用/输出(turn.tools)从不读;超出 PROMPT_BUDGET_CHARS = 12000 时保留头 55% 与尾 45%,
中间插一行省略说明。会话标题、备注、标签不再喂回模型(那是它要写的东西,喂回去只会让旧名字活下去)。tools: [])、不设 maxTokens、只带本插件自己的 system:DSH 的 agent
提示词不会混进来(pi-ai 适配器的 splitSystemPrompt() 只拼 options.system 和请求里的 system 消息),
而推理模型的第一块是 reasoning 而不是文字——那是正常输出,照样继续等 text-delta。不设输出上限
是因为思考也算进同一个预算,小上限买到的是空正文(dsh-pet 的 whisper.ts 记了同一个坑)。
调用带 sessionId: "session-navigator" 这个固定身份(有的 provider 按身份路由,缺了会 400),
它不是真实会话 id,检查点中间件查不到这个会话就直接放行,不会往任何历史里写东西。POST /summaries/:id/apply 是唯一会改动别的文档的路由,三个字段三种语义,都只走一次点击:
title 走 renameSession() 改名;description 用 mergeNote() 追加到用户自己的备注后面
(备注是用户的话,模型的句子只能是补充,重复提交不会贴两遍);tags 只并入 body.tags 里勾选的
那几个(pickTags() 与记录取交集,勾选的是过期值时回 409 nothing-to-apply,绝不退化成”全并”),
并把它们写进标签库——下次生成时它们就成了”已有标签”。
生成从不自动应用:名字改成什么由用户决定,标签库也不该被模型自己塞满。existingTags 读 tagVocabulary(),最多 40 个进系统提示词;
回答里的标签再由 alignTags() 按现有写法归一(”导出 “ → “导出”),库里有词就不再造同义词。DELETE /summaries/:id 只删记录,不动花费账本:钱已经花掉了,账本记的是事实。domain/annotations.js 的 isAnnotationId)过滤:start() 丢掉不是
会话 id 的入队项,DELETE 对不是会话 id 的路径参数回 400。记录是以会话 id 为键的普通对象,键写成
__proto__ 会让之后每一次 sessions[id] 查询都命中,整库看起来都”已经写完”;normalizeSummaries()
在读盘时也按同一条规则丢掉这类键。GET /summaries 的 revision 是 summaries.v1.json 自己的修订号,与 tree / settings 无关;
客户端 storeOf() 对 /summar… 前缀返回 null,所以这些请求不带任何 If-Match。lib/client.js 里 storeOf(path) / revisionOf(path) 负责把响应塞进订阅式 store,
所有写请求都带 If-Match。客户端不解析 error 文案,只按 code 分支;
revision-conflict 的处理是重新 GET /bootstrap?refresh=1 再让用户重试,
设置页同理——它重读 GET /settings 并采用新的 revision,否则每个控件都会带着
同一个陈旧的 If-Match 一直 409(lib/client.js SettingsSection.save)。
每一行只有一颗点加一句字,档位与 DSH 自己的侧栏完全一致,不额外发明任何一档
(DSH 的实现:packages/client/ui-workspace/src/client/rows/Rows.tsx sessionStatuses):
| 顺序 | 文案 | 点 | 什么条件 |
|---|---|---|---|
| 1 | 已删除 | 无 | 在插件的回收站里。这不是存活判断,是”这个载荷为什么打不开” |
| 2 | 等待审批 / 计划待审 / 等待回答 | 黄 | DSH 的 pendingInteraction.kind,只认这三个值,最高优先级 |
| 3 | 进行中 | 蓝 | DSH 的 running;服务不在时回落到插件从宿主读到的同一个 running |
| 4 | {n} 个子智能体运行中 | 蓝 | 本行下面还有正在跑的子会话(子行是 isSubagent 且 parentSession 指向本行) |
| 5 | 已完成 | 绿 | DSH 的 completionUnread:那一轮结束在别的视图里,本视图还没看过 |
| 6 | 空闲 | 无 | 以上都不是 |
2、3、4 可以同时成立,DSH 就把它们并成两颗点;插件只画一颗,所以按上表取最靠前的一条。
状态的来源是客户端服务 uiSession.sessionStatus(HostObservable<ReadonlyMap<SessionId, SessionStatus>>,
packages/client/ui-session/src/client/index.ts),插件用 useSyncExternalStore 订阅它,
所以 DSH 的侧栏变了,面板跟着变,不需要等下一次 /bootstrap。宿主没提供这个服务时整张表退化成
“只看 running“,不会报错。
已被删掉的两档不再回来:已中断(从日志尾部推断”一轮没关”——一次崩溃和一条收尾事件从外面看
一模一样,DSH 又在内存里判断存活,结果把正常结束的会话标成了中断)与异常
(formatVersion !== 4——DSH 认为那是需要迁移的旧载荷,不是故障)。