refactor(session): exclude live-session registry foundation

This commit is contained in:
Turtle
2026-07-29 16:28:17 +08:00
parent 991c1f14b6
commit ea9315841f
50 changed files with 6 additions and 2246 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: be455496346b9242585c1aace18f4f55cba905c0
2026-07-28-cross-workspace-resume.zh.md: 404b81cbc07a455e5553a9c227d663e491456c9e 2026-07-28-cross-workspace-resume.zh.md: 0384cf204e51e4086b05c75e691f59d6b60a7d11

View File

@@ -43,7 +43,6 @@ The shared base states that precedence in the row itself: `apps/cli/base.cordis.
## Consequences ## Consequences
- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration. - 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. - 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. - 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.

View File

@@ -43,7 +43,6 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根
## Consequences ## Consequences
- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。 - 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。
- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。
- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。 - 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。
- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。 - Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。

View File

@@ -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

View File

@@ -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.

View File

@@ -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` 剪除并持久重写。

View File

@@ -53,7 +53,7 @@
# The session store root is the launcher's policy, not a plugin's: `dsh` shares # 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 # 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. # an embedder's sessions beside its project.
- id: session-persistence-jsonl - id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl' name: '@deepseek-ai/dsh-session-persistence-jsonl'

View File

@@ -47,10 +47,6 @@ flowchart LR
pkg_workspace["workspace"] pkg_workspace["workspace"]
svc_workspace["ctx.workspace<br/>Workspace entity registry"] svc_workspace["ctx.workspace<br/>Workspace entity registry"]
pkg_apiproxy["apiproxy"] pkg_apiproxy["apiproxy"]
pkg_session_registry["session-registry"]
svc_sessionRegistry["ctx.sessionRegistry<br/>Live-session registry"]
pkg_session_registry_file["session-registry-file"]
pkg_session_registry_live["session-registry-live"]
svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"] svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"] pkg_session_reference["session-reference"]
pkg_tool_session_query["tool-session-query"] pkg_tool_session_query["tool-session-query"]
@@ -200,8 +196,6 @@ flowchart LR
pkg_session_query --> svc_sessionQuery pkg_session_query --> svc_sessionQuery
pkg_session_query_sqlite --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery
pkg_session_reference --> svc_sessionReferences pkg_session_reference --> svc_sessionReferences
pkg_session_registry --> svc_sessionRegistry
pkg_session_registry_file --> svc_sessionRegistry
pkg_session_telemetry --> svc_telemetry pkg_session_telemetry --> svc_telemetry
pkg_session_telemetry_otel --> svc_telemetry pkg_session_telemetry_otel --> svc_telemetry
pkg_session_title --> svc_sessionTitle pkg_session_title --> svc_sessionTitle
@@ -283,7 +277,6 @@ flowchart LR
svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_session_reference
svc_sessionQuery --> pkg_tool_session_query svc_sessionQuery --> pkg_tool_session_query
svc_sessionReferences --> pkg_tui svc_sessionReferences --> pkg_tui
svc_sessionRegistry --> pkg_session_registry_live
svc_sessions --> pkg_agent svc_sessions --> pkg_agent
svc_sessions --> pkg_agent_loop svc_sessions --> pkg_agent_loop
svc_sessions --> pkg_cli_demo 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.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.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.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.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.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. | | `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. |

View File

@@ -1153,26 +1153,6 @@ export interface Config {
Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) 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` ## `@deepseek-ai/dsh-session-telemetry-otel`
Requires: `sessions` 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` ([`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-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-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-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-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)) - `@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-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-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-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-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-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)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))

View File

@@ -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) 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<void>>
/**
* 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<void>
/**
* List live sessions, pruning records whose process no longer exists.
* @returns one record per live registered session, newest registration last.
*/
abstract list(): Promise<SessionRegistryRecord[]>
```
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` ## `ctx.sessions` — `SessionStore`
In-memory session store (`ctx.sessions`). In-memory session store (`ctx.sessions`).

View File

@@ -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) | | `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) | | `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) | | `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/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-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `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-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/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) | | `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-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` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |

View File

@@ -288,7 +288,6 @@
"tests/**/*.ts" "tests/**/*.ts"
] ]
}, },
"packages/session-registry/session-registry-file": {
"entry": [ "entry": [
"tests/**/*.spec.ts", "tests/**/*.spec.ts",
"tests/fixtures/register-once.ts" "tests/fixtures/register-once.ts"

View File

@@ -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<void>>',
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<void>',
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<SessionRegistryRecord[]>',
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', key: 'sessions',
summary: 'In-memory session store (`ctx.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}', 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', name: 'SessionResultFilter',

View File

@@ -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

View File

@@ -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.

View File

@@ -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 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。
这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。

View File

@@ -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

View File

