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