diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml
index 848b39e600..cd42eaf755 100644
--- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
-2026-07-28-cross-workspace-resume.md: d559b73a5ba0f8136d20ef6dcf7c62989d1527e9
-2026-07-28-cross-workspace-resume.zh.md: 404b81cbc07a455e5553a9c227d663e491456c9e
+2026-07-28-cross-workspace-resume.md: be455496346b9242585c1aace18f4f55cba905c0
+2026-07-28-cross-workspace-resume.zh.md: 0384cf204e51e4086b05c75e691f59d6b60a7d11
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
index d559b73a5b..be45549634 100644
--- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
@@ -43,7 +43,6 @@ The shared base states that precedence in the row itself: `apps/cli/base.cordis.
## Consequences
- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration.
-- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope.
- A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it.
- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here.
diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
index 404b81cbc0..0384cf204e 100644
--- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md
@@ -43,7 +43,6 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根
## Consequences
- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。
-- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。
- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。
- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。
diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml
deleted file mode 100644
index eb4893717e..0000000000
--- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md
-2026-07-28-live-session-registry-and-dsh-ls.md: 02343c83ccee7b67e3b3e4c72de842415d4a9f6e
-2026-07-28-live-session-registry-and-dsh-ls.zh.md: 722ac45f2eb07256f196d2828b8969ae9f4965b8
diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md
deleted file mode 100644
index 02343c83cc..0000000000
--- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# Agent Note: live-session registry and `dsh list-sessions`
-
-Status: implemented
-
-English | [中文](2026-07-28-live-session-registry-and-dsh-ls.zh.md)
-
-## Problem
-
-Nothing could answer "which dsh sessions am I running right now". A user with sessions across several projects had no way to enumerate them, and no way to recover the id needed for `--resume` except the exit line of the terminal that printed it. Session persistence records every session that ever existed, so it cannot answer the question: it has no notion of liveness, and no `process.pid` appeared anywhere in the session, persistence, or storage packages.
-
-## Decision
-
-`dsh list-sessions` (alias `dsh ps`) lists the sessions running right now — session id, pid, uptime, workspace, title — newest first, across every workspace, with `--json` for machines. Three packages back it, as a capability seam.
-
-[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md) (`ctx.sessionRegistry`) is the seam: the abstract service contract and record vocabulary, so the medium can later move to a database without touching consumers. [`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) implements it over one lock-guarded JSON file under the Harness home. [`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) follows `session/created`, `session/disposed`, and `session/title` and keeps the registry in step. `apps/cli` mounts both on every launcher surface — the TUI, `dsh meta`, headless, and web — and `dsh list-sessions` mounts only the service, booting no agent tree. No surface label is recorded: a launcher's mode is not a property of the session, and the workspace column already distinguishes a `dsh meta` session from a project one.
-
-### Liveness is derived, never stored
-
-`list()` probes each record's pid with `kill(pid, 0)` and drops the dead ones, writing the pruned result back. A process killed without running its disposer leaves a record that the next read removes, so there is no daemon, no heartbeat, and no permanent phantom. A per-process `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record from a different incarnation. `EPERM` counts as alive: a live session owned by another user must not be dropped.
-
-### Two independent concurrency layers
-
-The file is written by every dsh process and by several sessions inside one process, and the two cases need different mechanisms.
-
-Across processes, each read-modify-write holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Within one process, calls queue on an internal chain, because the advisory lock is tracked per process: overlapping same-process callers contend for its bounded retry budget rather than queueing, and past roughly a dozen concurrent calls that budget runs out and a registration rejects. Since publication is fire-and-forget, such a rejection silently drops a live session from the listing — the exact "listing that lies" failure this feature exists to avoid. Both layers are load-bearing and each is pinned by a test that fails without it.
-
-### Records carry their own title
-
-The title is the one mutable field, replaced through `retitle` as `session/title` events arrive. It lives in the record rather than being read from the session log because the log's location, format, and compression are per-deployment backend choices: the TUI writes project-local zstd-compressed JSONL, the web and headless surfaces write to a global root, a user profile overrides either, and SQLite has no per-session file at all. An independent reader cannot portably parse that, so `dsh list-sessions` opens no log and assumes no backend.
-
-### Subagents are invisible by construction
-
-Only top-level launcher surfaces mount the publisher. In-process subagents (`spawn`, `fork`) have no process of their own, and the out-of-process backends spawn `dsh-jsonrpc-agent` rather than this CLI. No filter flag is needed, and no subagent package changed.
-
-## Alternatives considered
-
-**One file per session under `~/.dsh/run/`.** No lock at all, since each process only writes and deletes its own file. Rejected in favour of the single file the user chose, which then made a real advisory lock mandatory rather than optional.
-
-**A domain over the `storage-json` backend.** The obvious reuse, and wrong: that backend documents "no cross-process write locking … last write wins" and names single-host-process as its assumption, and the [domain KV storage note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) puts multi-process explicitly out of scope. A registry written concurrently by every launcher is precisely that excluded case. Widening the backend's contract would have changed a shipped guarantee for one consumer; a separate package owns the multi-process medium instead.
-
-**Hand-rolled `O_EXCL` lock directory.** Rejected under the [dependencies-over-hand-rolling policy](../process/2026-07-26-dependencies-over-hand-rolling.md): stale-lock detection, retry backoff, and compromise handling are exactly the surface a maintained dependency should own.
-
-**Accept last-write-wins on the single file.** Cheapest to build, and it silently omits real running sessions when two start close together. A listing tool that lies is worse than no listing tool.
-
-**Read the title from the session log in `dsh list-sessions`.** Implemented first, then verified live: the shipped TUI writes `session.jsonl.zstd`, whose frame helpers are internal to the jsonl backend. Exporting them would have hard-coded one backend's file format into the CLI and still shown nothing for SQLite.
-
-**Register the web server itself with a placeholder session id.** `dsh web` owns no session — its sessions are created later by browser clients — so a server row would have put a fake id in a session table. Following session lifecycle instead makes browser sessions appear and disappear as they are opened, which also subsumed the TUI's launcher-side registration and deleted that separate path.
-
-**A `--here`/`--workspace` filter.** Dropped on request: the listing is always global, and narrowing is the user's `grep`.
-
-## Consequences
-
-The registry is an observability aid, so every write is best-effort: a registry fault warns and never fails a working agent session. The cost is that a listing can lag reality by one failed write, healed by the next.
-
-Title mirroring costs one locked read-modify-write per revision, so an aggressive retitling cadence pays that write each time.
-
-`bootId` bounds pid reuse only for records this process wrote. A foreign record whose pid the operating system has reassigned to an unrelated live process is reported alive until its owner removes it — accepted because the portable alternative, reading real process start times, is `/proc`-only.
-
-Liveness is pid existence, not health: a hung process still lists as running. The registry deliberately makes no progress judgement.
-
-## Testing
-
-Unit coverage pins durable-format validation (torn text, foreign version, per-row damage that must not hide siblings), pid pruning against a genuinely reaped pid, `EPERM`-is-alive, incarnation-scoped deregistration, and `retitle` scoping. Both concurrency layers have a regression test verified to fail when its mechanism is removed: 8 real processes for the cross-process lock, 24 overlapping in-process calls for the chain. The publisher is tested over the real `SessionStore` rather than a hand-built emitter, because publication depends on the store's actual lifecycle dispatch.
-
-Verified live in tmux against the assembled application: two concurrent TUI sessions in different workspaces both listed, a title appeared after the first turn, clean exit deregistered, and `SIGKILL` left a stale record that the next `dsh list-sessions` pruned and durably rewrote.
diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md
deleted file mode 100644
index 722ac45f2e..0000000000
--- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# Agent Note: 活跃会话注册表与 `dsh list-sessions`
-
-Status: implemented
-
-[English](2026-07-28-live-session-registry-and-dsh-ls.md) | 中文
-
-## 问题
-
-没有任何东西能回答「我此刻正在运行哪些 dsh 会话」。会话散落在多个项目中的用户既无法枚举它们,也无法找回 `--resume` 所需的 id,唯一的来源是打印过它的那个终端的退出行。会话持久化记录了曾经存在过的每个会话,因此它答不了这个问题:它没有存活状态的概念,而且 session、persistence、storage 这几个包里任何位置都没有出现过 `process.pid`。
-
-## 决策
-
-`dsh list-sessions`(别名 `dsh ps`)列出此刻正在运行的会话(会话 id、pid、运行时长、工作区、标题),最新的排在最前,覆盖所有工作区,并提供面向机器的 `--json`。背后由三个包(package)以能力 seam 的形式支撑。
-
-[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md)(`ctx.sessionRegistry`)是 seam:抽象服务契约与记录词汇,使介质将来可以换成数据库而不触及消费方。[`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) 在 Harness home 下的一个加锁保护的 JSON 文件上实现它。[`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) 跟随 `session/created`、`session/disposed` 和 `session/title`,让注册表保持同步。`apps/cli` 在每个启动方接口(TUI、`dsh meta`、headless、web)上都挂载这两个包,而 `dsh list-sessions` 只挂载该服务,不启动任何 agent(智能体)树。不记录任何接口标签:启动方的模式并不是会话的属性,而工作区那一列已经能把 `dsh meta` 会话和项目会话区分开。
-
-### 存活状态是推导出来的,绝不存储
-
-`list()` 用 `kill(pid, 0)` 探测每条记录的 pid,剪除已消亡的记录,并把剪除后的结果写回。未运行 disposer(资源释放)就被杀掉的进程留下的记录,会被下一次读取移除,因此不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。每个进程独有的 `bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。`EPERM` 算作存活:归属于另一个用户的存活会话绝不能被丢掉。
-
-### 两层相互独立的并发机制
-
-该文件既被每个 dsh 进程写入,也被同一进程内的多个会话写入,这两种情形需要不同的机制。
-
-跨进程时,每次读-改-写都持有一个 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。进程内则由各次调用在内部链上排队,因为咨询锁是按进程跟踪的:同一进程中重叠的调用方会争抢它有界的重试预算,而不是排队等待;大约超过十几次并发调用后,该预算耗尽,某次注册就会被拒绝。由于发布采用 fire-and-forget 方式,这样一次拒绝会静默地把一个存活会话从列表中丢掉——而这正是本功能要避免的「列表说谎」故障。两层机制都是必需的,且各有一个测试固定它:移除该机制,对应测试就会失败。
-
-### 记录自带标题
-
-标题是唯一的可变字段,随 `session/title` 事件到达,通过 `retitle` 替换。它存放在记录里,而不是从会话日志读取,因为日志的位置、格式和压缩都是逐部署的后端选择:TUI 写入项目本地的 zstd 压缩 JSONL,web 与 headless 界面写入全局根目录,用户配置文件可以覆盖二者,而 SQLite 根本没有逐会话的文件。独立读取方无法以可移植的方式解析这些内容,因此 `dsh list-sessions` 不打开任何日志,也不假定任何后端。
-
-### subagent 在设计上就不可见
-
-只有顶层启动方接口才挂载发布方。进程内 subagent(`spawn`、`fork`)没有自己的进程,而进程外后端 spawn 的是 `dsh-jsonrpc-agent` 而不是本 CLI(命令行界面)。不需要任何过滤开关,也没有改动任何 subagent 包。
-
-## 考虑过的替代方案
-
-**在 `~/.dsh/run/` 下每个会话一个文件。** 完全不需要锁,因为每个进程只写入和删除自己的文件。不予采纳,改用用户选定的单文件方案,而这也使真正的咨询锁从可选变为必需。
-
-**在 `storage-json` 后端之上做一个 domain。** 这是最显而易见的复用,但它是错的:该后端明确记载「无跨进程写锁……最后写入者胜出」,并把单一宿主进程列为自身前提,而[domain KV 存储 note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)明确把多进程排除在范围之外。被每个启动方并发写入的注册表恰恰就是这个被排除的场景。放宽该后端的契约,等于为一个消费方改动一项已上线的保证;改由一个独立的包拥有这套多进程介质。
-
-**手写 `O_EXCL` 锁目录。** 依据[优先使用依赖而非手写政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:陈旧锁检测、重试退避和受损处理,恰恰是应当由一个有人维护的依赖拥有的那部分工作。
-
-**在单文件上接受最后写入者胜出。** 这是最省事的实现,但当两个会话相近时间启动时,它会静默漏掉真实运行中的会话。一个会说谎的列表工具比没有列表工具更糟。
-
-**在 `dsh list-sessions` 中从会话日志读取标题。** 该方案先落地实现,随后经实机验证否决:上线的 TUI 写入 `session.jsonl.zstd`,其帧处理辅助函数是 jsonl 后端的内部实现。把它们导出,等于把某一个后端的文件格式硬编码进 CLI,而且对 SQLite 仍然什么都显示不出来。
-
-**用占位会话 id 注册 web 服务器本身。** `dsh web` 不拥有任何会话(它的会话由浏览器客户端稍后创建),因此一行服务器记录会把一个假 id 放进会话表。改为跟随会话生命周期后,浏览器会话会随打开与关闭而出现和消失,这同时也涵盖了 TUI 启动方一侧的注册,并删除了那条独立路径。
-
-**加一个 `--here`/`--workspace` 过滤开关。** 按要求放弃:列表始终是全局的,收窄范围交给用户自己的 `grep`。
-
-## 后果
-
-注册表是一项可观测性辅助设施,因此每次写入都是尽力而为:注册表故障只发出警告,绝不让正常工作的 agent 会话失败。代价是列表可能因一次失败的写入而落后于现实一步,并由下一次写入修复。
-
-标题镜像每次修订都要付出一次加锁的读-改-写,因此改名节奏激进时,每次改名都要付出这一次写入。
-
-`bootId` 只对本进程写入的记录约束 pid 复用。如果一条外来记录的 pid 已被操作系统重新分配给一个无关的存活进程,那么在其所有者移除它之前,该记录会被报告为存活——之所以接受,是因为可移植的替代方案(读取进程真实启动时间)仅在 `/proc` 上可用。
-
-存活状态只表示 pid 存在,不表示健康:挂死的进程仍会被列为正在运行。注册表刻意不对进展作出判断。
-
-## 测试
-
-单元覆盖固定了持久格式校验(截断文本、外来版本、不得遮蔽同级记录的单条损坏)、针对真正已回收 pid 的剪除、`EPERM` 算存活、按 incarnation 限定范围的注销,以及 `retitle` 的作用范围。两层并发机制各有一个回归测试,且都已验证在移除对应机制后会失败:跨进程锁用 8 个真实进程,进程内链用 24 次重叠调用。发布方在真实的 `SessionStore` 上测试,而非手搭的事件发射器,因为发布依赖该 store 实际的生命周期派发。
-
-已在 tmux 中针对组装后的应用实机验证:位于不同工作区的两个并发 TUI 会话都被列出,第一轮之后出现标题,正常退出完成注销,而 `SIGKILL` 留下的陈旧记录被下一次 `dsh list-sessions` 剪除并持久重写。
diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml
index 8ece88a40d..40bb3fe946 100644
--- a/apps/cli/base.cordis.yml
+++ b/apps/cli/base.cordis.yml
@@ -53,7 +53,7 @@
# The session store root is the launcher's policy, not a plugin's: `dsh` shares
# one store under the Harness home across every cwd, so `/resume` and
-# `dsh ps` span workspaces. Without a launcher the project-local fallback keeps
+# `/resume` spans workspaces. Without a launcher the project-local fallback keeps
# an embedder's sessions beside its project.
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index f4607ce0f2..a985ecd2ba 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -47,10 +47,6 @@ flowchart LR
pkg_workspace["workspace"]
svc_workspace["ctx.workspace
Workspace entity registry"]
pkg_apiproxy["apiproxy"]
- pkg_session_registry["session-registry"]
- svc_sessionRegistry["ctx.sessionRegistry
Live-session registry"]
- pkg_session_registry_file["session-registry-file"]
- pkg_session_registry_live["session-registry-live"]
svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
pkg_tool_session_query["tool-session-query"]
@@ -200,8 +196,6 @@ flowchart LR
pkg_session_query --> svc_sessionQuery
pkg_session_query_sqlite --> svc_sessionQuery
pkg_session_reference --> svc_sessionReferences
- pkg_session_registry --> svc_sessionRegistry
- pkg_session_registry_file --> svc_sessionRegistry
pkg_session_telemetry --> svc_telemetry
pkg_session_telemetry_otel --> svc_telemetry
pkg_session_title --> svc_sessionTitle
@@ -283,7 +277,6 @@ flowchart LR
svc_sessionQuery --> pkg_session_reference
svc_sessionQuery --> pkg_tool_session_query
svc_sessionReferences --> pkg_tui
- svc_sessionRegistry --> pkg_session_registry_live
svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_cli_demo
@@ -344,7 +337,6 @@ flowchart LR
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
-| `ctx.sessionRegistry` | `seam` | [`session-registry`](../packages/session-registry/session-registry) | [`session-registry-file`](../packages/session-registry/session-registry-file) | [`session-registry-live`](../packages/session-registry/session-registry-live) | - | Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. |
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
| `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 870c00e051..49e23c662e 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1153,26 +1153,6 @@ export interface Config {
Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts)
-## `@deepseek-ai/dsh-session-registry-file`
-
-```ts config-catalog
-/**
- * Plugin config as callers write it: `root` is required — a cwd fallback would
- * scatter registries — while the lock tunables are optional because
- * `static Config` supplies their defaults.
- */
-export interface Config {
- /** Directory holding the registry file; created `0o700` on demand. */
- root: string
- /** Milliseconds after which a held lock is considered abandoned and reclaimed. */
- lockStaleMs?: number
- /** Retries before a contended acquisition fails loud. */
- lockRetries?: number
-}
-```
-
-Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts)
-
## `@deepseek-ai/dsh-session-telemetry-otel`
Requires: `sessions`
@@ -2194,7 +2174,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
- `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts))
-- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts))
- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts))
@@ -2217,7 +2196,6 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
-- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts))
- `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts))
- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts))
- `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index f576af1243..59a5405bfb 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1464,44 +1464,6 @@ Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-s
Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts)
-## `ctx.sessionRegistry` — `SessionRegistry` (abstract seam)
-
-Cross-process live-session registry. Reads prune dead records, so every returned record's process existed at observation time. Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write.
-
-```ts cordis-catalog
-/**
- * Publish this process's record, replacing any stale record for the same
- * session id, and prune records whose process is gone.
- * @param registration - the session, surface, and workspace to publish.
- * @returns the effect disposer that removes this record again; awaiting it
- * waits for the removal to reach durability.
- */
-abstract register(registration: SessionRegistration): Promise<() => Promise>
-
-/**
- * Replace the recorded title of a session this process registered.
- *
- * Titles arrive after registration and can be revised, so this is the one
- * mutable field. Only a record matching this process and incarnation is
- * touched, leaving a same-id record owned by another process alone. An unknown
- * session id is a no-op rather than an error: a title can resolve after the
- * session's record has already been removed.
- * @param sessionId - the session whose recorded title changes.
- * @param title - the new title text.
- */
-abstract retitle(sessionId: SessionId, title: string): Promise
-
-/**
- * List live sessions, pruning records whose process no longer exists.
- * @returns one record per live registered session, newest registration last.
- */
-abstract list(): Promise
-```
-
-Types: [SessionId](../core-data-structures/core.md)
-
-Source: [`packages/session-registry/session-registry/src/index.ts:44`](../../packages/session-registry/session-registry/src/index.ts)
-
## `ctx.sessions` — `SessionStore`
In-memory session store (`ctx.sessions`).
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 13448e2c5b..141f770748 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
diff --git a/knip.json b/knip.json
index e40f343dff..b79a4d8977 100644
--- a/knip.json
+++ b/knip.json
@@ -288,7 +288,6 @@
"tests/**/*.ts"
]
},
- "packages/session-registry/session-registry-file": {
"entry": [
"tests/**/*.spec.ts",
"tests/fixtures/register-once.ts"
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index a27c126b75..d238897cf3 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -688,24 +688,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
- {
- key: 'sessionRegistry',
- summary: 'Cross-process live-session registry.',
- methods: [
- {
- signature: 'abstract register(registration: SessionRegistration): Promise<() => Promise>',
- jsDoc: '/**\n * Publish this process\'s record, replacing any stale record for the same\n * session id, and prune records whose process is gone.\n * @param registration - the session, surface, and workspace to publish.\n * @returns the effect disposer that removes this record again; awaiting it\n * waits for the removal to reach durability.\n */',
- },
- {
- signature: 'abstract retitle(sessionId: SessionId, title: string): Promise',
- jsDoc: '/**\n * Replace the recorded title of a session this process registered.\n *\n * Titles arrive after registration and can be revised, so this is the one\n * mutable field. Only a record matching this process and incarnation is\n * touched, leaving a same-id record owned by another process alone. An unknown\n * session id is a no-op rather than an error: a title can resolve after the\n * session\'s record has already been removed.\n * @param sessionId - the session whose recorded title changes.\n * @param title - the new title text.\n */',
- },
- {
- signature: 'abstract list(): Promise',
- jsDoc: '/**\n * List live sessions, pruning records whose process no longer exists.\n * @returns one record per live registered session, newest registration last.\n */',
- },
- ],
- },
{
key: 'sessions',
summary: 'In-memory session store (`ctx.sessions`).',
@@ -2270,12 +2252,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [
declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}',
},
{
- name: 'SessionRegistration',
- declaration: 'export interface SessionRegistration {\n sessionId: SessionId;\n cwd: string;\n title?: string;\n}',
},
{
- name: 'SessionRegistryRecord',
- declaration: 'export interface SessionRegistryRecord {\n readonly sessionId: SessionId;\n readonly pid: number;\n readonly cwd: string;\n readonly startedAt: number;\n readonly bootId: BootId;\n readonly title?: string;\n}',
},
{
name: 'SessionResultFilter',
diff --git a/packages/session-registry/README.i18n.yaml b/packages/session-registry/README.i18n.yaml
deleted file mode 100644
index 6eb22cf334..0000000000
--- a/packages/session-registry/README.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write packages/session-registry/README.md
-README.md: c79caccd05d6cbdda0663dd897490fb6004b8250
-README.zh.md: c3fff3bc6b0e0417dd291d129d7fb1001b50258f
diff --git a/packages/session-registry/README.md b/packages/session-registry/README.md
deleted file mode 100644
index c79caccd05..0000000000
--- a/packages/session-registry/README.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# session-registry/ — live-session registry family
-
-English | [中文](README.zh.md)
-
-Which sessions are running right now, readable from a different process. `dsh list-sessions` is the consumer.
-
-| Package | Role | ctx key |
-|---|---|---|
-| [`session-registry/`](session-registry/README.md) | The seam: abstract registry service contract and record vocabulary | `ctx.sessionRegistry` |
-| [`session-registry-file/`](session-registry-file/README.md) | Backend: one lock-guarded JSON file, pid-derived liveness | — |
-| [`session-registry-live/`](session-registry-live/README.md) | Publisher: follows session lifecycle and title events, keeping the registry in step | — |
-
-The split follows the three-package capability-seam convention: the seam answers "what is live" for a short-lived reader that mounts nothing else, the file backend owns today's medium and can be replaced by a database without touching consumers, and the publisher needs the session store and runs inside a full agent composition. Liveness is derived from the recorded pid at read time rather than stored, so a killed process leaves nothing to clean up. Records carry their own title because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse.
-
-This family is independent of session persistence: it records which processes hold which sessions, never conversation content, and a session that is never persisted still lists.
diff --git a/packages/session-registry/README.zh.md b/packages/session-registry/README.zh.md
deleted file mode 100644
index c3fff3bc6b..0000000000
--- a/packages/session-registry/README.zh.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# session-registry/:活跃会话注册表家族
-
-[English](README.md) | 中文
-
-当前正在运行哪些会话,可以从另一个进程读取。消费方是 `dsh list-sessions`。
-
-| 包 | 职责 | ctx 键 |
-|---|---|---|
-| [`session-registry/`](session-registry/README.md) | seam:抽象注册表服务契约与记录词汇 | `ctx.sessionRegistry` |
-| [`session-registry-file/`](session-registry-file/README.md) | 后端:单个加锁保护的 JSON 文件、由 pid 推导的存活状态 | — |
-| [`session-registry-live/`](session-registry-live/README.md) | 发布方:跟随会话生命周期与标题事件,让注册表保持同步 | — |
-
-这样拆分遵循由三个包构成的能力 seam 惯例:seam 要回答「哪些会话是活跃的」,供一个不挂载其他任何东西的短生命周期读取方使用;文件后端拥有今天的介质,将来可以换成数据库而不触及消费方;发布方需要会话存储,运行在完整的 agent(智能体)组合体内。存活状态在读取时由记录的 pid 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。
-
-这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。
diff --git a/packages/session-registry/session-registry-file/README.i18n.yaml b/packages/session-registry/session-registry-file/README.i18n.yaml
deleted file mode 100644
index d08b25d86f..0000000000
--- a/packages/session-registry/session-registry-file/README.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-file/README.md
-README.md: b6f29a459e5f7f6484aed9f4968d54f969ca6e73
-README.zh.md: 35861466ed5fb826ee118c506943c3a08024a827
diff --git a/packages/session-registry/session-registry-file/README.md b/packages/session-registry/session-registry-file/README.md
deleted file mode 100644
index b6f29a459e..0000000000
--- a/packages/session-registry/session-registry-file/README.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# @deepseek-ai/dsh-session-registry-file
-
-English | [中文](README.zh.md)
-
-File-backed implementation of the [live-session registry seam](../session-registry/README.md): one lock-guarded JSON file under the Harness home is the whole medium. Mounting it publishes `ctx.sessionRegistry`; `file` exposes the absolute registry path (`/sessions.json`).
-
-## Liveness and crash safety
-
-Liveness is derived at read time from the recorded pid via `kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive under another user, and any other errno propagates rather than being read as an answer. A process killed without running its disposer therefore leaves a record that the next `list()` prunes and rewrites — no daemon, no heartbeat, and no permanent phantom. `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record belonging to a different incarnation.
-
-## Concurrency
-
-Both layers are required and neither substitutes for the other.
-
-- **Across processes**, each read-modify-write cycle holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Unlocked whole-file republication loses records under concurrent launchers, which is why the storage-hub JSON backend — documented last-write-wins, single-host-process — cannot serve this medium.
-- **Within one process**, calls queue on an internal chain. The advisory lock is tracked per process, so overlapping same-process callers contend for its bounded retry budget instead of queueing; past roughly a dozen concurrent calls that budget runs out and a registration rejects. Callers publish fire-and-forget, so such a rejection would silently drop a live session from the listing.
-
-Writes are temp-file plus atomic `rename` (no fsync: a listing lost to a crash is rebuilt by the next process's read, so crash durability buys nothing here), under a `0o700` root with a `0o600` file.
-
-## Durable format
-
-`sessions.json` carries a `version` stamp pinned at `0` under the pre-release stance: a differing version is rejected rather than migrated. Reads validate every field because the medium is shared and user-visible. An individually unusable row is dropped while its siblings survive, and unparsable text or a foreign version reads as empty — one malformed record written by another harness version must not hide every other live session. Any of these marks the medium damaged, so the next write republishes and heals it.
-
-## Config
-
-| Key | Type | Default | Meaning |
-| --- | --- | --- | --- |
-| `root` | string | required — no default (a cwd fallback would scatter registries) | Directory holding `sessions.json`; created `0o700` on demand |
-| `lockStaleMs` | natural | `10000` | Milliseconds after which a held lock is treated as abandoned and reclaimed |
-| `lockRetries` | natural | `10` | Retries before a contended acquisition fails loud |
-
-## Model Experience
-
-None, as this package registers no tools, injects no prompts, and appends no session events; it stores host-side process records for the CLI listing surface only.
-
-#### KV Cache effect
-
-Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse.
-
-## Known Limitations and Deferred Work
-
-- **A reused pid within the stale window is trusted** — `bootId` distinguishes incarnations of records this process wrote, but a foreign record whose pid the operating system has since reassigned to an unrelated live process is reported alive until its owner removes it.
diff --git a/packages/session-registry/session-registry-file/README.zh.md b/packages/session-registry/session-registry-file/README.zh.md
deleted file mode 100644
index 35861466ed..0000000000
--- a/packages/session-registry/session-registry-file/README.zh.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# @deepseek-ai/dsh-session-registry-file
-
-[English](README.md) | 中文
-
-[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry`;`file` 暴露注册表文件的绝对路径(`/sessions.json`)。
-
-## 存活状态与崩溃安全
-
-存活状态在读取时由记录的 pid 经 `kill(pid, 0)` 推导:`ESRCH` 表示已消亡,`EPERM` 表示存活于另一个用户之下,其他任何 errno 都向外抛出,而不会被当成一个答案来解读。因此,未运行 disposer 就被杀掉的进程留下的记录,会被下一次 `list()` 剪除并重写——不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。`bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。
-
-## 并发
-
-两层机制都是必需的,任何一层都无法替代另一层。
-
-- **跨进程**:每个读改写周期都持有 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。无锁的全文件重发布会在并发启动器下丢失记录,这正是 storage-hub JSON 后端(文档声明 last-write-wins、单宿主进程)无法承担该介质的原因。
-- **进程内**:调用在内部链上排队。咨询锁按进程跟踪,因此同进程的重叠调用者会争用其有限的重试预算而非排队;并发调用超过十来个时预算耗尽,注册会被拒绝。调用方以 fire-and-forget 方式发布,这样的拒绝会静默地把一个存活会话从列表中丢掉。
-
-写入采用临时文件加原子 `rename`(不做 fsync:崩溃丢失的列表会被下一个进程的读取重建,崩溃持久性在这里没有收益),根目录 `0o700`,文件 `0o600`。
-
-## 持久化格式
-
-`sessions.json` 携带一个 `version` 戳,在预发布立场下固定为 `0`:版本不同将被拒绝而非迁移。由于介质是共享且用户可见的,读取会校验每个字段。单条不可用的行会被丢弃而其同伴保留;无法解析的文本或异版本文件读作空——另一个 harness 版本写入的一条损坏记录,不得隐藏所有其他存活会话。上述任一情况都会把介质标记为受损,下一次写入将重新发布并修复它。
-
-## 配置
-
-| 键 | 类型 | 默认值 | 含义 |
-| --- | --- | --- | --- |
-| `root` | string | 必填——无默认值(回退到 cwd 会使注册表散落各处) | 存放 `sessions.json` 的目录;按需以 `0o700` 创建 |
-| `lockStaleMs` | natural | `10000` | 持有的锁超过该毫秒数即视为被遗弃并被回收 |
-| `lockRetries` | natural | `10` | 锁争用时在明确失败前的重试次数 |
-
-## 模型体验
-
-无。本包不注册工具、不注入提示词、不追加会话事件;它只为 CLI 列表界面存储宿主侧进程记录。
-
-#### KV 缓存影响
-
-与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。
-
-## 已知限制与后续工作
-
-- **陈旧窗口内被复用的 pid 会被信任**——`bootId` 能区分本进程所写记录的 incarnation,但外来记录的 pid 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。
diff --git a/packages/session-registry/session-registry-file/package.json b/packages/session-registry/session-registry-file/package.json
deleted file mode 100644
index 321a59902d..0000000000
--- a/packages/session-registry/session-registry-file/package.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "name": "@deepseek-ai/dsh-session-registry-file",
- "description": "Lock-guarded JSON-file backend for the dsh live-session registry seam",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "lib/index.js",
- "types": "lib/types/index.d.ts",
- "exports": {
- ".": {
- "types": "./lib/types/index.d.ts",
- "default": "./lib/index.js"
- },
- "./src/*": "./src/*",
- "./package.json": "./package.json",
- "./invariant": {
- "types": "./lib/types/invariant.d.ts",
- "default": "./lib/invariant.js"
- }
- },
- "files": [
- "lib/index.js",
- "lib/invariant.js",
- "lib/types/**/*.d.ts",
- "lib/types/**/*.d.ts.map",
- "src"
- ],
- "license": "BSD-3-Clause",
- "dependencies": {
- "proper-lockfile": "^4.1.2",
- "schemastery": "^3.15.0"
- },
- "peerDependencies": {
- "@deepseek-ai/dsh-invariants": "^0.0.1",
- "@deepseek-ai/dsh-session": "^0.0.1",
- "@deepseek-ai/dsh-session-registry": "^0.0.1",
- "cordis": "^4.0.0-rc.6"
- },
- "devDependencies": {
- "@deepseek-ai/dsh-brand": "workspace:^",
- "@deepseek-ai/dsh-invariants": "workspace:^",
- "@deepseek-ai/dsh-session": "workspace:^",
- "@deepseek-ai/dsh-session-registry": "workspace:^",
- "@types/proper-lockfile": "^4.1.4",
- "cordis": "^4.0.0-rc.6"
- }
-}
diff --git a/packages/session-registry/session-registry-file/src/file.ts b/packages/session-registry/session-registry-file/src/file.ts
deleted file mode 100644
index 0c9a5775fb..0000000000
--- a/packages/session-registry/session-registry-file/src/file.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * Registry file format: the durable boundary between independent `dsh`
- * processes. Every field is validated on read because the medium is shared,
- * user-visible, and writable by other harness versions — a foreign or truncated
- * file must not crash `dsh list-sessions` into an empty listing that hides live sessions.
- * @module @deepseek-ai/dsh-session-registry-file/file
- */
-
-import { SessionId } from '@deepseek-ai/dsh-session'
-import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
-
-/**
- * On-disk format version. Pinned at `0` under the pre-release stance: a
- * differing version is rejected rather than migrated, matching every other
- * harness backend.
- */
-export const SESSION_REGISTRY_FORMAT_VERSION = 0
-
-/** The complete registry file: a version stamp plus the live records. */
-export interface RegistryFileContents {
- /** Format stamp, always {@link SESSION_REGISTRY_FORMAT_VERSION} when written. */
- readonly version: number
- /** One record per registered process, in no significant order. */
- readonly records: readonly SessionRegistryRecord[]
-}
-
-/** An empty registry: the value a missing file reads as. */
-export const EMPTY_REGISTRY: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records: [] }
-
-/** Narrow an unknown JSON value to a record shape, or reject it as unusable. */
-function parseRecord(value: unknown): SessionRegistryRecord | undefined {
- if (typeof value !== 'object' || value === null) return undefined
- const row = value as Record
- const { sessionId, pid, cwd, startedAt, bootId } = row
- if (typeof sessionId !== 'string' || sessionId === '') return undefined
- // A non-integer or non-positive pid cannot be probed for liveness.
- if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined
- if (typeof cwd !== 'string' || cwd === '') return undefined
- if (typeof startedAt !== 'number' || !Number.isSafeInteger(startedAt) || startedAt < 0) return undefined
- if (typeof bootId !== 'string' || bootId === '') return undefined
- // An absent title is legal (a fresh session has none); a present but
- // non-string one is a damaged row rather than a missing optional field.
- const { title } = row
- if (title !== undefined && typeof title !== 'string') return undefined
- return {
- sessionId: SessionId(sessionId),
- pid,
- cwd,
- startedAt,
- bootId: BootId(bootId),
- ...title !== undefined && { title },
- }
-}
-
-/**
- * Parse registry file text into records, dropping individually unusable rows.
- *
- * A row that cannot be interpreted is dropped rather than rejected wholesale:
- * one malformed record written by a different harness version must not hide
- * every other live session. Unparsable text and a version mismatch yield an
- * empty registry for the same reason — the caller republishes the whole file, so
- * the next write heals the medium.
- * @param text - the raw file contents.
- * @returns the records that parsed, and whether the text was fully understood.
- */
-export function parseRegistry(text: string): { records: SessionRegistryRecord[]; intact: boolean } {
- let parsed: unknown
- try {
- parsed = JSON.parse(text)
- } catch {
- // Swallows only SyntaxError from this one JSON.parse: a torn or foreign
- // file heals on the next write, and nothing else can reach this catch.
- return { records: [], intact: false }
- }
- if (typeof parsed !== 'object' || parsed === null) return { records: [], intact: false }
- const file = parsed as Record
- if (file.version !== SESSION_REGISTRY_FORMAT_VERSION) return { records: [], intact: false }
- if (!Array.isArray(file.records)) return { records: [], intact: false }
- const records: SessionRegistryRecord[] = []
- let intact = true
- for (const row of file.records) {
- const record = parseRecord(row)
- if (record === undefined) intact = false
- else records.push(record)
- }
- return { records, intact }
-}
-
-/**
- * Serialize records as registry file text.
- * @param records - the live records to publish.
- * @returns pretty-printed JSON with a trailing newline, for a legible medium.
- */
-export function serializeRegistry(records: readonly SessionRegistryRecord[]): string {
- const file: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records }
- return `${JSON.stringify(file, undefined, 2)}\n`
-}
diff --git a/packages/session-registry/session-registry-file/src/index.ts b/packages/session-registry/session-registry-file/src/index.ts
deleted file mode 100644
index 41e080b79a..0000000000
--- a/packages/session-registry/session-registry-file/src/index.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-/**
- * File-backed live-session registry: one lock-guarded JSON file under the
- * Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every
- * operation is a read-modify-write under an advisory lock, because concurrent
- * launchers write the same file — the storage-hub JSON backend documents
- * last-write-wins for exactly this case and cannot be reused. Liveness is
- * derived at read time from the recorded pid.
- * @module @deepseek-ai/dsh-session-registry-file
- */
-
-import { randomUUID } from 'node:crypto'
-import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises'
-import { dirname, join } from 'node:path'
-import type { Context } from 'cordis'
-import lockfile from 'proper-lockfile'
-import z from 'schemastery'
-import type { SessionId } from '@deepseek-ai/dsh-session'
-import {
- SessionRegistry, BootId,
- type SessionRegistration, type SessionRegistryRecord,
-} from '@deepseek-ai/dsh-session-registry'
-import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts'
-import { isPidAlive } from './liveness.ts'
-
-export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts'
-export type { RegistryFileContents } from './file.ts'
-export { isPidAlive } from './liveness.ts'
-
-/** The file name holding the registry, relative to {@link Config.root}. */
-export const REGISTRY_FILE_NAME = 'sessions.json'
-
-/** Default lock staleness threshold; a held lock older than this is reclaimed. */
-const DEFAULT_LOCK_STALE_MS = 10_000
-
-/** Default retry budget for a contended lock acquisition. */
-const DEFAULT_LOCK_RETRIES = 10
-
-/**
- * Plugin config as callers write it: `root` is required — a cwd fallback would
- * scatter registries — while the lock tunables are optional because
- * `static Config` supplies their defaults.
- */
-export interface Config {
- /** Directory holding the registry file; created `0o700` on demand. */
- root: string
- /** Milliseconds after which a held lock is considered abandoned and reclaimed. */
- lockStaleMs?: number
- /** Retries before a contended acquisition fails loud. */
- lockRetries?: number
-}
-
-/** The file-backed {@link SessionRegistry} implementation. */
-export class SessionRegistryFile extends SessionRegistry {
- static Config: z = z.object({
- root: z.string().required(),
- lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS),
- lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES),
- })
-
- /** Absolute path of the registry file this service reads and writes. */
- readonly file: string
-
- /** Directory holding {@link file}, created `0o700` on demand. */
- private readonly root: string
-
- /** Tail of the in-process serialization chain; see {@link mutate}. */
- private chain: Promise = Promise.resolve()
-
- /** Resolved lock staleness threshold in milliseconds, fixed at construction. */
- private readonly stale: number
-
- /** Resolved contended-acquisition retry budget, fixed at construction. */
- private readonly retries: number
-
- constructor(ctx: Context, config: Config) {
- super(ctx, BootId(randomUUID()))
- this.root = config.root
- this.file = join(this.root, REGISTRY_FILE_NAME)
- // Resolve the optional tunables here, once: `static Config` supplies these
- // same defaults for a Loader mount, and a direct programmatic mount that
- // omits them gets them too rather than an undefined lock option.
- this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS
- this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES
- }
-
- /** @inheritdoc */
- async register(registration: SessionRegistration): Promise<() => Promise> {
- const record: SessionRegistryRecord = {
- sessionId: registration.sessionId,
- pid: process.pid,
- cwd: registration.cwd,
- startedAt: Date.now(),
- bootId: this.bootId,
- ...registration.title !== undefined && { title: registration.title },
- }
- await this.mutate(records => [
- ...records.filter(other => other.sessionId !== record.sessionId),
- record,
- ])
- // The disposer is awaited by Cordis teardown, so the record is durably gone
- // before disposal completes rather than racing process exit. A failure here
- // is reported, not thrown: the record is already pid-prunable, and an
- // unwinding teardown must not be turned into a rejection.
- return this.ctx.effect(() => async () => {
- try {
- await this.mutate(records => records.filter(other => !this.isSelf(other, record)))
- } catch (error) {
- this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error))
- }
- })
- }
-
- /** @inheritdoc */
- async retitle(sessionId: SessionId, title: string): Promise {
- await this.mutate(records => records.map(record =>
- record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId
- ? { ...record, title }
- : record))
- }
-
- /** @inheritdoc */
- async list(): Promise {
- // Pruning is a write, so the read path takes the same lock: a listing that
- // observed a half-written file could omit a live session.
- return this.mutate(records => [...records])
- }
-
- /** True when a stored record is this exact registration (pid AND incarnation). */
- private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean {
- return candidate.sessionId === self.sessionId
- && candidate.pid === self.pid
- && candidate.bootId === self.bootId
- }
-
- /**
- * Serialize one read-modify-write cycle against every other cycle in THIS
- * process, then run it under the cross-process lock.
- *
- * Both layers are required and neither substitutes for the other. The advisory
- * lock excludes other processes but is tracked per process, so it rejects a
- * same-process concurrent acquisition outright (`ELOCKED`) instead of queueing
- * — and a composition that creates several sessions at once really does
- * overlap these calls. This chain gives those callers a queue; the lock gives
- * independent processes exclusion.
- */
- private mutate(
- change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[],
- ): Promise {
- // Failures must not poison the chain for later callers, so the tail only
- // tracks settlement, never the rejection itself.
- const result = this.chain.then(() => this.mutateExclusively(change))
- this.chain = result.then(() => undefined, () => undefined)
- return result
- }
-
- /**
- * Run one locked read-modify-write cycle: read, prune dead records, apply
- * `change`, and republish when the result differs from what was stored.
- */
- private async mutateExclusively(
- change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[],
- ): Promise {
- await mkdir(this.root, { recursive: true, mode: 0o700 })
- // proper-lockfile needs the target to exist before it can guard it; an
- // exclusive create loses harmlessly to a concurrent launcher doing the same.
- await this.ensureFile()
- const release = await lockfile.lock(this.file, {
- stale: this.stale,
- retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 },
- })
- try {
- const before = await this.read()
- const live = before.records.filter(record => isPidAlive(record.pid))
- const next = change(live)
- // Republish when a record changed or the medium itself was damaged, so a
- // foreign or torn file heals instead of being re-parsed on every read.
- if (!before.intact || !sameRecords(before.records, next)) await this.write(next)
- return next
- } finally {
- await release()
- }
- }
-
- /** Create the registry file if absent, without disturbing existing content. */
- private async ensureFile(): Promise {
- try {
- const handle = await open(this.file, 'wx', 0o600)
- try {
- await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records))
- } finally {
- await handle.close()
- }
- } catch (error) {
- // Swallows only EEXIST: another launcher created the file first, which is
- // the intended outcome. Every other errno propagates.
- /* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
- }
- }
-
- /** Read and parse the registry file; a missing file reads as empty. */
- private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> {
- // The caller holds the lock, and acquiring it requires the file to exist, so
- // a read failure here is real corruption rather than an absent registry and
- // propagates: a swallowed error would report "no live sessions" for a medium
- // that could not be read.
- return parseRegistry(await readFile(this.file, 'utf8'))
- }
-
- /** Publish the complete record set via temp-write plus atomic rename. */
- private async write(records: readonly SessionRegistryRecord[]): Promise {
- const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`)
- await writeFile(temp, serializeRegistry(records), { mode: 0o600 })
- await rename(temp, this.file)
- }
-}
-
-/** Compare record lists by identity fields, to decide whether a write is needed. */
-function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean {
- if (left.length !== right.length) return false
- return left.every((record, index) => {
- const other = right[index]
- return other !== undefined
- && record.sessionId === other.sessionId
- && record.pid === other.pid
- && record.bootId === other.bootId
- && record.cwd === other.cwd
- && record.startedAt === other.startedAt
- && record.title === other.title
- })
-}
-
-export default SessionRegistryFile
diff --git a/packages/session-registry/session-registry-file/src/invariant.ts b/packages/session-registry/session-registry-file/src/invariant.ts
deleted file mode 100644
index 9fd175abc4..0000000000
--- a/packages/session-registry/session-registry-file/src/invariant.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-file`.
- * @module @deepseek-ai/dsh-session-registry-file/invariant
- */
-
-import type { Context } from 'cordis'
-import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
-
-const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-file'
-
-/** Cordis companion plugin name. */
-export const name = 'session-registry-file-invariant'
-/** Service required before the companion can reserve package ownership. */
-export const inject = ['invariants']
-
-/**
- * No runtime invariant: the relations a reader must trust (unique live session
- * ids, attributable pids) are contract-level and validated by the seam's
- * companion around the authoritative `list()`, whatever backend serves it. The
- * file medium's own correctness — locking, atomic republication, and
- * foreign-row rejection — requires cross-process round-trip tests, not a
- * continuously observable in-process relation.
- */
-const install: InvariantInstaller = () => {}
-
-/**
- * Register this package's invariant companion.
- * @param ctx - Cordis context carrying the invariant service.
- * @returns the installed registration's disposer after setup succeeds.
- */
-export const apply = (ctx: Context): Promise<() => void> =>
- Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/session-registry/session-registry-file/src/liveness.ts b/packages/session-registry/session-registry-file/src/liveness.ts
deleted file mode 100644
index ff7421edd9..0000000000
--- a/packages/session-registry/session-registry-file/src/liveness.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Process-liveness probe for stored registry records.
- * @module @deepseek-ai/dsh-session-registry-file/liveness
- */
-
-/**
- * Signal-0 probe: report whether a pid currently exists.
- *
- * `kill(pid, 0)` sends no signal and only tests existence. `ESRCH` means no such
- * process. `EPERM` means the process exists but is owned by another user, which
- * is still alive — reporting it dead would drop a live record. Any other errno
- * is unexpected and propagates rather than being read as a liveness answer.
- * @param pid - the operating-system process id to probe.
- * @param kill - signal sender, defaulting to `process.kill`; injected by tests.
- * @returns whether a process with this pid exists.
- */
-export function isPidAlive(
- pid: number,
- kill: (pid: number, signal: number) => void = process.kill.bind(process),
-): boolean {
- try {
- kill(pid, 0)
- return true
- } catch (error) {
- const code = (error as NodeJS.ErrnoException).code
- if (code === 'ESRCH') return false
- if (code === 'EPERM') return true
- throw error
- }
-}
diff --git a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts
deleted file mode 100644
index 56e05d11fd..0000000000
--- a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * Concurrency-test driver: register one session in a real separate process,
- * report readiness on stdout, then stay alive until the parent closes stdin.
- *
- * Staying alive is load-bearing. The registry prunes records whose process is
- * gone, so a driver that exited after writing would be pruned by the next
- * writer — the test would then measure pruning instead of the concurrent
- * read-modify-write it exists to cover. Argv: ` `.
- */
-
-import { Context } from 'cordis'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
-
-const [root, sessionId] = process.argv.slice(2)
-if (root === undefined || sessionId === undefined) throw new Error('usage: register-once ')
-
-const ctx = new Context()
-await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 60 })
-await ctx.sessionRegistry.register({ sessionId: SessionId(sessionId), cwd: process.cwd() })
-process.stdout.write('registered\n')
-
-// Hold the process open so its record stays live; the parent ends the run by
-// closing stdin, and never disposes the fiber, so no deregistration races the
-// parent's read.
-process.stdin.resume()
-process.stdin.on('end', () => { process.exit(0) })
diff --git a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts
deleted file mode 100644
index e48bd46385..0000000000
--- a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts
+++ /dev/null
@@ -1,382 +0,0 @@
-/**
- * Tests for the cross-process live-session registry: records survive a
- * round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign
- * record, the file format rejects foreign and torn media without hiding live
- * sessions, disposal deregisters, and concurrent registrations from independent
- * processes all survive (the failure the advisory lock exists to prevent).
- */
-
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { Context } from 'cordis'
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
-import { execFile, spawn } from 'node:child_process'
-import { tmpdir } from 'node:os'
-import { fileURLToPath } from 'node:url'
-import { join } from 'node:path'
-import { promisify } from 'node:util'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import { BootId } from '@deepseek-ai/dsh-session-registry'
-import SessionRegistryFile, {
- REGISTRY_FILE_NAME,
- SESSION_REGISTRY_FORMAT_VERSION,
- isPidAlive,
- parseRegistry,
- serializeRegistry,
-} from '@deepseek-ai/dsh-session-registry-file'
-
-const run = promisify(execFile)
-
-let root: string
-
-beforeEach(() => {
- root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-'))
-})
-afterEach(() => {
- rmSync(root, { recursive: true, force: true })
-})
-
-/** Mount the service on a fresh Cordis fiber, returning it with its context. */
-async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> {
- const ctx = new Context()
- await ctx.plugin(SessionRegistryFile, { root })
- return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile }
-}
-
-const file = (): string => join(root, REGISTRY_FILE_NAME)
-
-describe('config resolution', () => {
- it('applies the shipped lock defaults when a caller omits them', async () => {
- // `ctx.plugin` runs the schema, which fills these in, so the constructor's
- // own resolution is reachable only by constructing the service directly —
- // the path a programmatic embedder takes.
- const ctx = new Context()
- const service = new SessionRegistryFile(ctx, { root })
- await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' })
- expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted'])
- await ctx.fiber.dispose()
- })
-
- it('honors explicitly configured lock tunables', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 })
- await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' })
- expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned'])
- await ctx.fiber.dispose()
- })
-})
-
-describe('register and list', () => {
- it('publishes a record readable by an independent service instance', async () => {
- const first = await service()
- await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' })
-
- // A second instance stands in for another process reading the same file.
- const reader = await service()
- const listed = await reader.registry.list()
- expect(listed).toHaveLength(1)
- expect(listed[0]).toMatchObject({
- sessionId: 'sess-1',
- cwd: '/tmp/project',
- pid: process.pid,
- })
- await first.ctx.fiber.dispose()
- await reader.ctx.fiber.dispose()
- })
-
- it('replaces an earlier record for the same session id', async () => {
- const { ctx, registry } = await service()
- await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
- await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' })
- const listed = await registry.list()
- expect(listed).toHaveLength(1)
- // The later registration wins: `cwd` distinguishes the two calls.
- expect(listed[0]?.cwd).toBe('/b')
- await ctx.fiber.dispose()
- })
-
- it('creates the registry root private and the file owner-only', async () => {
- const { ctx, registry } = await service()
- await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
- expect(statSync(root).mode & 0o777).toBe(0o700)
- expect(statSync(file()).mode & 0o777).toBe(0o600)
- await ctx.fiber.dispose()
- })
-})
-
-describe('liveness pruning', () => {
- it('drops a record whose process is gone', async () => {
- const { ctx, registry } = await service()
- await registry.register({ sessionId: SessionId('live'), cwd: '/a' })
-
- // A real exited pid: spawn a process, wait for it, then claim its id. The
- // kernel has reaped it, so signal 0 reports ESRCH.
- const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'])
- const deadPid = Number(dead.stdout)
- expect(isPidAlive(deadPid)).toBe(false)
- const stored = parseRegistry(readFileSync(file(), 'utf8')).records
- writeFileSync(file(), serializeRegistry([
- ...stored,
- { sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') },
- ]))
-
- const listed = await registry.list()
- expect(listed.map(record => record.sessionId)).toEqual(['live'])
- // The prune is durable, not just filtered in memory.
- expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live'])
- await ctx.fiber.dispose()
- })
-
- it('keeps a live record owned by another user (EPERM means alive)', () => {
- const eperm = (): never => {
- const error = new Error('operation not permitted') as NodeJS.ErrnoException
- error.code = 'EPERM'
- throw error
- }
- expect(isPidAlive(1, eperm)).toBe(true)
- })
-
- it('propagates an unexpected errno instead of guessing liveness', () => {
- const einval = (): never => {
- const error = new Error('invalid') as NodeJS.ErrnoException
- error.code = 'EINVAL'
- throw error
- }
- expect(() => isPidAlive(1, einval)).toThrow('invalid')
- })
-})
-
-describe('pid recycling', () => {
- it('deregistration removes only this incarnation, not a namesake pid', async () => {
- const { ctx, registry } = await service()
- const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' })
-
- // A foreign record reusing THIS live pid under a different session and boot
- // id: deregistering must not delete it.
- const stored = parseRegistry(readFileSync(file(), 'utf8')).records
- writeFileSync(file(), serializeRegistry([
- ...stored,
- { sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') },
- ]))
-
- // Awaiting the disposer is the contract: the record is durably gone when it
- // settles, so the assertion needs no timing slack.
- await disposer()
- const listed = await registry.list()
- expect(listed.map(record => record.sessionId)).toEqual(['other'])
- await ctx.fiber.dispose()
- })
-})
-
-describe('file format', () => {
- it('round-trips records', () => {
- const records = [{
- sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'),
- }]
- expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true })
- })
-
- it('stamps the format version', () => {
- const stamped = JSON.parse(serializeRegistry([])) as { version: number }
- expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION)
- })
-
- it.each([
- ['torn json', '{"version":0,"records":[{'],
- ['a foreign version', '{"version":99,"records":[]}'],
- ['a non-object root', '[]'],
- ['a null root', 'null'],
- ['a non-array records field', '{"version":0,"records":{}}'],
- ])('reads %s as an empty, non-intact registry', (_label, text) => {
- expect(parseRegistry(text)).toEqual({ records: [], intact: false })
- })
-
- it.each([
- ['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }],
- ['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }],
- ['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }],
- ['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }],
- ['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }],
- ['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }],
- ['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }],
- ['a non-object row', 'nonsense'],
- ])('drops a row with %s but keeps its intact siblings', (_label, row) => {
- const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }
- const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] })
- const parsed = parseRegistry(text)
- expect(parsed.records.map(record => record.sessionId)).toEqual(['keep'])
- expect(parsed.intact).toBe(false)
- })
-
- it('heals a damaged medium on the next locked write', async () => {
- writeFileSync(file(), 'not json at all')
- const { ctx, registry } = await service()
- await registry.list()
- expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true)
- await ctx.fiber.dispose()
- })
-
- it('reads a missing file as no live sessions', async () => {
- const { ctx, registry } = await service()
- rmSync(file(), { force: true })
- expect(await registry.list()).toEqual([])
- await ctx.fiber.dispose()
- })
-
-})
-
-describe('failure reporting', () => {
- it('tolerates a registry file another process created first', async () => {
- // Two services racing `ensureFile`: the loser sees EEXIST, which is the
- // intended outcome rather than an error, and both still publish.
- const first = await service()
- const second = await service()
- await Promise.all([
- first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }),
- second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }),
- ])
- expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b'])
- await first.ctx.fiber.dispose()
- await second.ctx.fiber.dispose()
- })
-
- it('warns instead of throwing when deregistration fails during teardown', async () => {
- const { ctx, registry } = await service()
- await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })
- // Make the registry path unusable, so the disposer's own write fails while the
- // fiber is already unwinding. Teardown must still complete.
- rmSync(root, { recursive: true, force: true })
- mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true })
- await expect(ctx.fiber.dispose()).resolves.not.toThrow()
- })
-
- it('propagates a read failure that is not a missing file', async () => {
- const { ctx, registry } = await service()
- await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' })
- // A directory where the file belongs makes the read fail with EISDIR, which
- // is corruption rather than "no live sessions" and must not read as empty.
- rmSync(file(), { force: true })
- mkdirSync(file(), { recursive: true })
- await expect(registry.list()).rejects.toThrow()
- rmSync(file(), { recursive: true, force: true })
- await ctx.fiber.dispose()
- })
-})
-
-describe('retitle', () => {
- it('replaces the recorded title of a session this process owns', async () => {
- const { ctx, registry } = await service()
- await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
- expect((await registry.list())[0]?.title).toBeUndefined()
-
- await registry.retitle(SessionId('sess-1'), 'first')
- expect((await registry.list())[0]?.title).toBe('first')
- await registry.retitle(SessionId('sess-1'), 'second')
- expect((await registry.list())[0]?.title).toBe('second')
- await ctx.fiber.dispose()
- })
-
- it('accepts a registration that already carries a title', async () => {
- const { ctx, registry } = await service()
- await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' })
- expect((await registry.list())[0]?.title).toBe('preset')
- await ctx.fiber.dispose()
- })
-
- it('leaves a same-id record owned by another incarnation untouched', async () => {
- const { ctx, registry } = await service()
- // Same live pid, different boot id: another incarnation's record must not be
- // retitled by this one.
- writeFileSync(file(), serializeRegistry([
- { sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') },
- ]))
- await registry.retitle(SessionId('foreign'), 'not mine')
- expect((await registry.list())[0]?.title).toBeUndefined()
- await ctx.fiber.dispose()
- })
-
- it('ignores an unknown session id, since a title can resolve after removal', async () => {
- const { ctx, registry } = await service()
- await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined()
- expect(await registry.list()).toEqual([])
- await ctx.fiber.dispose()
- })
-})
-
-describe('same-process concurrency', () => {
- it('keeps every record when one process registers several sessions at once', async () => {
- // The advisory lock is tracked per process, so same-process callers contend
- // for it through its bounded retry budget instead of queueing. Past a dozen
- // or so overlapping calls that budget runs out and a registration rejects —
- // and callers publish fire-and-forget, so the rejection is swallowed and the
- // session silently vanishes from the listing. The service therefore
- // serializes its own callers; the lock only excludes other processes.
- const { ctx, registry } = await service()
- // Register once first so the file and directory already exist: without that,
- // the concurrent calls serialize behind their own mkdir/create awaits and the
- // overlap under test never happens.
- await registry.register({ sessionId: SessionId('warm'), cwd: '/w' })
- const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) =>
- registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` })))
-
- // Every call must SUCCEED, not merely leave the file consistent. Callers
- // publish fire-and-forget, so a rejection is swallowed and the session
- // silently vanishes from the listing rather than failing loudly.
- expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([])
- const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort()
- expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected)
- await ctx.fiber.dispose()
- })
-
- it('keeps serving later callers after one cycle fails', async () => {
- const { ctx, registry } = await service()
- // A directory sitting where the registry file must be makes one cycle fail
- // without breaking the shared chain for the calls queued behind it.
- rmSync(root, { recursive: true, force: true })
- mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true })
- await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow()
-
- rmSync(root, { recursive: true, force: true })
- await registry.register({ sessionId: SessionId('after'), cwd: '/w' })
- expect((await registry.list()).map(record => record.sessionId)).toEqual(['after'])
- await ctx.fiber.dispose()
- })
-})
-
-describe('cross-process concurrency', () => {
- it('keeps every record when independent processes register at once', async () => {
- // The regression that motivates the advisory lock: unlocked whole-file
- // republication loses records under concurrent writers. Real processes are
- // required — same-process promises would serialize on the event loop.
- const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url))
- const count = 8
- const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
- const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs')
- // Source plane: tsx resolves the workspace import through the root
- // tsconfig `paths` to `src`, so this runs without a build step.
- const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }
-
- const children = Array.from({ length: count }, (_unused, index) =>
- spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], {
- env,
- stdio: ['pipe', 'pipe', 'inherit'],
- }))
- try {
- // Every child must have committed its record AND still be alive when the
- // file is read, so the assertion sees concurrent writes rather than prunes.
- await Promise.all(children.map(child => new Promise((resolve, reject) => {
- child.stdout.once('data', () => { resolve() })
- child.once('error', reject)
- child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) })
- })))
-
- const stored = parseRegistry(readFileSync(file(), 'utf8'))
- expect(stored.intact).toBe(true)
- expect(stored.records.map(record => record.sessionId).sort()).toEqual(
- Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(),
- )
- } finally {
- for (const child of children) child.stdin.end()
- await Promise.all(children.map(child => new Promise((resolve) => { child.once('exit', () => { resolve() }) })))
- }
- }, 60_000)
-})
diff --git a/packages/session-registry/session-registry-file/tsconfig.json b/packages/session-registry/session-registry-file/tsconfig.json
deleted file mode 100644
index 0ab9c6a3a4..0000000000
--- a/packages/session-registry/session-registry-file/tsconfig.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "rootDir": "src",
- "outDir": "lib/types"
- },
- "include": [
- "src"
- ],
- "references": [
- {
- "path": "../session-registry"
- },
- {
- "path": "../../support/invariants"
- },
- {
- "path": "../../core/session"
- }
- ]
-}
diff --git a/packages/session-registry/session-registry-live/README.i18n.yaml b/packages/session-registry/session-registry-live/README.i18n.yaml
deleted file mode 100644
index 2d4554f70c..0000000000
--- a/packages/session-registry/session-registry-live/README.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-live/README.md
-README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040
-README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515
diff --git a/packages/session-registry/session-registry-live/README.md b/packages/session-registry/session-registry-live/README.md
deleted file mode 100644
index 0404915a97..0000000000
--- a/packages/session-registry/session-registry-live/README.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# @deepseek-ai/dsh-session-registry-live
-
-English | [中文](README.zh.md)
-
-Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front.
-
-## Behavior
-
-Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions.
-
-A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show.
-
-`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one.
-
-Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune.
-
-## Config
-
-None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure.
-
-## Model Experience
-
-None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record.
-
-#### KV Cache effect
-
-Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse.
-
-## Known Limitations and Deferred Work
-
-- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check.
-- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision.
diff --git a/packages/session-registry/session-registry-live/README.zh.md b/packages/session-registry/session-registry-live/README.zh.md
deleted file mode 100644
index 5bfb9a90db..0000000000
--- a/packages/session-registry/session-registry-live/README.zh.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# @deepseek-ai/dsh-session-registry-live
-
-[English](README.md) | 中文
-
-把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。
-
-## 行为
-
-注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose(资源释放)时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。
-
-会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。
-
-`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL),因此独立进程无法以可移植的方式解析它。
-
-发布是 fire-and-forget,失败只发出警告:注册表是一项可观测性辅助设施,因此注册表故障绝不能让正常工作的 agent(智能体)会话失败。会话在其注册仍在途中时结束,会留下一个 tombstone,让即将完成的注册观测到,因此它的记录不会一直存活到某次基于 pid 的清理才消失。
-
-## 配置
-
-无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。
-
-## 模型体验
-
-无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。
-
-#### KV 缓存影响
-
-与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。
-
-## 已知限制与延期工作
-
-- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。
-- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。
diff --git a/packages/session-registry/session-registry-live/package.json b/packages/session-registry/session-registry-live/package.json
deleted file mode 100644
index f0c5aeddc6..0000000000
--- a/packages/session-registry/session-registry-live/package.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "name": "@deepseek-ai/dsh-session-registry-live",
- "description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "lib/index.js",
- "types": "lib/types/index.d.ts",
- "exports": {
- ".": {
- "types": "./lib/types/index.d.ts",
- "default": "./lib/index.js"
- },
- "./invariant": {
- "types": "./lib/types/invariant.d.ts",
- "default": "./lib/invariant.js"
- },
- "./src/*": "./src/*",
- "./package.json": "./package.json"
- },
- "files": [
- "lib/index.js",
- "lib/invariant.js",
- "lib/types/**/*.d.ts",
- "lib/types/**/*.d.ts.map",
- "src"
- ],
- "license": "BSD-3-Clause",
- "peerDependencies": {
- "@deepseek-ai/dsh-invariants": "^0.0.1",
- "@deepseek-ai/dsh-session": "^0.0.1",
- "@deepseek-ai/dsh-session-registry": "^0.0.1",
- "@deepseek-ai/dsh-session-title": "^0.0.1",
- "cordis": "^4.0.0-rc.6"
- },
- "devDependencies": {
- "@deepseek-ai/dsh-invariants": "workspace:^",
- "@deepseek-ai/dsh-session": "workspace:^",
- "@deepseek-ai/dsh-session-registry": "workspace:^",
- "@deepseek-ai/dsh-session-registry-file": "workspace:^",
- "@deepseek-ai/dsh-session-title": "workspace:^",
- "cordis": "^4.0.0-rc.6"
- }
-}
diff --git a/packages/session-registry/session-registry-live/src/index.ts b/packages/session-registry/session-registry-live/src/index.ts
deleted file mode 100644
index 8e34487e65..0000000000
--- a/packages/session-registry/session-registry-live/src/index.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * Publishes every live session in this process into the cross-process session
- * registry, so `dsh list-sessions` lists sessions a server creates on demand rather than
- * only the one a launcher minted up front.
- *
- * Mounted in a composition whose sessions come and go — the browser UI creates
- * one per conversation — this plugin follows `session/created` and
- * `session/disposed` instead of registering a single launcher-known identity.
- * A session with no `cwd` in its header is skipped: the registry's workspace
- * column would have nothing truthful to show, and a subagent child is exactly
- * that case. Titles are mirrored into the record as `session/title` events
- * arrive, so a reader never has to parse a backend's log format.
- * @module @deepseek-ai/dsh-session-registry-live
- */
-
-import type { Context } from 'cordis'
-import type { Session } from '@deepseek-ai/dsh-session'
-// Empty type imports carry the Context merges this plugin relies on: the
-// `sessionRegistry` service and the `session/title` session event.
-import type {} from '@deepseek-ai/dsh-session-registry'
-import type {} from '@deepseek-ai/dsh-session-title'
-
-/** Cordis plugin name. */
-export const name = 'session-registry-live'
-
-/** Services required before sessions can be followed and records published. */
-export const inject = ['sessions', 'sessionRegistry']
-
-/**
- * Follow session lifecycle and keep the registry in step.
- * @param ctx - context carrying the session store and the registry service.
- */
-export function apply(ctx: Context): void {
- /**
- * Per-session registration state. `'disposing'` is a tombstone written when a
- * session ends while its registration is still in flight: without it the
- * late-arriving disposer would be stored for a session that no longer exists
- * and its record would outlive the session until a pid-based prune.
- */
- const registered = new Map Promise) | 'disposing'>()
-
- const publish = (session: Session): void => {
- const cwd = session.header.cwd
- // A session without a workspace has no listable location; skipping keeps the
- // registry free of rows `dsh list-sessions` could not render truthfully.
- if (cwd === undefined) return
- void ctx.sessionRegistry.register({ sessionId: session.id, cwd })
- .then((dispose) => {
- if (registered.get(session) === 'disposing') {
- registered.delete(session)
- void dispose()
- return
- }
- registered.set(session, dispose)
- })
- .catch((error: unknown) => {
- registered.delete(session)
- ctx.logger.warn('failed to publish session %s: %s', session.id, String(error))
- })
- }
-
- for (const session of ctx.sessions.list()) publish(session)
- ctx.on('session/created', (session) => { publish(session) }, { global: true })
- ctx.on('session/disposed', (session) => {
- const entry = registered.get(session)
- if (typeof entry === 'function') {
- registered.delete(session)
- void entry()
- return
- }
- // Registration is still in flight; leave a tombstone for it to observe.
- registered.set(session, 'disposing')
- }, { global: true })
-
- // Mirror title revisions onto the record. A title arrives after registration
- // and may be replaced, so the listing tracks the latest logged value.
- ctx.on('session/event', (session, event) => {
- if (event.type !== 'session/title') return
- const { title } = event.data
- void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => {
- ctx.logger.warn('failed to retitle %s: %s', session.id, String(error))
- })
- }, { global: true })
-}
diff --git a/packages/session-registry/session-registry-live/src/invariant.ts b/packages/session-registry/session-registry-live/src/invariant.ts
deleted file mode 100644
index 19d2a9f483..0000000000
--- a/packages/session-registry/session-registry-live/src/invariant.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`.
- * @module @deepseek-ai/dsh-session-registry-live/invariant
- */
-
-/* jscpd:ignore-start */
-import type { Context } from 'cordis'
-import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
-
-const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live'
-
-/** Cordis companion plugin name. */
-export const name = 'session-registry-live-invariant'
-/** Service required before the companion can reserve package ownership. */
-export const inject = ['invariants']
-
-/**
- * No runtime invariant: this plugin owns no durable state of its own — the
- * uniqueness and liveness relations over published records are checked by the
- * companion in `@deepseek-ai/dsh-session-registry`, which owns that file.
- */
-const install: InvariantInstaller = () => {}
-
-/**
- * Register this package's invariant companion.
- * @param ctx - Cordis context carrying the invariant service.
- * @returns the installed registration's disposer after setup succeeds.
- */
-export const apply = (ctx: Context): Promise<() => void> =>
- Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
-/* jscpd:ignore-end */
diff --git a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts
deleted file mode 100644
index ec0973259e..0000000000
--- a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-/**
- * Tests for the live-session publisher over the REAL session store, so
- * publication follows the store's actual lifecycle dispatch rather than a
- * hand-built event emitter: sessions created after mount are published,
- * disposal removes their records, a session without a workspace is skipped, and
- * logged title revisions are mirrored onto the record so a reader never parses a
- * backend's log format.
- */
-
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { Context } from 'cordis'
-import { mkdtempSync, rmSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
-import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
-import * as live from '@deepseek-ai/dsh-session-registry-live'
-// Empty type import carries the `session/title` event into the session-event map.
-import type {} from '@deepseek-ai/dsh-session-title'
-
-let root: string
-
-beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) })
-afterEach(() => {
- rmSync(root, { recursive: true, force: true })
- vi.restoreAllMocks()
-})
-
-/** Mount the real store plus the publisher. */
-async function mount(): Promise {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
- await ctx.plugin(live)
- return ctx
-}
-
-/** Let the publisher's fire-and-forget registration reach durability. */
-const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 200) })
-
-/** Read the registry through an independent service, as `dsh list-sessions` would. */
-async function listExternally(): Promise {
- const reader = new Context()
- await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
- const records = await reader.sessionRegistry.list()
- await reader.fiber.dispose()
- return records
-}
-
-describe('publishing', () => {
- it('publishes sessions that already exist when the plugin mounts', async () => {
- // A composition may mount the publisher after sessions exist (a resumed
- // session, or plugin order), so mount-time adoption is its own path.
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } })
- await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
- await ctx.plugin(live)
- await settle()
-
- expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting'])
- await ctx.fiber.dispose()
- })
-
- it('publishes a session created after mount', async () => {
- const ctx = await mount()
- ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } })
- await settle()
-
- const listed = await ctx.sessionRegistry.list()
- expect(listed).toHaveLength(1)
- expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' })
- await ctx.fiber.dispose()
- })
-
- it('skips a session with no workspace, having nothing truthful to list', async () => {
- const ctx = await mount()
- ctx.sessions.create(SessionId('no-cwd'))
- await settle()
- expect(await ctx.sessionRegistry.list()).toEqual([])
- await ctx.fiber.dispose()
- })
-
- it('has no title until one is logged', async () => {
- const ctx = await mount()
- ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } })
- await settle()
- expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined()
- await ctx.fiber.dispose()
- })
-
- it('mirrors the latest logged title onto the record', async () => {
- const ctx = await mount()
- const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } })
- await settle()
-
- session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } })
- await settle()
- expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess')
-
- // A revision replaces the previous value rather than accumulating.
- session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } })
- await settle()
- expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title')
- await ctx.fiber.dispose()
- })
-
- it('ignores session events other than a title revision', async () => {
- const ctx = await mount()
- const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } })
- await settle()
- const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle')
-
- session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
- await settle()
- expect(retitle).not.toHaveBeenCalled()
- await ctx.fiber.dispose()
- })
-
- it('retitles only the session that logged the event', async () => {
- const ctx = await mount()
- const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } })
- ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } })
- await settle()
-
- first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } })
- await settle()
- const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title]))
- expect(byId.get(SessionId('one'))).toBe('only mine')
- expect(byId.get(SessionId('two'))).toBeUndefined()
- await ctx.fiber.dispose()
- })
-
- it('publishes every concurrently created session', async () => {
- const ctx = await mount()
- for (let index = 0; index < 5; index += 1) {
- ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } })
- }
- await settle()
- expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort())
- .toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4'])
- await ctx.fiber.dispose()
- })
-})
-
-describe('failure and race handling', () => {
- it('removes the record when a session is disposed mid-registration', async () => {
- // The tombstone path: the session ends before its registration resolves, so
- // the late disposer must be applied instead of stored for a dead session.
- const ctx = await mount()
- let owner: Context | undefined
- await ctx.plugin({
- inject: ['sessions'],
- apply: (child: Context) => {
- owner = child
- child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } })
- },
- })
- // No settle: dispose while `register` is still in flight.
- await owner?.fiber.dispose()
- await settle()
- expect(await ctx.sessionRegistry.list()).toEqual([])
- await ctx.fiber.dispose()
- })
-
- it('warns and drops the record when publication fails', async () => {
- const ctx = await mount()
- ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline'))
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-
- ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } })
- await settle()
- expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/)
- await ctx.fiber.dispose()
- })
-
- it('warns when a title revision cannot be recorded', async () => {
- const ctx = await mount()
- const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } })
- await settle()
-
- ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline'))
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-
- session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } })
- await settle()
- expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/)
- await ctx.fiber.dispose()
- })
-})
-
-describe('disposal', () => {
- it('removes a record when its own session is disposed, keeping the others', async () => {
- const ctx = await mount()
- // A session belongs to the fiber that created it, so a child plugin fiber
- // gives one session an independent lifetime without disposing the services.
- let owner: Context | undefined
- await ctx.plugin({
- inject: ['sessions'],
- apply: (child: Context) => {
- owner = child
- child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } })
- },
- })
- ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } })
- await settle()
- expect(await ctx.sessionRegistry.list()).toHaveLength(2)
-
- // Disposing only that fiber ends its session, which the publisher follows.
- await owner?.fiber.dispose()
- await settle()
- expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable'])
- await ctx.fiber.dispose()
- })
-
- it('leaves no record behind after the whole tree unloads', async () => {
- const ctx = await mount()
- ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } })
- ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } })
- await settle()
- expect(await ctx.sessionRegistry.list()).toHaveLength(2)
-
- await ctx.fiber.dispose()
- expect(await listExternally()).toEqual([])
- })
-})
diff --git a/packages/session-registry/session-registry-live/tsconfig.json b/packages/session-registry/session-registry-live/tsconfig.json
deleted file mode 100644
index 456adfc51b..0000000000
--- a/packages/session-registry/session-registry-live/tsconfig.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "rootDir": "src",
- "outDir": "lib/types"
- },
- "include": [
- "src"
- ],
- "references": [
- {
- "path": "../../support/invariants"
- },
- {
- "path": "../../core/session"
- },
- {
- "path": "../session-registry"
- },
- {
- "path": "../../session-title/session-title"
- }
- ]
-}
diff --git a/packages/session-registry/session-registry/README.i18n.yaml b/packages/session-registry/session-registry/README.i18n.yaml
deleted file mode 100644
index 211c8222e8..0000000000
--- a/packages/session-registry/session-registry/README.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write packages/session-registry/session-registry/README.md
-README.md: 8ab8e232e6cd041e5476d4e6cadcb8b64a85f586
-README.zh.md: 62774e8a2a892c97ba6343f12dd35f827ad63e33
diff --git a/packages/session-registry/session-registry/README.md b/packages/session-registry/session-registry/README.md
deleted file mode 100644
index 8ab8e232e6..0000000000
--- a/packages/session-registry/session-registry/README.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# @deepseek-ai/dsh-session-registry
-
-English | [中文](README.zh.md)
-
-Live-session registry seam (`ctx.sessionRegistry`): the contract and record vocabulary for a cross-process registry of the sessions running right now, so a separate short-lived process such as `dsh list-sessions` can answer "what am I running". This package owns no medium — a backend (the lock-guarded JSON file in [`session-registry-file`](../session-registry-file/README.md) today, a database later) implements the abstract service.
-
-## Shape
-
-- `register(registration)` — publish `{ sessionId, cwd, title? }` stamped with this process's pid, a per-incarnation `bootId`, and `startedAt`. Replaces any existing record for the same session id. Returns the `ctx.effect` disposer; awaiting it waits for the removal to reach durability.
-- `retitle(sessionId, title)` — replace the recorded title of a session **this** process registered. Titles arrive after registration and can be revised, so it is the one mutable field. A record owned by another pid or incarnation is left alone, and an unknown id is a no-op because a title can resolve after the record is gone.
-- `list()` — every live record, newest registration last. Liveness is part of the contract, not the backend's discretion: every returned record's process existed at observation time, so a process killed without running its disposer leaves no permanent phantom.
-
-Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write.
-
-## Record vocabulary
-
-`SessionRegistryRecord` carries `sessionId` (unique across live records), `pid`, `cwd`, `startedAt`, a `bootId` distinguishing a recycled pid from the original incarnation, and an optional `title`. The title travels in the record rather than being read from the session log because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse.
-
-## Model Experience
-
-None, as this package registers no tools, injects no prompts, and appends no session events; it defines the host-side listing contract only.
-
-#### KV Cache effect
-
-Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse.
-
-## Known Limitations and Deferred Work
-
-- **Records are process-scoped, not agent-scoped** — only top-level launcher surfaces publish. In-process subagents have no process of their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` rather than the CLI, so neither appears in a listing.
-- **Liveness is pid existence, not health** — a hung or stopped process still lists as running; the contract deliberately makes no judgement about whether a session is making progress.
diff --git a/packages/session-registry/session-registry/README.zh.md b/packages/session-registry/session-registry/README.zh.md
deleted file mode 100644
index 62774e8a2a..0000000000
--- a/packages/session-registry/session-registry/README.zh.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# @deepseek-ai/dsh-session-registry
-
-[English](README.md) | 中文
-
-存活会话注册表 seam(`ctx.sessionRegistry`):定义跨进程「当前正在运行哪些会话」注册表的契约与记录词汇,使 `dsh list-sessions` 这类独立的短生命周期进程能够回答「我正在运行什么」。本包不拥有任何介质——由后端实现该抽象服务(今天是 [`session-registry-file`](../session-registry-file/README.md) 中加锁保护的 JSON 文件,将来可以是数据库)。
-
-## 形状
-
-- `register(registration)`:发布 `{ sessionId, cwd, title? }`,并盖上本进程的 pid、每个 incarnation 独有的 `bootId` 和 `startedAt`。同一会话 id 的既有记录会被替换。返回 `ctx.effect` disposer;await 它即等待移除达到持久性。
-- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。
-- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。
-
-后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。
-
-## 记录词汇
-
-`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid`、`cwd`、`startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。
-
-## 模型体验
-
-无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。
-
-#### KV 缓存影响
-
-与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。
-
-## 已知限制与后续工作
-
-- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI,两者都不会出现在列表中。
-- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。
diff --git a/packages/session-registry/session-registry/package.json b/packages/session-registry/session-registry/package.json
deleted file mode 100644
index 75308999f5..0000000000
--- a/packages/session-registry/session-registry/package.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "name": "@deepseek-ai/dsh-session-registry",
- "description": "Live-session registry seam for the DeepSeek Harness: contract and record vocabulary",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "lib/index.js",
- "types": "lib/types/index.d.ts",
- "exports": {
- ".": {
- "types": "./lib/types/index.d.ts",
- "default": "./lib/index.js"
- },
- "./invariant": {
- "types": "./lib/types/invariant.d.ts",
- "default": "./lib/invariant.js"
- },
- "./src/*": "./src/*",
- "./package.json": "./package.json"
- },
- "files": [
- "lib/index.js",
- "lib/invariant.js",
- "lib/types/**/*.d.ts",
- "lib/types/**/*.d.ts.map",
- "src"
- ],
- "license": "BSD-3-Clause",
- "peerDependencies": {
- "@deepseek-ai/dsh-brand": "^0.0.1",
- "@deepseek-ai/dsh-invariants": "^0.0.1",
- "@deepseek-ai/dsh-session": "^0.0.1",
- "cordis": "^4.0.0-rc.6"
- },
- "devDependencies": {
- "@deepseek-ai/dsh-brand": "workspace:^",
- "@deepseek-ai/dsh-invariants": "workspace:^",
- "@deepseek-ai/dsh-session": "workspace:^",
- "cordis": "^4.0.0-rc.6"
- }
-}
diff --git a/packages/session-registry/session-registry/src/index.ts b/packages/session-registry/session-registry/src/index.ts
deleted file mode 100644
index 413317c31c..0000000000
--- a/packages/session-registry/session-registry/src/index.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * Live-session registry seam (`ctx.sessionRegistry`): a cross-process registry
- * of live `dsh` sessions, so a separate short-lived process such as
- * `dsh list-sessions` can answer "what am I running right now".
- *
- * This package owns only the service contract and the record vocabulary; a
- * backend (the lock-guarded JSON file in
- * `@deepseek-ai/dsh-session-registry-file` today, a database later) owns the
- * medium. Whatever the medium, liveness is part of the contract: {@link list}
- * returns only records whose process existed at observation time, so a process
- * killed without running its disposer leaves no permanent phantom.
- * @module @deepseek-ai/dsh-session-registry
- */
-
-import { Context, Service } from 'cordis'
-import type { SessionId } from '@deepseek-ai/dsh-session'
-import { BootId, type SessionRegistryRecord } from './types.ts'
-
-export { BootId } from './types.ts'
-export type { SessionRegistryRecord } from './types.ts'
-
-declare module 'cordis' {
- interface Context {
- sessionRegistry: SessionRegistry
- }
-}
-
-/** What one process publishes about itself; the service supplies pid and timing. */
-export interface SessionRegistration {
- /** The session this process runs. */
- sessionId: SessionId
- /** Absolute workspace directory the session acts on. */
- cwd: string
- /** Human-readable session title, when one already exists. */
- title?: string
-}
-
-/**
- * Cross-process live-session registry. Reads prune dead records, so every
- * returned record's process existed at observation time. Backends serialize
- * mutations against concurrent registrars — other processes and overlapping
- * calls in this one — so records are never lost to a torn read-modify-write.
- */
-export abstract class SessionRegistry extends Service {
- /** This process incarnation's id, stamped into every record it publishes. */
- protected readonly bootId: BootId
-
- constructor(ctx: Context, bootId: BootId) {
- super(ctx, 'sessionRegistry')
- this.bootId = bootId
- }
-
- /**
- * Publish this process's record, replacing any stale record for the same
- * session id, and prune records whose process is gone.
- * @param registration - the session, surface, and workspace to publish.
- * @returns the effect disposer that removes this record again; awaiting it
- * waits for the removal to reach durability.
- */
- abstract register(registration: SessionRegistration): Promise<() => Promise>
-
- /**
- * Replace the recorded title of a session this process registered.
- *
- * Titles arrive after registration and can be revised, so this is the one
- * mutable field. Only a record matching this process and incarnation is
- * touched, leaving a same-id record owned by another process alone. An unknown
- * session id is a no-op rather than an error: a title can resolve after the
- * session's record has already been removed.
- * @param sessionId - the session whose recorded title changes.
- * @param title - the new title text.
- */
- abstract retitle(sessionId: SessionId, title: string): Promise
-
- /**
- * List live sessions, pruning records whose process no longer exists.
- * @returns one record per live registered session, newest registration last.
- */
- abstract list(): Promise
-}
-
-export default SessionRegistry
diff --git a/packages/session-registry/session-registry/src/invariant.ts b/packages/session-registry/session-registry/src/invariant.ts
deleted file mode 100644
index f41207fbed..0000000000
--- a/packages/session-registry/session-registry/src/invariant.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry`.
- * @module @deepseek-ai/dsh-session-registry/invariant
- */
-
-import type { Context } from 'cordis'
-import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
-import type { SessionRegistryRecord } from './types.ts'
-
-const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry'
-
-/** Cordis companion plugin name. */
-export const name = 'session-registry-invariant'
-/** Service required before the companion can reserve package ownership. */
-export const inject = ['invariants']
-
-/**
- * Cross-check every published listing against the relations the seam contract
- * owns: a session id identifies at most one live record, and each listed record
- * carries the identity fields a reader must be able to trust. Only a backend's
- * mutation path can break either, so the check wraps the authoritative read
- * rather than inspecting any medium.
- *
- * Liveness itself is deliberately not re-probed here. A backend derives it at
- * read time, so a second probe would race the first and report a process that
- * exited in between as a violation of a contract the seam never made.
- */
-const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
- const service = ctx.sessionRegistry
- const listed = service.list.bind(service)
- ctx.effect(() => {
- service.list = async (): Promise => {
- const records = await listed()
- const seen = new Set()
- for (const record of records) {
- if (seen.has(record.sessionId)) {
- fail(`session ${record.sessionId} appears in more than one live registry record`)
- }
- seen.add(record.sessionId)
- // A record a reader cannot attribute to a process is unusable: `dsh list-sessions`
- // renders the pid and derives liveness from it.
- if (!Number.isSafeInteger(record.pid) || record.pid <= 0) {
- fail(`listed session ${record.sessionId} carries unusable pid ${String(record.pid)}`)
- }
- }
- return records
- }
- return () => { service.list = listed }
- })
-}, { inject: ['sessionRegistry'] })
-
-/**
- * Register this package's invariant companion.
- * @param ctx - Cordis context carrying the invariant service.
- * @returns the installed registration's disposer after setup succeeds.
- */
-export const apply = (ctx: Context): Promise<() => void> =>
- Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/session-registry/session-registry/src/types.ts b/packages/session-registry/session-registry/src/types.ts
deleted file mode 100644
index 6fff91648e..0000000000
--- a/packages/session-registry/session-registry/src/types.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * Registry record vocabulary: the durable shape one live `dsh` process
- * publishes about itself and `dsh list-sessions` reads back.
- * @module @deepseek-ai/dsh-session-registry/types
- */
-
-import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { SessionId } from '@deepseek-ai/dsh-session'
-
-/**
- * Identifies one process incarnation. Minted per registering process, so a
- * record whose `pid` was recycled by the operating system cannot be mistaken
- * for the original: the boot id differs even when the pid matches.
- */
-export type BootId = Branded<'BootId'>
-
-/**
- * Brand a string as a {@link BootId}.
- * @param id - the raw boot id string.
- * @returns the same string, branded (a compile-time cast — no runtime cost).
- */
-export function BootId(id: string): BootId {
- return id as BootId
-}
-
-/**
- * One live session's self-published registration. Every field is immutable for
- * the lifetime of the registration: a process publishes once at startup and
- * removes the record on exit, never mutating it in place.
- *
- * Only top-level surfaces a user starts directly register: in-process subagents
- * have no process of their own, and out-of-process subagent backends spawn
- * `dsh-jsonrpc-agent` rather than this CLI, so neither can reach the registry.
- */
-export interface SessionRegistryRecord {
- /** The session this process is running. Unique across live records. */
- readonly sessionId: SessionId
- /** Operating-system process id, used with `bootId` to decide liveness. */
- readonly pid: number
- /** Absolute workspace directory the session acts on. */
- readonly cwd: string
- /** Non-negative safe-integer Unix epoch milliseconds when the process registered. */
- readonly startedAt: number
- /** This process incarnation's id, distinguishing a recycled `pid`. */
- readonly bootId: BootId
- /**
- * Human-readable session title, as the registering process last knew it.
- *
- * Carried in the record rather than read from the session log: the log's
- * location, file format, and compression are per-deployment backend choices,
- * so an independent reader cannot portably parse one. Absent until a title
- * exists — a fresh session has none.
- */
- readonly title?: string
-}
diff --git a/packages/session-registry/session-registry/tests/invariant.spec.ts b/packages/session-registry/session-registry/tests/invariant.spec.ts
deleted file mode 100644
index 6d97b3d129..0000000000
--- a/packages/session-registry/session-registry/tests/invariant.spec.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * Tests for the registry's invariant companion: each acceptance path is proven
- * to REJECT an invalid case, since a check that cannot fail is not a check.
- * The backend is a minimal in-memory stub — the companion owns contract-level
- * relations over `list()` results, whatever medium serves them.
- */
-
-import { describe, expect, it } from 'vitest'
-import { Context } from 'cordis'
-import InvariantService from '@deepseek-ai/dsh-invariants'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import { BootId, SessionRegistry, type SessionRegistration, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
-import * as invariant from '@deepseek-ai/dsh-session-registry/src/invariant.ts'
-
-/** Minimal in-memory backend whose listings the test scripts directly. */
-class StubRegistry extends SessionRegistry {
- records: SessionRegistryRecord[] = []
-
- constructor(ctx: Context) {
- super(ctx, BootId('stub-boot'))
- }
-
- register(registration: SessionRegistration): Promise<() => Promise> {
- this.records.push({
- sessionId: registration.sessionId,
- pid: process.pid,
- cwd: registration.cwd,
- startedAt: Date.now(),
- bootId: this.bootId,
- })
- return Promise.resolve(() => Promise.resolve())
- }
-
- retitle(): Promise {
- return Promise.resolve()
- }
-
- list(): Promise {
- return Promise.resolve([...this.records])
- }
-}
-
-/** One record with the given identity fields, live by construction. */
-function record(sessionId: string, boot: string, pid = process.pid): SessionRegistryRecord {
- return { sessionId: SessionId(sessionId), pid, cwd: '/w', startedAt: 1, bootId: BootId(boot) }
-}
-
-/** Mount the stub backend, optionally seeding records before the companion wraps `list`. */
-async function mount(records?: SessionRegistryRecord[]): Promise<{ ctx: Context; stub: StubRegistry }> {
- const ctx = new Context()
- await ctx.plugin(InvariantService, { enabled: true })
- await ctx.plugin(StubRegistry)
- const stub = ctx.sessionRegistry as StubRegistry
- if (records !== undefined) stub.records = records
- await ctx.plugin(invariant)
- return { ctx, stub }
-}
-
-describe('listing invariants', () => {
- it('accepts a well-formed listing', async () => {
- const { ctx } = await mount()
- await ctx.sessionRegistry.register({ sessionId: SessionId('ok'), cwd: '/w' })
- await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(1)
- await ctx.fiber.dispose()
- })
-
- it('rejects a listing where one session id appears twice', async () => {
- // Two live records for one session: only a broken mutation path (or an
- // out-of-band writer) can produce this, and it would make
- // `dsh list-sessions` show one session twice.
- const { ctx } = await mount([record('dup', 'boot-a'), record('dup', 'boot-b')])
- await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one live registry record/)
- await ctx.fiber.dispose()
- })
-
- it('rejects a listing whose record carries an unusable pid', async () => {
- // A record no reader could attribute to a process: `dsh list-sessions`
- // renders the pid and derives liveness from it.
- const { ctx } = await mount([record('ghost', 'boot-x', 0)])
- await expect(ctx.sessionRegistry.list()).rejects.toThrow(/carries unusable pid/)
- await ctx.fiber.dispose()
- })
-
- it('stops checking, and keeps working, when the companion unloads', async () => {
- // A duplicate-id listing the mounted companion rejects, so the post-disposal
- // read proves the wrapper is gone rather than merely bypassed.
- const ctx = new Context()
- await ctx.plugin(InvariantService, { enabled: true })
- await ctx.plugin(StubRegistry)
- ;(ctx.sessionRegistry as StubRegistry).records = [record('dup', 'boot-a'), record('dup', 'boot-b')]
- const companion = await ctx.plugin(invariant)
- await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one/)
-
- await companion.dispose()
- await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(2)
- await ctx.fiber.dispose()
- })
-})
diff --git a/packages/session-registry/session-registry/tsconfig.json b/packages/session-registry/session-registry/tsconfig.json
deleted file mode 100644
index cca8f9282b..0000000000
--- a/packages/session-registry/session-registry/tsconfig.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "rootDir": "src",
- "outDir": "lib/types"
- },
- "include": [
- "src"
- ],
- "references": [
- {
- "path": "../../support/invariants"
- },
- {
- "path": "../../util/brand"
- },
- {
- "path": "../../core/session"
- }
- ]
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8dc9613dc0..6e54ba3a05 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3889,70 +3889,6 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
- packages/session-registry/session-registry:
- devDependencies:
- '@deepseek-ai/dsh-brand':
- specifier: workspace:^
- version: link:../../util/brand
- '@deepseek-ai/dsh-invariants':
- specifier: workspace:^
- version: link:../../support/invariants
- '@deepseek-ai/dsh-session':
- specifier: workspace:^
- version: link:../../core/session
- cordis:
- specifier: ^4.0.0-rc.6
- version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
-
- packages/session-registry/session-registry-file:
- dependencies:
- proper-lockfile:
- specifier: ^4.1.2
- version: 4.1.2
- schemastery:
- specifier: ^3.15.0
- version: 3.18.0
- devDependencies:
- '@deepseek-ai/dsh-brand':
- specifier: workspace:^
- version: link:../../util/brand
- '@deepseek-ai/dsh-invariants':
- specifier: workspace:^
- version: link:../../support/invariants
- '@deepseek-ai/dsh-session':
- specifier: workspace:^
- version: link:../../core/session
- '@deepseek-ai/dsh-session-registry':
- specifier: workspace:^
- version: link:../session-registry
- '@types/proper-lockfile':
- specifier: ^4.1.4
- version: 4.1.4
- cordis:
- specifier: ^4.0.0-rc.6
- version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
-
- packages/session-registry/session-registry-live:
- devDependencies:
- '@deepseek-ai/dsh-invariants':
- specifier: workspace:^
- version: link:../../support/invariants
- '@deepseek-ai/dsh-session':
- specifier: workspace:^
- version: link:../../core/session
- '@deepseek-ai/dsh-session-registry':
- specifier: workspace:^
- version: link:../session-registry
- '@deepseek-ai/dsh-session-registry-file':
- specifier: workspace:^
- version: link:../session-registry-file
- '@deepseek-ai/dsh-session-title':
- specifier: workspace:^
- version: link:../../session-title/session-title
- cordis:
- specifier: ^4.0.0-rc.6
- version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
-
packages/session-title/session-title:
dependencies:
schemastery:
@@ -8063,9 +7999,6 @@ packages:
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
- '@types/proper-lockfile@4.1.4':
- resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
-
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
@@ -9169,9 +9102,6 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
- graceful-fs@4.2.11:
- resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
-
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
@@ -10159,9 +10089,6 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
- proper-lockfile@4.1.2:
- resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
-
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
@@ -10265,10 +10192,6 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
- retry@0.12.0:
- resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
- engines: {node: '>= 4'}
-
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
@@ -10410,9 +10333,6 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
- signal-exit@3.0.7:
- resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
-
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@@ -12982,10 +12902,6 @@ snapshots:
'@types/prop-types@15.7.15': {}
- '@types/proper-lockfile@4.1.4':
- dependencies:
- '@types/retry': 0.12.0
-
'@types/react-dom@18.3.7(@types/react@18.3.31)':
dependencies:
'@types/react': 18.3.31
@@ -14287,8 +14203,6 @@ snapshots:
gopd@1.2.0: {}
- graceful-fs@4.2.11: {}
-
hachure-fill@0.5.2: {}
handlebars@4.7.9:
@@ -15469,12 +15383,6 @@ snapshots:
process-nextick-args@2.0.1: {}
- proper-lockfile@4.1.2:
- dependencies:
- graceful-fs: 4.2.11
- retry: 0.12.0
- signal-exit: 3.0.7
-
property-information@7.2.0: {}
protobufjs@7.6.4:
@@ -15624,8 +15532,6 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
- retry@0.12.0: {}
-
retry@0.13.1: {}
rfdc@1.4.1: {}
@@ -15862,8 +15768,6 @@ snapshots:
siginfo@2.0.0: {}
- signal-exit@3.0.7: {}
-
signal-exit@4.1.0: {}
sisteransi@1.0.5: {}
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 90a5bef153..0f3270f8ee 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -229,8 +229,6 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = {
DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md',
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
Domain: 'domain interface is owned by packages/storage/storage-domain/README.md',
- SessionRegistration: 'registry publication input is owned by packages/session-registry/session-registry/README.md',
- SessionRegistryRecord: 'live-session record vocabulary is owned by packages/session-registry/session-registry/README.md',
DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts',
DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md',
DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index c836289049..73ba05f748 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -171,15 +171,6 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['apiproxy'],
note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.',
},
- {
- key: 'sessionRegistry',
- pkg: 'session-registry',
- title: 'Live-session registry',
- mode: 'seam',
- implementations: ['session-registry-file'],
- consumers: ['session-registry-live'],
- note: 'Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`.',
- },
{
key: 'sessionQuery',
pkg: 'session-query',
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 58ac34a3cf..fd74c6d13e 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -98,9 +98,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
- 'packages/session-registry/session-registry': { kind: 'none', reason: 'The seam defines the host-side listing contract and registers no model surface.' },
- 'packages/session-registry/session-registry-file': { kind: 'none', reason: 'The file backend stores host-side process records for the CLI listing surface and registers no model surface.' },
- 'packages/session-registry/session-registry-live': { kind: 'none', reason: 'The publisher mirrors lifecycle and title events into host-side process records and registers no model surface.' },
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index ba1f88779c..2e35d53c43 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -88,7 +88,6 @@
"./packages/session-persistence/*/src/invariant.ts",
"./packages/session-projection/*/src/invariant.ts",
"./packages/session-query/*/src/invariant.ts",
- "./packages/session-registry/*/src/invariant.ts",
"./packages/telemetry/*/src/invariant.ts",
"./packages/acp/*/src/invariant.ts",
"./packages/storage/*/src/invariant.ts",
@@ -180,7 +179,6 @@
"./packages/session-persistence/*/src",
"./packages/session-projection/*/src",
"./packages/session-query/*/src",
- "./packages/session-registry/*/src",
"./packages/session-title/*/src",
"./packages/telemetry/*/src",
"./packages/acp/*/src",
diff --git a/tsconfig.host.json b/tsconfig.host.json
index b0121f2a0a..d46a167eb5 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -181,9 +181,6 @@
{ "path": "./packages/lsp/lsp" },
{ "path": "./packages/lsp/lsp-local" },
{ "path": "./packages/lsp/tool-lsp" },
- { "path": "./packages/session-registry/session-registry" },
- { "path": "./packages/session-registry/session-registry-file" },
- { "path": "./packages/session-registry/session-registry-live" },
{ "path": "./apps/cli" }
]
}