@@ -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 (`<root>/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.

View File

@@ -1,42 +0,0 @@
# @deepseek-ai/dsh-session-registry-file
[English](README.md) | 中文
[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry`;`file` 暴露注册表文件的绝对路径(`<root>/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 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。

View File

@@ -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"
}
}

View File

@@ -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<string, unknown>
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<string, unknown>
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`
}

View File

@@ -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<Config> = 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<void> = 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<void>> {
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<void> {
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<SessionRegistryRecord[]> {
// 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<SessionRegistryRecord[]> {
// 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<SessionRegistryRecord[]> {
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<void> {
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<void> {
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

View File

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

View File

@@ -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
}
}

View File

@@ -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: `<root> <sessionId>`.
*/
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 <root> <sessionId>')
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) })

View File

@@ -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<void>((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<void>((resolve) => { child.once('exit', () => { resolve() }) })))
}
}, 60_000)
})

View File

@@ -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"
}
]
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。

View File

@@ -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"
}
}

View File

@@ -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<Session, (() => Promise<void>) | '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 })
}

View File

@@ -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 */

View File

@@ -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<Context> {
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<void> => new Promise((resolve) => { setTimeout(resolve, 200) })
/** Read the registry through an independent service, as `dsh list-sessions` would. */
async function listExternally(): Promise<SessionRegistryRecord[]> {
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([])
})
})

View File

@@ -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"
}
]
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。

View File

@@ -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"
}
}

View File

@@ -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<void>>
/**
* 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<void>
/**
* List live sessions, pruning records whose process no longer exists.
* @returns one record per live registered session, newest registration last.
*/
abstract list(): Promise<SessionRegistryRecord[]>
}
export default SessionRegistry

View File

@@ -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<SessionRegistryRecord[]> => {
const records = await listed()
const seen = new Set<string>()
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))

View File

@@ -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
}

View File

@@ -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<void>> {
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<void> {
return Promise.resolve()
}
list(): Promise<SessionRegistryRecord[]> {
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()
})
})

View File

@@ -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"
}
]
}

96
pnpm-lock.yaml generated
View File

@@ -3889,70 +3889,6 @@ importers:
specifier: ^4.0.0-rc.7 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) 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: packages/session-title/session-title:
dependencies: dependencies:
schemastery: schemastery:
@@ -8063,9 +7999,6 @@ packages:
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/proper-lockfile@4.1.4':
resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
'@types/react-dom@18.3.7': '@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies: peerDependencies:
@@ -9169,9 +9102,6 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'} 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: hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
@@ -10159,9 +10089,6 @@ packages:
process-nextick-args@2.0.1: process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} 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: property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
@@ -10265,10 +10192,6 @@ packages:
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
retry@0.13.1: retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -10410,9 +10333,6 @@ packages:
siginfo@2.0.0: siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
signal-exit@4.1.0: signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -12982,10 +12902,6 @@ snapshots:
'@types/prop-types@15.7.15': {} '@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)': '@types/react-dom@18.3.7(@types/react@18.3.31)':
dependencies: dependencies:
'@types/react': 18.3.31 '@types/react': 18.3.31
@@ -14287,8 +14203,6 @@ snapshots:
gopd@1.2.0: {} gopd@1.2.0: {}
graceful-fs@4.2.11: {}
hachure-fill@0.5.2: {} hachure-fill@0.5.2: {}
handlebars@4.7.9: handlebars@4.7.9:
@@ -15469,12 +15383,6 @@ snapshots:
process-nextick-args@2.0.1: {} 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: {} property-information@7.2.0: {}
protobufjs@7.6.4: protobufjs@7.6.4:
@@ -15624,8 +15532,6 @@ snapshots:
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
retry@0.12.0: {}
retry@0.13.1: {} retry@0.13.1: {}
rfdc@1.4.1: {} rfdc@1.4.1: {}
@@ -15862,8 +15768,6 @@ snapshots:
siginfo@2.0.0: {} siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
sisteransi@1.0.5: {} sisteransi@1.0.5: {}

View File

@@ -229,8 +229,6 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', 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', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
Domain: 'domain interface is owned by packages/storage/storage-domain/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', 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', 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', DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md',

View File

@@ -171,15 +171,6 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['apiproxy'], consumers: ['apiproxy'],
note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.', 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', key: 'sessionQuery',
pkg: 'session-query', pkg: 'session-query',

View File

@@ -98,9 +98,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'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': { 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-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-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/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': { 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.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },

View File

@@ -88,7 +88,6 @@
"./packages/session-persistence/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts",
"./packages/session-projection/*/src/invariant.ts", "./packages/session-projection/*/src/invariant.ts",
"./packages/session-query/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts",
"./packages/session-registry/*/src/invariant.ts",
"./packages/telemetry/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts",
"./packages/acp/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts",
"./packages/storage/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts",
@@ -180,7 +179,6 @@
"./packages/session-persistence/*/src", "./packages/session-persistence/*/src",
"./packages/session-projection/*/src", "./packages/session-projection/*/src",
"./packages/session-query/*/src", "./packages/session-query/*/src",
"./packages/session-registry/*/src",
"./packages/session-title/*/src", "./packages/session-title/*/src",
"./packages/telemetry/*/src", "./packages/telemetry/*/src",
"./packages/acp/*/src", "./packages/acp/*/src",

View File

@@ -181,9 +181,6 @@
{ "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp" },
{ "path": "./packages/lsp/lsp-local" }, { "path": "./packages/lsp/lsp-local" },
{ "path": "./packages/lsp/tool-lsp" }, { "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" } { "path": "./apps/cli" }
] ]
} }