dsh-session-delete
Adds the operation the Harness itself does not have: deleting a conversation.
English | 中文
Why this exists
DSH can archive a Session but never delete one. That is a product decision, not a missing button:
dsh-client-ui-workspacerecords the limit — “No Session deletion — sessions can be archived but never deleted … Workspace registration deletion does not delete Sessions.”dsh-session-persistence-jsonlrecords the same at the storage seam — “Nothing deletes session files — logs accumulate underrootuntil removed externally; the seam has no deletion API.”
Archiving only appends the Session id to archivedSessionIds in
$DSH_HOME/storages/workspace.json; the log, the projection checkpoint and the
attachments all stay on disk. This bundle supplies the external removal that the
documentation says is the only way, behind a normal sidebar action.
What it adds
- One row in the Session “...” menu —
sidebar.workspaces.session.menu.itematorder: 500, after the shipped Rename / Fork / Pin / Archive rows, with a group hairline above it and the host's destructive-row colors. - One confirm dialog in
shell.overlay, because the row unmounts with its menu. - One Host Remote,
sessionJanitor/deleteSession, which is the only place the deletion rules live. The row menu is a caller of it, not a second implementation.
What a deletion removes
For the Session and, by default, every subagent Session beneath it:
| Path | What it is |
|---|---|
$DSH_HOME/sessions/<project-key>/<session-id>/ |
the stored log (session.v4.jsonl.zstd) |
$DSH_HOME/sessions/<project-key>/<session-id>.jsonl[.zstd] |
a pre-release flat sibling |
$DSH_HOME/sessions/<session-id>.jsonl[.zstd] |
the pre-release flat root layout |
$DSH_HOME/storages/session_projcache/sessions/<session-id>.json |
the projection checkpoint |
Then the id is dropped from the archive and pin sets, and api-session/removed is
emitted so every connected Client retires the row immediately.
Left alone on purpose:
- Content-addressed attachments under
$DSH_HOME/attachmentsand$DSH_HOME/cache/attachments— they are shared between Sessions by hash, so deleting one Session's attachments could break another's. - The Session search index — it reconciles against
sessionPersistence.list()on every read, so a removed log drops out of it on its own. workspaces[].sessionIdsmembership — the registry exposes no removal API, and the sidebar derives rows from Session summaries, so a stale id renders nothing.
Refusals
deleteSession returns a result object rather than throwing, and every expected
failure carries a reason:
reason |
Meaning |
|---|---|
invalid-id |
the value is not a Session id (no separators, so it can never escape sessions/) |
active |
a live Agent still owns the log, because the Session was opened or used during this Harness run. See Why a loaded Session is refused |
unknown |
no stored Session and no artifact carry that id |
partial |
some paths survived (a lock or a permission problem); failed names them |
Why a loaded Session is refused
ctx.sessions.get(id) is truthy for any Session whose Agent lives in this process, and
dsh-session documents the consequence: “the agent lifecycle attaches a session-log
writer to each published session's write handle; a session published outside that
lifecycle persists nothing.” So while an Agent owns the id, the log is a live artifact
with an open writer. Removing the file under it would
- be undone by the next append (the handle writes to the path and re-materializes the log, now holding only the events that arrived after the deletion), and
- make that handle's own
flushfail, which the service-widesessionPersistence.flush()surfaces as anAggregateErrornaming the Session.
The refusal is therefore keyed on the Agent, not on the sidebar row or the archive
flag, and it fails safe: when the agents service cannot answer, a published Session is
refused too.
What triggers it is opening a Session, not archiving one. The controller's history
stream promotes a cold Session — source.retain() plus promote() → resolveObservedAgent()
→ agents.resume() — so merely viewing a conversation activates its Agent, and that
Agent is owned by the controller's own context, so it lives until the Harness exits.
Archiving does not unload it, and switching to another Session does not either. In
practice: Sessions you never opened during a run delete immediately; the ones you did
need a fresh Harness start, where the deletion must happen before the Session is opened.
Layout
| File | Role |
|---|---|
index.js |
Host plugin: the sessionJanitor service, its Typert contribution, and the deletion policy |
purge.js |
Plain Node library: session-id validation, artifact discovery, measurement, removal |
remote.js |
Host wire contract: zod schemas, the invocation descriptor, the Client mount contribution |
typert.host.js |
The reflection manifest index.js registers with ctx.typert |
client.js |
Browser plugin: the menu row, the confirm dialog, the styles copied from ui-primitives |
test/ |
node --test coverage for the removal rules and the Host policy |
remote.js and the contract mirror inside client.js are two copies of one
descriptor: the browser module table has no zod, so the browser half restates the
schemas with a minimal validator. Keep them in sync.
Why index.js registers the reflection itself
There is no "./typert" export on purpose. That export is the Typert loader's own
discovery path, and the loader caches its per-package verdict for the process
lifetime — a bundle installed into a running Harness would sit unregistered until
the next restart. ctx.typert.register(contribution) is the documented path for
“any other owner”, and its disposer withdraws the contribution when the plugin
unloads, so the removal rules and the wire contract mount and unmount together.
Install
Install it as a bundle in the current profile; do not hand-edit the profile's
package.json or run pnpm in the profile directory. Ask the agent for the bundle, or
call plugin_manager directly:
plugin_manager action=install_bundle target=https://github.com/LilJoeIJOYU/dsh-session-delete
Working from a checkout instead? Install the directory:
plugin_manager action=install_bundle target=<absolute path to this checkout>
A Host plugin module that was already imported cannot be replaced inside the running
process. set_plugin toggles, remove_bundle, and reinstalling all re-import the
same module URL, so a change to any Host file (index.js, purge.js, remote.js,
typert.host.js) becomes live on the next Harness start, not before. The browser
half is separate: an installed bundle's Client artifact loads without a restart.
Test
node --test "test/*.test.mjs"
The tests build throwaway Harness homes; they never touch real Session data.
Limitations
- A
partialoutcome still retires the row: the id is announced as removed even when a file survived. The dialog shows the surviving paths. - A Session that a Client currently holds open cannot be deleted; the Harness exposes no way to dispose one Session without its Agent.
- Cascade reaches subagent Sessions through
ctx.subagents.listDescendants, so a corrupt parent log can hide children that would then be left behind as orphans.
中文
为什么需要它
DSH 只能归档会话,不能删除会话,这是产品决定而不是漏了按钮:ui-workspace 明确
写着「没有 Session 删除」,session-persistence-jsonl 也写着「不删除会话文件
——日志在 root 下累积,直到外部移除;seam 无删除接口」。归档只是在
$DSH_HOME/storages/workspace.json 的 archivedSessionIds 里加一个 id,日志、
投影缓存和附件都还在磁盘上。本插件把文档所说「唯一可行的外部移除」做成侧栏动作。
增加的东西
- 会话「...」菜单里多一行「删除会话」(
order: 500,排在重命名 / 分叉 / 置顶 / 取消归档之后,带分组细线,用 host 的危险色)。 shell.overlay里一个确认弹窗(行随菜单卸载,弹窗不能放在行里)。- 一个 Host Remote
sessionJanitor/deleteSession,删除规则只在这里实现一次。
真正删掉的内容
目标会话及其(默认)全部子代理会话的:会话日志目录、预发布期的扁平日志文件、
投影缓存 storages/session_projcache/sessions/<id>.json。之后把 id 从归档/置顶集合里
摘掉,并发出 api-session/removed,让所有已连接的客户端立刻收掉那一行。
故意不动:内容寻址的附件(多会话按哈希共享)、搜索索引(每次读取都会和
sessionPersistence.list() 对账)、workspaces[].sessionIds 成员关系(注册表没有
移除接口,而侧栏按会话摘要渲染,残留 id 不会渲染出任何东西)。
拒绝的情形
invalid-id(非法 id)、unknown(没有任何记录带这个 id)、partial(有文件被占用,
failed 里列出路径),以及 active:
只要某个会话的 Agent 还活着,它的日志就挂着写句柄(dsh-session 的原话是
“the agent lifecycle attaches a session-log writer to each published session's write
handle”)。这时把文件删掉会有两个后果:下一次写入会把日志重新写回来(而且只剩删除
之后的事件),以及它自己的 flush 会失败——服务级的 sessionPersistence.flush() 会把
这个失败作为 AggregateError 抛给别的会话。所以 active 是按 Agent 判断的,不是按
侧栏行或归档标记;而且拿不到 agents 服务时按「有风险」处理,一律拒绝。
触发它的是打开过这个会话,不是归档它:控制器的 history 流会把冷会话
promote() → resolveObservedAgent() → agents.resume(),所以哪怕只是点开看一眼,
它的 Agent 也就被激活了,而这个 Agent 挂在控制器自己的 context 上,会一直活到 Harness
退出。归档不会把它卸下来,切到别的会话也不会。实际表现就是:这次运行里没打开过的
会话立刻能删;打开过的要等下次启动 Harness,并且要在打开它之前删。
安装
让 agent 装,或直接调 plugin_manager:
plugin_manager action=install_bundle target=https://github.com/LilJoeIJOYU/dsh-session-delete
在本地 checkout 上开发时,指向目录即可(不要手改 profile 的 package.json,也不要在
profile 目录里跑 pnpm):
plugin_manager action=install_bundle target=<本目录的绝对路径>
已经被导入过的 Host 插件模块无法在运行中的进程里替换。 set_plugin 开关、
remove_bundle、重新安装都会重新导入同一个模块 URL,所以对任何 Host 文件
(index.js、purge.js、remote.js、typert.host.js)的修改要在下次启动 Harness
才生效。客户端半边是独立的:装好的 bundle 的浏览器产物不需要重启就能加载。
另外本插件故意不导出 "./typert":Typert loader 会把它对每个包的判定结果缓存一整个
进程生命周期,正在运行的 Harness 里新装的 bundle 要等到重启才会被注册。因此
index.js 自己调用 ctx.typert.register(contribution) —— 这是文档给「其他 owner」的
路径,返回的 disposer 会在插件卸载时撤销贡献。
No comments yet. Be the first to write one.