dsh-session-navigator

HTTP API

所有端点都挂在 /session-navigator/api 前缀下,由 lib/index.js:624 的 ROUTES 表定义, lib/index.js:2195 的 handle() 分发。共 77 条。

1. 信封

// 成功
{ "ok": true, "result": <任意 JSON> }
// 失败
{ "ok": false, "error": "给人看的消息", "code": "给机器分支的稳定码" }

四条路由不写信封,它们自己接管响应:GET /bridge/events(SSE)、GET /export(文件)、 GET /ui*(静态资源)。handle() 在 headersSent 后不再追加信封(lib/index.js:2218)。

2. 通用约定

乐观锁:revision

四份侧车文档各自带单调递增的 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)。否则写入会落进正在被复制的旧目录。

id 约定

前缀 含义 例子
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-、会话原样),这样解析时不用猜。

3. 错误码 → HTTP 状态

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 未预期的服务端故障

3.1 写请求的同源约束

路由挂在 DSH 自己的端口上,没有 token,所以浏览器里任何一个页面都能往这里 POST—— 响应它读不到,但 POST /trash/empty 已经生效了。lib/index.js 的 assertSameOrigin() 用两个浏览器自己加、脚本伪造不了的信号堵住这条路(只对 GET/HEAD 以外的请求生效):

插件自己的前端一直带 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、脚本、本机工具)照常放行——它们不是跨站浏览器请求。

3.2 子智能体的绑定

子智能体是父会话一次委派的产物,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 标记——隐藏是礼貌,上面这张表才是规则。

4. 路由

4.1 快照与设置

路由 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 丢掉而不是落盘。

4.2 标签

路由 body 返回
GET /tags — { tags, library }
POST /tags { tag } { tag, library, revision }
DELETE /tags/:tag — { tag, library, revision }

删除库里的标签不会把它从会话上摘掉——那些会话仍然带着它,只是不再出现在候选列表的库段。

4.3 会话注解

路由 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)。

4.4 树:文件夹、快捷方式、置顶、快速访问、放置

路由 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 }

4.5 会话操作

路由 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),插件不落盘:

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):

三级顺序(lib/host/actions.js 的 revealOnWindows,前一级成功就不再往下走):

  1. shell API(首选,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},自动落到下一级——缺绑定只损失速度,不损失功能。
  2. 常驻 PowerShell(lib/host/reveal-agent.js):Shell.Application.Explore + 前台验证,冷启动 约 1.8 s,之后每次 130–180 ms。只有在第 1 级不可用时才会在面板打开时预热。
  3. DSH 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 里。

4.6 workspace

路由 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 交给资源管理器是凭据泄露面,不是便利)。子目录不算:比对的是完整路径,不是前缀。

4.7 数据目录

路由 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。

4.8 浏览器组件

路由 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。

4.9 桥与独立窗口

路由 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。

4.10 静态资源

路由 文件 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.11 关于:更新检查、缓存清理、链接

设置页「关于」组的三条后端(它后面还有一组「危险操作」,另记在 §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):

组件正在 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。

读这份文档的有两个入口,读的是同一条路由:

4.12 清除数据

路由 body 返回
POST /data/reset { confirm: "DELETE" } { freedBytes, documents, trash, quarantine, tmp, keptLocation, dataDir, tookMs }

插件里唯一一个不可逆的操作(lib/host/reset.js),因此有两道门,且都在 host 侧:

  1. confirm 去掉首尾空白后必须恰好是 DELETE(RESET_CONFIRMATION),否则 400 confirm-required——在读到任何文件之前就返回,一个字节都不动。门装在这里而不是 只装在页面上,是因为这条路由没有 token:只靠一颗红按钮拦的话,一次 fetch 就绕过去了。
  2. api.dataDir 必须过 isUsableDataDir()(不是文件系统根,不是 ~/.dsh 也不是它的祖先), 否则 409 reset-refused。越是要清空一个目录,越要先确认那是它自己的目录。

过了门,它删的是插件自己的记录:

它不动的东西:

迁移过的安装(settings.dataDir.current 有记录)会在删之前先记下这个路径,删完立刻用一份 新文档写回去(新文档 revision 从 1 开始),否则下次启动会退回默认目录,面板就指向一个空位置。 keptLocation 是写回去的那个值,没记录时为 null。写不回去也不报错:退回默认目录比 拒绝执行好,那本来就是没迁移过的安装的位置。

清完必须 invalidateMemory(api):面板上每张列表都是 memo 过的,只删文件的话,被删掉的 文件夹和标签还会从内存里一直答到进程重启为止。

documents 每项是 { removed, bytes }(第五份被删的是派生缓存 contentIndex)。文件被扫描器或索引器占着删不掉时 removed: false 而不是抛错——摘要要描述真实发生的事,freedBytes 也只累加真的回来了的字节。

4.13 全文检索

路由 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 之后搜不到,就是这份日志里没人说过。

索引总是落盘一份(content-index.v1.json),进程重启后不必重读日志;它没有开关(m01916 去掉了),重建与删除见 §4.14。 缓存 payload 带 version(当前 2):v1 只存了用户那一半正文,签名(logPath|size|activityAt)却一样, 留着它就会永远回答”没搜到”而不自愈,所以版本一升旧条目即被忽略并就地覆盖。文件名里的 .v1 是槽位 不是版本号,改名只会把旧文件变成垃圾。

搜索的位置与字段是两件事,两者都在客户端的「搜索范围」弹窗里选:位置是「所有位置」(缺省) 或「当前目录」,字段是会话名 / 标签 / 备注 / 会话内容。缺省搜整个库——站在任何目录里搜,答案都 不该是”这个目录里没有”;只有用户明确选了「当前目录」,才收窄到脚下这一层(文件夹列表也一起收窄, 不再把命中的兄弟目录列出来)。回收站按自己的行回答,不受位置影响。

4.14 搜索索引(缓存 / 重建 / 删除)

路由 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 里按未知键丢弃,不报错也不生效。

4.15 AI 字段

产物是标题、描述、标签三个字段,不是一段摘要文字(用户 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 */ }
}

5. 客户端怎么用

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)。

5.1 列表里的状态

每一行只有一颗点加一句字,档位与 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 认为那是需要迁移的旧载荷,不是故障)。