Merge origin/master: web permission sandbox, default pi-ai providers

This commit is contained in:
Turtle
2026-07-29 14:29:32 +08:00
parent 42e3cceb64
commit e7c0a5b794
147 changed files with 6770 additions and 195 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-registry/README.md
README.md: c79caccd05d6cbdda0663dd897490fb6004b8250
README.zh.md: c3fff3bc6b0e0417dd291d129d7fb1001b50258f

View File

@@ -0,0 +1,15 @@
# session-registry/ — live-session registry family
English | [中文](README.zh.md)
Which sessions are running right now, readable from a different process. `dsh list-sessions` is the consumer.
| Package | Role | ctx key |
|---|---|---|
| [`session-registry/`](session-registry/README.md) | The seam: abstract registry service contract and record vocabulary | `ctx.sessionRegistry` |
| [`session-registry-file/`](session-registry-file/README.md) | Backend: one lock-guarded JSON file, pid-derived liveness | — |
| [`session-registry-live/`](session-registry-live/README.md) | Publisher: follows session lifecycle and title events, keeping the registry in step | — |
The split follows the three-package capability-seam convention: the seam answers "what is live" for a short-lived reader that mounts nothing else, the file backend owns today's medium and can be replaced by a database without touching consumers, and the publisher needs the session store and runs inside a full agent composition. Liveness is derived from the recorded pid at read time rather than stored, so a killed process leaves nothing to clean up. Records carry their own title because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse.
This family is independent of session persistence: it records which processes hold which sessions, never conversation content, and a session that is never persisted still lists.

View File

@@ -0,0 +1,15 @@
# session-registry/:活跃会话注册表家族
[English](README.md) | 中文
当前正在运行哪些会话,可以从另一个进程读取。消费方是 `dsh list-sessions`
| 包 | 职责 | ctx 键 |
|---|---|---|
| [`session-registry/`](session-registry/README.md) | seam抽象注册表服务契约与记录词汇 | `ctx.sessionRegistry` |
| [`session-registry-file/`](session-registry-file/README.md) | 后端:单个加锁保护的 JSON 文件、由 pid 推导的存活状态 | — |
| [`session-registry-live/`](session-registry-live/README.md) | 发布方:跟随会话生命周期与标题事件,让注册表保持同步 | — |
这样拆分遵循由三个包构成的能力 seam 惯例seam 要回答「哪些会话是活跃的」,供一个不挂载其他任何东西的短生命周期读取方使用;文件后端拥有今天的介质,将来可以换成数据库而不触及消费方;发布方需要会话存储,运行在完整的 agent智能体组合体内。存活状态在读取时由记录的 pid 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。
这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-file/README.md
README.md: b6f29a459e5f7f6484aed9f4968d54f969ca6e73
README.zh.md: 35861466ed5fb826ee118c506943c3a08024a827

View File

@@ -0,0 +1,42 @@
# @deepseek-ai/dsh-session-registry-file
English | [中文](README.zh.md)
File-backed implementation of the [live-session registry seam](../session-registry/README.md): one lock-guarded JSON file under the Harness home is the whole medium. Mounting it publishes `ctx.sessionRegistry`; `file` exposes the absolute registry path (`<root>/sessions.json`).
## Liveness and crash safety
Liveness is derived at read time from the recorded pid via `kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive under another user, and any other errno propagates rather than being read as an answer. A process killed without running its disposer therefore leaves a record that the next `list()` prunes and rewrites — no daemon, no heartbeat, and no permanent phantom. `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record belonging to a different incarnation.
## Concurrency
Both layers are required and neither substitutes for the other.
- **Across processes**, each read-modify-write cycle holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Unlocked whole-file republication loses records under concurrent launchers, which is why the storage-hub JSON backend — documented last-write-wins, single-host-process — cannot serve this medium.
- **Within one process**, calls queue on an internal chain. The advisory lock is tracked per process, so overlapping same-process callers contend for its bounded retry budget instead of queueing; past roughly a dozen concurrent calls that budget runs out and a registration rejects. Callers publish fire-and-forget, so such a rejection would silently drop a live session from the listing.
Writes are temp-file plus atomic `rename` (no fsync: a listing lost to a crash is rebuilt by the next process's read, so crash durability buys nothing here), under a `0o700` root with a `0o600` file.
## Durable format
`sessions.json` carries a `version` stamp pinned at `0` under the pre-release stance: a differing version is rejected rather than migrated. Reads validate every field because the medium is shared and user-visible. An individually unusable row is dropped while its siblings survive, and unparsable text or a foreign version reads as empty — one malformed record written by another harness version must not hide every other live session. Any of these marks the medium damaged, so the next write republishes and heals it.
## Config
| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `root` | string | required — no default (a cwd fallback would scatter registries) | Directory holding `sessions.json`; created `0o700` on demand |
| `lockStaleMs` | natural | `10000` | Milliseconds after which a held lock is treated as abandoned and reclaimed |
| `lockRetries` | natural | `10` | Retries before a contended acquisition fails loud |
## Model Experience
None, as this package registers no tools, injects no prompts, and appends no session events; it stores host-side process records for the CLI listing surface only.
#### KV Cache effect
Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse.
## Known Limitations and Deferred Work
- **A reused pid within the stale window is trusted** — `bootId` distinguishes incarnations of records this process wrote, but a foreign record whose pid the operating system has since reassigned to an unrelated live process is reported alive until its owner removes it.

View File

@@ -0,0 +1,42 @@
# @deepseek-ai/dsh-session-registry-file
[English](README.md) | 中文
[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry``file` 暴露注册表文件的绝对路径(`<root>/sessions.json`)。
## 存活状态与崩溃安全
存活状态在读取时由记录的 pid 经 `kill(pid, 0)` 推导:`ESRCH` 表示已消亡,`EPERM` 表示存活于另一个用户之下,其他任何 errno 都向外抛出,而不会被当成一个答案来解读。因此,未运行 disposer 就被杀掉的进程留下的记录,会被下一次 `list()` 剪除并重写——不需要 daemon不需要心跳也不会有永久残留的幽灵记录。`bootId` 用于区分被复用的 pid因此注销不会删除属于另一个 incarnation 的同名记录。
## 并发
两层机制都是必需的,任何一层都无法替代另一层。
- **跨进程**:每个读改写周期都持有 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。无锁的全文件重发布会在并发启动器下丢失记录,这正是 storage-hub JSON 后端(文档声明 last-write-wins、单宿主进程无法承担该介质的原因。
- **进程内**:调用在内部链上排队。咨询锁按进程跟踪,因此同进程的重叠调用者会争用其有限的重试预算而非排队;并发调用超过十来个时预算耗尽,注册会被拒绝。调用方以 fire-and-forget 方式发布,这样的拒绝会静默地把一个存活会话从列表中丢掉。
写入采用临时文件加原子 `rename`(不做 fsync崩溃丢失的列表会被下一个进程的读取重建崩溃持久性在这里没有收益根目录 `0o700`,文件 `0o600`
## 持久化格式
`sessions.json` 携带一个 `version` 戳,在预发布立场下固定为 `0`:版本不同将被拒绝而非迁移。由于介质是共享且用户可见的,读取会校验每个字段。单条不可用的行会被丢弃而其同伴保留;无法解析的文本或异版本文件读作空——另一个 harness 版本写入的一条损坏记录,不得隐藏所有其他存活会话。上述任一情况都会把介质标记为受损,下一次写入将重新发布并修复它。
## 配置
| 键 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `root` | string | 必填——无默认值(回退到 cwd 会使注册表散落各处) | 存放 `sessions.json` 的目录;按需以 `0o700` 创建 |
| `lockStaleMs` | natural | `10000` | 持有的锁超过该毫秒数即视为被遗弃并被回收 |
| `lockRetries` | natural | `10` | 锁争用时在明确失败前的重试次数 |
## 模型体验
无。本包不注册工具、不注入提示词、不追加会话事件;它只为 CLI 列表界面存储宿主侧进程记录。
#### KV 缓存影响
与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。
## 已知限制与后续工作
- **陈旧窗口内被复用的 pid 会被信任**——`bootId` 能区分本进程所写记录的 incarnation但外来记录的 pid 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。

View File

@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-session-registry-file",
"description": "Lock-guarded JSON-file backend for the dsh live-session registry seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"proper-lockfile": "^4.1.2",
"schemastery": "^3.15.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-registry": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-registry": "workspace:^",
"@types/proper-lockfile": "^4.1.4",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,97 @@
/**
* Registry file format: the durable boundary between independent `dsh`
* processes. Every field is validated on read because the medium is shared,
* user-visible, and writable by other harness versions — a foreign or truncated
* file must not crash `dsh list-sessions` into an empty listing that hides live sessions.
* @module @deepseek-ai/dsh-session-registry-file/file
*/
import { SessionId } from '@deepseek-ai/dsh-session'
import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
/**
* On-disk format version. Pinned at `0` under the pre-release stance: a
* differing version is rejected rather than migrated, matching every other
* harness backend.
*/
export const SESSION_REGISTRY_FORMAT_VERSION = 0
/** The complete registry file: a version stamp plus the live records. */
export interface RegistryFileContents {
/** Format stamp, always {@link SESSION_REGISTRY_FORMAT_VERSION} when written. */
readonly version: number
/** One record per registered process, in no significant order. */
readonly records: readonly SessionRegistryRecord[]
}
/** An empty registry: the value a missing file reads as. */
export const EMPTY_REGISTRY: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records: [] }
/** Narrow an unknown JSON value to a record shape, or reject it as unusable. */
function parseRecord(value: unknown): SessionRegistryRecord | undefined {
if (typeof value !== 'object' || value === null) return undefined
const row = value as Record<string, unknown>
const { sessionId, pid, cwd, startedAt, bootId } = row
if (typeof sessionId !== 'string' || sessionId === '') return undefined
// A non-integer or non-positive pid cannot be probed for liveness.
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined
if (typeof cwd !== 'string' || cwd === '') return undefined
if (typeof startedAt !== 'number' || !Number.isSafeInteger(startedAt) || startedAt < 0) return undefined
if (typeof bootId !== 'string' || bootId === '') return undefined
// An absent title is legal (a fresh session has none); a present but
// non-string one is a damaged row rather than a missing optional field.
const { title } = row
if (title !== undefined && typeof title !== 'string') return undefined
return {
sessionId: SessionId(sessionId),
pid,
cwd,
startedAt,
bootId: BootId(bootId),
...title !== undefined && { title },
}
}
/**
* Parse registry file text into records, dropping individually unusable rows.
*
* A row that cannot be interpreted is dropped rather than rejected wholesale:
* one malformed record written by a different harness version must not hide
* every other live session. Unparsable text and a version mismatch yield an
* empty registry for the same reason — the caller republishes the whole file, so
* the next write heals the medium.
* @param text - the raw file contents.
* @returns the records that parsed, and whether the text was fully understood.
*/
export function parseRegistry(text: string): { records: SessionRegistryRecord[]; intact: boolean } {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
// Swallows only SyntaxError from this one JSON.parse: a torn or foreign
// file heals on the next write, and nothing else can reach this catch.
return { records: [], intact: false }
}
if (typeof parsed !== 'object' || parsed === null) return { records: [], intact: false }
const file = parsed as Record<string, unknown>
if (file.version !== SESSION_REGISTRY_FORMAT_VERSION) return { records: [], intact: false }
if (!Array.isArray(file.records)) return { records: [], intact: false }
const records: SessionRegistryRecord[] = []
let intact = true
for (const row of file.records) {
const record = parseRecord(row)
if (record === undefined) intact = false
else records.push(record)
}
return { records, intact }
}
/**
* Serialize records as registry file text.
* @param records - the live records to publish.
* @returns pretty-printed JSON with a trailing newline, for a legible medium.
*/
export function serializeRegistry(records: readonly SessionRegistryRecord[]): string {
const file: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records }
return `${JSON.stringify(file, undefined, 2)}\n`
}

View File

@@ -0,0 +1,233 @@
/**
* File-backed live-session registry: one lock-guarded JSON file under the
* Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every
* operation is a read-modify-write under an advisory lock, because concurrent
* launchers write the same file — the storage-hub JSON backend documents
* last-write-wins for exactly this case and cannot be reused. Liveness is
* derived at read time from the recorded pid.
* @module @deepseek-ai/dsh-session-registry-file
*/
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
import lockfile from 'proper-lockfile'
import z from 'schemastery'
import type { SessionId } from '@deepseek-ai/dsh-session'
import {
SessionRegistry, BootId,
type SessionRegistration, type SessionRegistryRecord,
} from '@deepseek-ai/dsh-session-registry'
import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts'
import { isPidAlive } from './liveness.ts'
export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts'
export type { RegistryFileContents } from './file.ts'
export { isPidAlive } from './liveness.ts'
/** The file name holding the registry, relative to {@link Config.root}. */
export const REGISTRY_FILE_NAME = 'sessions.json'
/** Default lock staleness threshold; a held lock older than this is reclaimed. */
const DEFAULT_LOCK_STALE_MS = 10_000
/** Default retry budget for a contended lock acquisition. */
const DEFAULT_LOCK_RETRIES = 10
/**
* Plugin config as callers write it: `root` is required — a cwd fallback would
* scatter registries — while the lock tunables are optional because
* `static Config` supplies their defaults.
*/
export interface Config {
/** Directory holding the registry file; created `0o700` on demand. */
root: string
/** Milliseconds after which a held lock is considered abandoned and reclaimed. */
lockStaleMs?: number
/** Retries before a contended acquisition fails loud. */
lockRetries?: number
}
/** The file-backed {@link SessionRegistry} implementation. */
export class SessionRegistryFile extends SessionRegistry {
static Config: z<Config> = z.object({
root: z.string().required(),
lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS),
lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES),
})
/** Absolute path of the registry file this service reads and writes. */
readonly file: string
/** Directory holding {@link file}, created `0o700` on demand. */
private readonly root: string
/** Tail of the in-process serialization chain; see {@link mutate}. */
private chain: Promise<void> = Promise.resolve()
/** Resolved lock staleness threshold in milliseconds, fixed at construction. */
private readonly stale: number
/** Resolved contended-acquisition retry budget, fixed at construction. */
private readonly retries: number
constructor(ctx: Context, config: Config) {
super(ctx, BootId(randomUUID()))
this.root = config.root
this.file = join(this.root, REGISTRY_FILE_NAME)
// Resolve the optional tunables here, once: `static Config` supplies these
// same defaults for a Loader mount, and a direct programmatic mount that
// omits them gets them too rather than an undefined lock option.
this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS
this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES
}
/** @inheritdoc */
async register(registration: SessionRegistration): Promise<() => Promise<void>> {
const record: SessionRegistryRecord = {
sessionId: registration.sessionId,
pid: process.pid,
cwd: registration.cwd,
startedAt: Date.now(),
bootId: this.bootId,
...registration.title !== undefined && { title: registration.title },
}
await this.mutate(records => [
...records.filter(other => other.sessionId !== record.sessionId),
record,
])
// The disposer is awaited by Cordis teardown, so the record is durably gone
// before disposal completes rather than racing process exit. A failure here
// is reported, not thrown: the record is already pid-prunable, and an
// unwinding teardown must not be turned into a rejection.
return this.ctx.effect(() => async () => {
try {
await this.mutate(records => records.filter(other => !this.isSelf(other, record)))
} catch (error) {
this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error))
}
})
}
/** @inheritdoc */
async retitle(sessionId: SessionId, title: string): Promise<void> {
await this.mutate(records => records.map(record =>
record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId
? { ...record, title }
: record))
}
/** @inheritdoc */
async list(): Promise<SessionRegistryRecord[]> {
// Pruning is a write, so the read path takes the same lock: a listing that
// observed a half-written file could omit a live session.
return this.mutate(records => [...records])
}
/** True when a stored record is this exact registration (pid AND incarnation). */
private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean {
return candidate.sessionId === self.sessionId
&& candidate.pid === self.pid
&& candidate.bootId === self.bootId
}
/**
* Serialize one read-modify-write cycle against every other cycle in THIS
* process, then run it under the cross-process lock.
*
* Both layers are required and neither substitutes for the other. The advisory
* lock excludes other processes but is tracked per process, so it rejects a
* same-process concurrent acquisition outright (`ELOCKED`) instead of queueing
* — and a composition that creates several sessions at once really does
* overlap these calls. This chain gives those callers a queue; the lock gives
* independent processes exclusion.
*/
private mutate(
change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[],
): Promise<SessionRegistryRecord[]> {
// Failures must not poison the chain for later callers, so the tail only
// tracks settlement, never the rejection itself.
const result = this.chain.then(() => this.mutateExclusively(change))
this.chain = result.then(() => undefined, () => undefined)
return result
}
/**
* Run one locked read-modify-write cycle: read, prune dead records, apply
* `change`, and republish when the result differs from what was stored.
*/
private async mutateExclusively(
change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[],
): Promise<SessionRegistryRecord[]> {
await mkdir(this.root, { recursive: true, mode: 0o700 })
// proper-lockfile needs the target to exist before it can guard it; an
// exclusive create loses harmlessly to a concurrent launcher doing the same.
await this.ensureFile()
const release = await lockfile.lock(this.file, {
stale: this.stale,
retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 },
})
try {
const before = await this.read()
const live = before.records.filter(record => isPidAlive(record.pid))
const next = change(live)
// Republish when a record changed or the medium itself was damaged, so a
// foreign or torn file heals instead of being re-parsed on every read.
if (!before.intact || !sameRecords(before.records, next)) await this.write(next)
return next
} finally {
await release()
}
}
/** Create the registry file if absent, without disturbing existing content. */
private async ensureFile(): Promise<void> {
try {
const handle = await open(this.file, 'wx', 0o600)
try {
await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records))
} finally {
await handle.close()
}
} catch (error) {
// Swallows only EEXIST: another launcher created the file first, which is
// the intended outcome. Every other errno propagates.
/* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
}
/** Read and parse the registry file; a missing file reads as empty. */
private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> {
// The caller holds the lock, and acquiring it requires the file to exist, so
// a read failure here is real corruption rather than an absent registry and
// propagates: a swallowed error would report "no live sessions" for a medium
// that could not be read.
return parseRegistry(await readFile(this.file, 'utf8'))
}
/** Publish the complete record set via temp-write plus atomic rename. */
private async write(records: readonly SessionRegistryRecord[]): Promise<void> {
const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`)
await writeFile(temp, serializeRegistry(records), { mode: 0o600 })
await rename(temp, this.file)
}
}
/** Compare record lists by identity fields, to decide whether a write is needed. */
function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean {
if (left.length !== right.length) return false
return left.every((record, index) => {
const other = right[index]
return other !== undefined
&& record.sessionId === other.sessionId
&& record.pid === other.pid
&& record.bootId === other.bootId
&& record.cwd === other.cwd
&& record.startedAt === other.startedAt
&& record.title === other.title
})
}
export default SessionRegistryFile

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-file`.
* @module @deepseek-ai/dsh-session-registry-file/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-file'
/** Cordis companion plugin name. */
export const name = 'session-registry-file-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the relations a reader must trust (unique live session
* ids, attributable pids) are contract-level and validated by the seam's
* companion around the authoritative `list()`, whatever backend serves it. The
* file medium's own correctness — locking, atomic republication, and
* foreign-row rejection — requires cross-process round-trip tests, not a
* continuously observable in-process relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,30 @@
/**
* Process-liveness probe for stored registry records.
* @module @deepseek-ai/dsh-session-registry-file/liveness
*/
/**
* Signal-0 probe: report whether a pid currently exists.
*
* `kill(pid, 0)` sends no signal and only tests existence. `ESRCH` means no such
* process. `EPERM` means the process exists but is owned by another user, which
* is still alive — reporting it dead would drop a live record. Any other errno
* is unexpected and propagates rather than being read as a liveness answer.
* @param pid - the operating-system process id to probe.
* @param kill - signal sender, defaulting to `process.kill`; injected by tests.
* @returns whether a process with this pid exists.
*/
export function isPidAlive(
pid: number,
kill: (pid: number, signal: number) => void = process.kill.bind(process),
): boolean {
try {
kill(pid, 0)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ESRCH') return false
if (code === 'EPERM') return true
throw error
}
}

View File

@@ -0,0 +1,27 @@
/**
* Concurrency-test driver: register one session in a real separate process,
* report readiness on stdout, then stay alive until the parent closes stdin.
*
* Staying alive is load-bearing. The registry prunes records whose process is
* gone, so a driver that exited after writing would be pruned by the next
* writer — the test would then measure pruning instead of the concurrent
* read-modify-write it exists to cover. Argv: `<root> <sessionId>`.
*/
import { Context } from 'cordis'
import { SessionId } from '@deepseek-ai/dsh-session'
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
const [root, sessionId] = process.argv.slice(2)
if (root === undefined || sessionId === undefined) throw new Error('usage: register-once <root> <sessionId>')
const ctx = new Context()
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 60 })
await ctx.sessionRegistry.register({ sessionId: SessionId(sessionId), cwd: process.cwd() })
process.stdout.write('registered\n')
// Hold the process open so its record stays live; the parent ends the run by
// closing stdin, and never disposes the fiber, so no deregistration races the
// parent's read.
process.stdin.resume()
process.stdin.on('end', () => { process.exit(0) })

View File

@@ -0,0 +1,382 @@
/**
* Tests for the cross-process live-session registry: records survive a
* round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign
* record, the file format rejects foreign and torn media without hiding live
* sessions, disposal deregisters, and concurrent registrations from independent
* processes all survive (the failure the advisory lock exists to prevent).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { execFile, spawn } from 'node:child_process'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { SessionId } from '@deepseek-ai/dsh-session'
import { BootId } from '@deepseek-ai/dsh-session-registry'
import SessionRegistryFile, {
REGISTRY_FILE_NAME,
SESSION_REGISTRY_FORMAT_VERSION,
isPidAlive,
parseRegistry,
serializeRegistry,
} from '@deepseek-ai/dsh-session-registry-file'
const run = promisify(execFile)
let root: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-'))
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
/** Mount the service on a fresh Cordis fiber, returning it with its context. */
async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> {
const ctx = new Context()
await ctx.plugin(SessionRegistryFile, { root })
return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile }
}
const file = (): string => join(root, REGISTRY_FILE_NAME)
describe('config resolution', () => {
it('applies the shipped lock defaults when a caller omits them', async () => {
// `ctx.plugin` runs the schema, which fills these in, so the constructor's
// own resolution is reachable only by constructing the service directly —
// the path a programmatic embedder takes.
const ctx = new Context()
const service = new SessionRegistryFile(ctx, { root })
await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' })
expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted'])
await ctx.fiber.dispose()
})
it('honors explicitly configured lock tunables', async () => {
const ctx = new Context()
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 })
await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' })
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned'])
await ctx.fiber.dispose()
})
})
describe('register and list', () => {
it('publishes a record readable by an independent service instance', async () => {
const first = await service()
await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' })
// A second instance stands in for another process reading the same file.
const reader = await service()
const listed = await reader.registry.list()
expect(listed).toHaveLength(1)
expect(listed[0]).toMatchObject({
sessionId: 'sess-1',
cwd: '/tmp/project',
pid: process.pid,
})
await first.ctx.fiber.dispose()
await reader.ctx.fiber.dispose()
})
it('replaces an earlier record for the same session id', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' })
const listed = await registry.list()
expect(listed).toHaveLength(1)
// The later registration wins: `cwd` distinguishes the two calls.
expect(listed[0]?.cwd).toBe('/b')
await ctx.fiber.dispose()
})
it('creates the registry root private and the file owner-only', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
expect(statSync(root).mode & 0o777).toBe(0o700)
expect(statSync(file()).mode & 0o777).toBe(0o600)
await ctx.fiber.dispose()
})
})
describe('liveness pruning', () => {
it('drops a record whose process is gone', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('live'), cwd: '/a' })
// A real exited pid: spawn a process, wait for it, then claim its id. The
// kernel has reaped it, so signal 0 reports ESRCH.
const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'])
const deadPid = Number(dead.stdout)
expect(isPidAlive(deadPid)).toBe(false)
const stored = parseRegistry(readFileSync(file(), 'utf8')).records
writeFileSync(file(), serializeRegistry([
...stored,
{ sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') },
]))
const listed = await registry.list()
expect(listed.map(record => record.sessionId)).toEqual(['live'])
// The prune is durable, not just filtered in memory.
expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live'])
await ctx.fiber.dispose()
})
it('keeps a live record owned by another user (EPERM means alive)', () => {
const eperm = (): never => {
const error = new Error('operation not permitted') as NodeJS.ErrnoException
error.code = 'EPERM'
throw error
}
expect(isPidAlive(1, eperm)).toBe(true)
})
it('propagates an unexpected errno instead of guessing liveness', () => {
const einval = (): never => {
const error = new Error('invalid') as NodeJS.ErrnoException
error.code = 'EINVAL'
throw error
}
expect(() => isPidAlive(1, einval)).toThrow('invalid')
})
})
describe('pid recycling', () => {
it('deregistration removes only this incarnation, not a namesake pid', async () => {
const { ctx, registry } = await service()
const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' })
// A foreign record reusing THIS live pid under a different session and boot
// id: deregistering must not delete it.
const stored = parseRegistry(readFileSync(file(), 'utf8')).records
writeFileSync(file(), serializeRegistry([
...stored,
{ sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') },
]))
// Awaiting the disposer is the contract: the record is durably gone when it
// settles, so the assertion needs no timing slack.
await disposer()
const listed = await registry.list()
expect(listed.map(record => record.sessionId)).toEqual(['other'])
await ctx.fiber.dispose()
})
})
describe('file format', () => {
it('round-trips records', () => {
const records = [{
sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'),
}]
expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true })
})
it('stamps the format version', () => {
const stamped = JSON.parse(serializeRegistry([])) as { version: number }
expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION)
})
it.each([
['torn json', '{"version":0,"records":[{'],
['a foreign version', '{"version":99,"records":[]}'],
['a non-object root', '[]'],
['a null root', 'null'],
['a non-array records field', '{"version":0,"records":{}}'],
])('reads %s as an empty, non-intact registry', (_label, text) => {
expect(parseRegistry(text)).toEqual({ records: [], intact: false })
})
it.each([
['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }],
['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }],
['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }],
['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }],
['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }],
['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }],
['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }],
['a non-object row', 'nonsense'],
])('drops a row with %s but keeps its intact siblings', (_label, row) => {
const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }
const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] })
const parsed = parseRegistry(text)
expect(parsed.records.map(record => record.sessionId)).toEqual(['keep'])
expect(parsed.intact).toBe(false)
})
it('heals a damaged medium on the next locked write', async () => {
writeFileSync(file(), 'not json at all')
const { ctx, registry } = await service()
await registry.list()
expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true)
await ctx.fiber.dispose()
})
it('reads a missing file as no live sessions', async () => {
const { ctx, registry } = await service()
rmSync(file(), { force: true })
expect(await registry.list()).toEqual([])
await ctx.fiber.dispose()
})
})
describe('failure reporting', () => {
it('tolerates a registry file another process created first', async () => {
// Two services racing `ensureFile`: the loser sees EEXIST, which is the
// intended outcome rather than an error, and both still publish.
const first = await service()
const second = await service()
await Promise.all([
first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }),
second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }),
])
expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b'])
await first.ctx.fiber.dispose()
await second.ctx.fiber.dispose()
})
it('warns instead of throwing when deregistration fails during teardown', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })
// Make the registry path unusable, so the disposer's own write fails while the
// fiber is already unwinding. Teardown must still complete.
rmSync(root, { recursive: true, force: true })
mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true })
await expect(ctx.fiber.dispose()).resolves.not.toThrow()
})
it('propagates a read failure that is not a missing file', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' })
// A directory where the file belongs makes the read fail with EISDIR, which
// is corruption rather than "no live sessions" and must not read as empty.
rmSync(file(), { force: true })
mkdirSync(file(), { recursive: true })
await expect(registry.list()).rejects.toThrow()
rmSync(file(), { recursive: true, force: true })
await ctx.fiber.dispose()
})
})
describe('retitle', () => {
it('replaces the recorded title of a session this process owns', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' })
expect((await registry.list())[0]?.title).toBeUndefined()
await registry.retitle(SessionId('sess-1'), 'first')
expect((await registry.list())[0]?.title).toBe('first')
await registry.retitle(SessionId('sess-1'), 'second')
expect((await registry.list())[0]?.title).toBe('second')
await ctx.fiber.dispose()
})
it('accepts a registration that already carries a title', async () => {
const { ctx, registry } = await service()
await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' })
expect((await registry.list())[0]?.title).toBe('preset')
await ctx.fiber.dispose()
})
it('leaves a same-id record owned by another incarnation untouched', async () => {
const { ctx, registry } = await service()
// Same live pid, different boot id: another incarnation's record must not be
// retitled by this one.
writeFileSync(file(), serializeRegistry([
{ sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') },
]))
await registry.retitle(SessionId('foreign'), 'not mine')
expect((await registry.list())[0]?.title).toBeUndefined()
await ctx.fiber.dispose()
})
it('ignores an unknown session id, since a title can resolve after removal', async () => {
const { ctx, registry } = await service()
await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined()
expect(await registry.list()).toEqual([])
await ctx.fiber.dispose()
})
})
describe('same-process concurrency', () => {
it('keeps every record when one process registers several sessions at once', async () => {
// The advisory lock is tracked per process, so same-process callers contend
// for it through its bounded retry budget instead of queueing. Past a dozen
// or so overlapping calls that budget runs out and a registration rejects —
// and callers publish fire-and-forget, so the rejection is swallowed and the
// session silently vanishes from the listing. The service therefore
// serializes its own callers; the lock only excludes other processes.
const { ctx, registry } = await service()
// Register once first so the file and directory already exist: without that,
// the concurrent calls serialize behind their own mkdir/create awaits and the
// overlap under test never happens.
await registry.register({ sessionId: SessionId('warm'), cwd: '/w' })
const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) =>
registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` })))
// Every call must SUCCEED, not merely leave the file consistent. Callers
// publish fire-and-forget, so a rejection is swallowed and the session
// silently vanishes from the listing rather than failing loudly.
expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([])
const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort()
expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected)
await ctx.fiber.dispose()
})
it('keeps serving later callers after one cycle fails', async () => {
const { ctx, registry } = await service()
// A directory sitting where the registry file must be makes one cycle fail
// without breaking the shared chain for the calls queued behind it.
rmSync(root, { recursive: true, force: true })
mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true })
await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow()
rmSync(root, { recursive: true, force: true })
await registry.register({ sessionId: SessionId('after'), cwd: '/w' })
expect((await registry.list()).map(record => record.sessionId)).toEqual(['after'])
await ctx.fiber.dispose()
})
})
describe('cross-process concurrency', () => {
it('keeps every record when independent processes register at once', async () => {
// The regression that motivates the advisory lock: unlocked whole-file
// republication loses records under concurrent writers. Real processes are
// required — same-process promises would serialize on the event loop.
const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url))
const count = 8
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs')
// Source plane: tsx resolves the workspace import through the root
// tsconfig `paths` to `src`, so this runs without a build step.
const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }
const children = Array.from({ length: count }, (_unused, index) =>
spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], {
env,
stdio: ['pipe', 'pipe', 'inherit'],
}))
try {
// Every child must have committed its record AND still be alive when the
// file is read, so the assertion sees concurrent writes rather than prunes.
await Promise.all(children.map(child => new Promise<void>((resolve, reject) => {
child.stdout.once('data', () => { resolve() })
child.once('error', reject)
child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) })
})))
const stored = parseRegistry(readFileSync(file(), 'utf8'))
expect(stored.intact).toBe(true)
expect(stored.records.map(record => record.sessionId).sort()).toEqual(
Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(),
)
} finally {
for (const child of children) child.stdin.end()
await Promise.all(children.map(child => new Promise<void>((resolve) => { child.once('exit', () => { resolve() }) })))
}
}, 60_000)
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../session-registry"
},
{
"path": "../../support/invariants"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-live/README.md
README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040
README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515

View File

@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-session-registry-live
English | [中文](README.zh.md)
Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front.
## Behavior
Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions.
A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show.
`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one.
Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune.
## Config
None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure.
## Model Experience
None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record.
#### KV Cache effect
Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse.
## Known Limitations and Deferred Work
- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check.
- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision.

View File

@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-session-registry-live
[English](README.md) | 中文
把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。
## 行为
注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose资源释放时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。
会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。
`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL因此独立进程无法以可移植的方式解析它。
发布是 fire-and-forget失败只发出警告注册表是一项可观测性辅助设施因此注册表故障绝不能让正常工作的 agent智能体会话失败。会话在其注册仍在途中时结束会留下一个 tombstone让即将完成的注册观测到因此它的记录不会一直存活到某次基于 pid 的清理才消失。
## 配置
无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。
## 模型体验
无。该包package不注册工具、不注入提示词也不追加会话事件它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。
#### KV 缓存影响
与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。
## 已知限制与延期工作
- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。
- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。

View File

@@ -0,0 +1,44 @@
{
"name": "@deepseek-ai/dsh-session-registry-live",
"description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-registry": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-registry": "workspace:^",
"@deepseek-ai/dsh-session-registry-file": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,84 @@
/**
* Publishes every live session in this process into the cross-process session
* registry, so `dsh list-sessions` lists sessions a server creates on demand rather than
* only the one a launcher minted up front.
*
* Mounted in a composition whose sessions come and go — the browser UI creates
* one per conversation — this plugin follows `session/created` and
* `session/disposed` instead of registering a single launcher-known identity.
* A session with no `cwd` in its header is skipped: the registry's workspace
* column would have nothing truthful to show, and a subagent child is exactly
* that case. Titles are mirrored into the record as `session/title` events
* arrive, so a reader never has to parse a backend's log format.
* @module @deepseek-ai/dsh-session-registry-live
*/
import type { Context } from 'cordis'
import type { Session } from '@deepseek-ai/dsh-session'
// Empty type imports carry the Context merges this plugin relies on: the
// `sessionRegistry` service and the `session/title` session event.
import type {} from '@deepseek-ai/dsh-session-registry'
import type {} from '@deepseek-ai/dsh-session-title'
/** Cordis plugin name. */
export const name = 'session-registry-live'
/** Services required before sessions can be followed and records published. */
export const inject = ['sessions', 'sessionRegistry']
/**
* Follow session lifecycle and keep the registry in step.
* @param ctx - context carrying the session store and the registry service.
*/
export function apply(ctx: Context): void {
/**
* Per-session registration state. `'disposing'` is a tombstone written when a
* session ends while its registration is still in flight: without it the
* late-arriving disposer would be stored for a session that no longer exists
* and its record would outlive the session until a pid-based prune.
*/
const registered = new Map<Session, (() => Promise<void>) | 'disposing'>()
const publish = (session: Session): void => {
const cwd = session.header.cwd
// A session without a workspace has no listable location; skipping keeps the
// registry free of rows `dsh list-sessions` could not render truthfully.
if (cwd === undefined) return
void ctx.sessionRegistry.register({ sessionId: session.id, cwd })
.then((dispose) => {
if (registered.get(session) === 'disposing') {
registered.delete(session)
void dispose()
return
}
registered.set(session, dispose)
})
.catch((error: unknown) => {
registered.delete(session)
ctx.logger.warn('failed to publish session %s: %s', session.id, String(error))
})
}
for (const session of ctx.sessions.list()) publish(session)
ctx.on('session/created', (session) => { publish(session) }, { global: true })
ctx.on('session/disposed', (session) => {
const entry = registered.get(session)
if (typeof entry === 'function') {
registered.delete(session)
void entry()
return
}
// Registration is still in flight; leave a tombstone for it to observe.
registered.set(session, 'disposing')
}, { global: true })
// Mirror title revisions onto the record. A title arrives after registration
// and may be replaced, so the listing tracks the latest logged value.
ctx.on('session/event', (session, event) => {
if (event.type !== 'session/title') return
const { title } = event.data
void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => {
ctx.logger.warn('failed to retitle %s: %s', session.id, String(error))
})
}, { global: true })
}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`.
* @module @deepseek-ai/dsh-session-registry-live/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live'
/** Cordis companion plugin name. */
export const name = 'session-registry-live-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this plugin owns no durable state of its own — the
* uniqueness and liveness relations over published records are checked by the
* companion in `@deepseek-ai/dsh-session-registry`, which owns that file.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,227 @@
/**
* Tests for the live-session publisher over the REAL session store, so
* publication follows the store's actual lifecycle dispatch rather than a
* hand-built event emitter: sessions created after mount are published,
* disposal removes their records, a session without a workspace is skipped, and
* logged title revisions are mirrored onto the record so a reader never parses a
* backend's log format.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
import * as live from '@deepseek-ai/dsh-session-registry-live'
// Empty type import carries the `session/title` event into the session-event map.
import type {} from '@deepseek-ai/dsh-session-title'
let root: string
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) })
afterEach(() => {
rmSync(root, { recursive: true, force: true })
vi.restoreAllMocks()
})
/** Mount the real store plus the publisher. */
async function mount(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
await ctx.plugin(live)
return ctx
}
/** Let the publisher's fire-and-forget registration reach durability. */
const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 200) })
/** Read the registry through an independent service, as `dsh list-sessions` would. */
async function listExternally(): Promise<SessionRegistryRecord[]> {
const reader = new Context()
await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
const records = await reader.sessionRegistry.list()
await reader.fiber.dispose()
return records
}
describe('publishing', () => {
it('publishes sessions that already exist when the plugin mounts', async () => {
// A composition may mount the publisher after sessions exist (a resumed
// session, or plugin order), so mount-time adoption is its own path.
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } })
await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 })
await ctx.plugin(live)
await settle()
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting'])
await ctx.fiber.dispose()
})
it('publishes a session created after mount', async () => {
const ctx = await mount()
ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } })
await settle()
const listed = await ctx.sessionRegistry.list()
expect(listed).toHaveLength(1)
expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' })
await ctx.fiber.dispose()
})
it('skips a session with no workspace, having nothing truthful to list', async () => {
const ctx = await mount()
ctx.sessions.create(SessionId('no-cwd'))
await settle()
expect(await ctx.sessionRegistry.list()).toEqual([])
await ctx.fiber.dispose()
})
it('has no title until one is logged', async () => {
const ctx = await mount()
ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } })
await settle()
expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined()
await ctx.fiber.dispose()
})
it('mirrors the latest logged title onto the record', async () => {
const ctx = await mount()
const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } })
await settle()
session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } })
await settle()
expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess')
// A revision replaces the previous value rather than accumulating.
session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } })
await settle()
expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title')
await ctx.fiber.dispose()
})
it('ignores session events other than a title revision', async () => {
const ctx = await mount()
const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } })
await settle()
const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await settle()
expect(retitle).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('retitles only the session that logged the event', async () => {
const ctx = await mount()
const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } })
ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } })
await settle()
first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } })
await settle()
const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title]))
expect(byId.get(SessionId('one'))).toBe('only mine')
expect(byId.get(SessionId('two'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('publishes every concurrently created session', async () => {
const ctx = await mount()
for (let index = 0; index < 5; index += 1) {
ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } })
}
await settle()
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort())
.toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4'])
await ctx.fiber.dispose()
})
})
describe('failure and race handling', () => {
it('removes the record when a session is disposed mid-registration', async () => {
// The tombstone path: the session ends before its registration resolves, so
// the late disposer must be applied instead of stored for a dead session.
const ctx = await mount()
let owner: Context | undefined
await ctx.plugin({
inject: ['sessions'],
apply: (child: Context) => {
owner = child
child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } })
},
})
// No settle: dispose while `register` is still in flight.
await owner?.fiber.dispose()
await settle()
expect(await ctx.sessionRegistry.list()).toEqual([])
await ctx.fiber.dispose()
})
it('warns and drops the record when publication fails', async () => {
const ctx = await mount()
ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline'))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } })
await settle()
expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/)
await ctx.fiber.dispose()
})
it('warns when a title revision cannot be recorded', async () => {
const ctx = await mount()
const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } })
await settle()
ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline'))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } })
await settle()
expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/)
await ctx.fiber.dispose()
})
})
describe('disposal', () => {
it('removes a record when its own session is disposed, keeping the others', async () => {
const ctx = await mount()
// A session belongs to the fiber that created it, so a child plugin fiber
// gives one session an independent lifetime without disposing the services.
let owner: Context | undefined
await ctx.plugin({
inject: ['sessions'],
apply: (child: Context) => {
owner = child
child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } })
},
})
ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } })
await settle()
expect(await ctx.sessionRegistry.list()).toHaveLength(2)
// Disposing only that fiber ends its session, which the publisher follows.
await owner?.fiber.dispose()
await settle()
expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable'])
await ctx.fiber.dispose()
})
it('leaves no record behind after the whole tree unloads', async () => {
const ctx = await mount()
ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } })
ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } })
await settle()
expect(await ctx.sessionRegistry.list()).toHaveLength(2)
await ctx.fiber.dispose()
expect(await listExternally()).toEqual([])
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
},
{
"path": "../../core/session"
},
{
"path": "../session-registry"
},
{
"path": "../../session-title/session-title"
}
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-registry/session-registry/README.md
README.md: 8ab8e232e6cd041e5476d4e6cadcb8b64a85f586
README.zh.md: 62774e8a2a892c97ba6343f12dd35f827ad63e33

View File

@@ -0,0 +1,30 @@
# @deepseek-ai/dsh-session-registry
English | [中文](README.zh.md)
Live-session registry seam (`ctx.sessionRegistry`): the contract and record vocabulary for a cross-process registry of the sessions running right now, so a separate short-lived process such as `dsh list-sessions` can answer "what am I running". This package owns no medium — a backend (the lock-guarded JSON file in [`session-registry-file`](../session-registry-file/README.md) today, a database later) implements the abstract service.
## Shape
- `register(registration)` — publish `{ sessionId, cwd, title? }` stamped with this process's pid, a per-incarnation `bootId`, and `startedAt`. Replaces any existing record for the same session id. Returns the `ctx.effect` disposer; awaiting it waits for the removal to reach durability.
- `retitle(sessionId, title)` — replace the recorded title of a session **this** process registered. Titles arrive after registration and can be revised, so it is the one mutable field. A record owned by another pid or incarnation is left alone, and an unknown id is a no-op because a title can resolve after the record is gone.
- `list()` — every live record, newest registration last. Liveness is part of the contract, not the backend's discretion: every returned record's process existed at observation time, so a process killed without running its disposer leaves no permanent phantom.
Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write.
## Record vocabulary
`SessionRegistryRecord` carries `sessionId` (unique across live records), `pid`, `cwd`, `startedAt`, a `bootId` distinguishing a recycled pid from the original incarnation, and an optional `title`. The title travels in the record rather than being read from the session log because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse.
## Model Experience
None, as this package registers no tools, injects no prompts, and appends no session events; it defines the host-side listing contract only.
#### KV Cache effect
Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse.
## Known Limitations and Deferred Work
- **Records are process-scoped, not agent-scoped** — only top-level launcher surfaces publish. In-process subagents have no process of their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` rather than the CLI, so neither appears in a listing.
- **Liveness is pid existence, not health** — a hung or stopped process still lists as running; the contract deliberately makes no judgement about whether a session is making progress.

View File

@@ -0,0 +1,30 @@
# @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` disposerawait 它即等待移除达到持久性。
- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。
- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。
后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。
## 记录词汇
`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid``cwd``startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。
## 模型体验
无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。
#### KV 缓存影响
与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。
## 已知限制与后续工作
- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI两者都不会出现在列表中。
- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-session-registry",
"description": "Live-session registry seam for the DeepSeek Harness: contract and record vocabulary",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,82 @@
/**
* Live-session registry seam (`ctx.sessionRegistry`): a cross-process registry
* of live `dsh` sessions, so a separate short-lived process such as
* `dsh list-sessions` can answer "what am I running right now".
*
* This package owns only the service contract and the record vocabulary; a
* backend (the lock-guarded JSON file in
* `@deepseek-ai/dsh-session-registry-file` today, a database later) owns the
* medium. Whatever the medium, liveness is part of the contract: {@link list}
* returns only records whose process existed at observation time, so a process
* killed without running its disposer leaves no permanent phantom.
* @module @deepseek-ai/dsh-session-registry
*/
import { Context, Service } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { BootId, type SessionRegistryRecord } from './types.ts'
export { BootId } from './types.ts'
export type { SessionRegistryRecord } from './types.ts'
declare module 'cordis' {
interface Context {
sessionRegistry: SessionRegistry
}
}
/** What one process publishes about itself; the service supplies pid and timing. */
export interface SessionRegistration {
/** The session this process runs. */
sessionId: SessionId
/** Absolute workspace directory the session acts on. */
cwd: string
/** Human-readable session title, when one already exists. */
title?: string
}
/**
* Cross-process live-session registry. Reads prune dead records, so every
* returned record's process existed at observation time. Backends serialize
* mutations against concurrent registrars — other processes and overlapping
* calls in this one — so records are never lost to a torn read-modify-write.
*/
export abstract class SessionRegistry extends Service {
/** This process incarnation's id, stamped into every record it publishes. */
protected readonly bootId: BootId
constructor(ctx: Context, bootId: BootId) {
super(ctx, 'sessionRegistry')
this.bootId = bootId
}
/**
* Publish this process's record, replacing any stale record for the same
* session id, and prune records whose process is gone.
* @param registration - the session, surface, and workspace to publish.
* @returns the effect disposer that removes this record again; awaiting it
* waits for the removal to reach durability.
*/
abstract register(registration: SessionRegistration): Promise<() => Promise<void>>
/**
* Replace the recorded title of a session this process registered.
*
* Titles arrive after registration and can be revised, so this is the one
* mutable field. Only a record matching this process and incarnation is
* touched, leaving a same-id record owned by another process alone. An unknown
* session id is a no-op rather than an error: a title can resolve after the
* session's record has already been removed.
* @param sessionId - the session whose recorded title changes.
* @param title - the new title text.
*/
abstract retitle(sessionId: SessionId, title: string): Promise<void>
/**
* List live sessions, pruning records whose process no longer exists.
* @returns one record per live registered session, newest registration last.
*/
abstract list(): Promise<SessionRegistryRecord[]>
}
export default SessionRegistry

View File

@@ -0,0 +1,58 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-registry`.
* @module @deepseek-ai/dsh-session-registry/invariant
*/
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { SessionRegistryRecord } from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry'
/** Cordis companion plugin name. */
export const name = 'session-registry-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Cross-check every published listing against the relations the seam contract
* owns: a session id identifies at most one live record, and each listed record
* carries the identity fields a reader must be able to trust. Only a backend's
* mutation path can break either, so the check wraps the authoritative read
* rather than inspecting any medium.
*
* Liveness itself is deliberately not re-probed here. A backend derives it at
* read time, so a second probe would race the first and report a process that
* exited in between as a violation of a contract the seam never made.
*/
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const service = ctx.sessionRegistry
const listed = service.list.bind(service)
ctx.effect(() => {
service.list = async (): Promise<SessionRegistryRecord[]> => {
const records = await listed()
const seen = new Set<string>()
for (const record of records) {
if (seen.has(record.sessionId)) {
fail(`session ${record.sessionId} appears in more than one live registry record`)
}
seen.add(record.sessionId)
// A record a reader cannot attribute to a process is unusable: `dsh list-sessions`
// renders the pid and derives liveness from it.
if (!Number.isSafeInteger(record.pid) || record.pid <= 0) {
fail(`listed session ${record.sessionId} carries unusable pid ${String(record.pid)}`)
}
}
return records
}
return () => { service.list = listed }
})
}, { inject: ['sessionRegistry'] })
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,55 @@
/**
* Registry record vocabulary: the durable shape one live `dsh` process
* publishes about itself and `dsh list-sessions` reads back.
* @module @deepseek-ai/dsh-session-registry/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { SessionId } from '@deepseek-ai/dsh-session'
/**
* Identifies one process incarnation. Minted per registering process, so a
* record whose `pid` was recycled by the operating system cannot be mistaken
* for the original: the boot id differs even when the pid matches.
*/
export type BootId = Branded<'BootId'>
/**
* Brand a string as a {@link BootId}.
* @param id - the raw boot id string.
* @returns the same string, branded (a compile-time cast — no runtime cost).
*/
export function BootId(id: string): BootId {
return id as BootId
}
/**
* One live session's self-published registration. Every field is immutable for
* the lifetime of the registration: a process publishes once at startup and
* removes the record on exit, never mutating it in place.
*
* Only top-level surfaces a user starts directly register: in-process subagents
* have no process of their own, and out-of-process subagent backends spawn
* `dsh-jsonrpc-agent` rather than this CLI, so neither can reach the registry.
*/
export interface SessionRegistryRecord {
/** The session this process is running. Unique across live records. */
readonly sessionId: SessionId
/** Operating-system process id, used with `bootId` to decide liveness. */
readonly pid: number
/** Absolute workspace directory the session acts on. */
readonly cwd: string
/** Non-negative safe-integer Unix epoch milliseconds when the process registered. */
readonly startedAt: number
/** This process incarnation's id, distinguishing a recycled `pid`. */
readonly bootId: BootId
/**
* Human-readable session title, as the registering process last knew it.
*
* Carried in the record rather than read from the session log: the log's
* location, file format, and compression are per-deployment backend choices,
* so an independent reader cannot portably parse one. Absent until a title
* exists — a fresh session has none.
*/
readonly title?: string
}

View File

@@ -0,0 +1,98 @@
/**
* Tests for the registry's invariant companion: each acceptance path is proven
* to REJECT an invalid case, since a check that cannot fail is not a check.
* The backend is a minimal in-memory stub — the companion owns contract-level
* relations over `list()` results, whatever medium serves them.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { SessionId } from '@deepseek-ai/dsh-session'
import { BootId, SessionRegistry, type SessionRegistration, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
import * as invariant from '@deepseek-ai/dsh-session-registry/src/invariant.ts'
/** Minimal in-memory backend whose listings the test scripts directly. */
class StubRegistry extends SessionRegistry {
records: SessionRegistryRecord[] = []
constructor(ctx: Context) {
super(ctx, BootId('stub-boot'))
}
register(registration: SessionRegistration): Promise<() => Promise<void>> {
this.records.push({
sessionId: registration.sessionId,
pid: process.pid,
cwd: registration.cwd,
startedAt: Date.now(),
bootId: this.bootId,
})
return Promise.resolve(() => Promise.resolve())
}
retitle(): Promise<void> {
return Promise.resolve()
}
list(): Promise<SessionRegistryRecord[]> {
return Promise.resolve([...this.records])
}
}
/** One record with the given identity fields, live by construction. */
function record(sessionId: string, boot: string, pid = process.pid): SessionRegistryRecord {
return { sessionId: SessionId(sessionId), pid, cwd: '/w', startedAt: 1, bootId: BootId(boot) }
}
/** Mount the stub backend, optionally seeding records before the companion wraps `list`. */
async function mount(records?: SessionRegistryRecord[]): Promise<{ ctx: Context; stub: StubRegistry }> {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(StubRegistry)
const stub = ctx.sessionRegistry as StubRegistry
if (records !== undefined) stub.records = records
await ctx.plugin(invariant)
return { ctx, stub }
}
describe('listing invariants', () => {
it('accepts a well-formed listing', async () => {
const { ctx } = await mount()
await ctx.sessionRegistry.register({ sessionId: SessionId('ok'), cwd: '/w' })
await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(1)
await ctx.fiber.dispose()
})
it('rejects a listing where one session id appears twice', async () => {
// Two live records for one session: only a broken mutation path (or an
// out-of-band writer) can produce this, and it would make
// `dsh list-sessions` show one session twice.
const { ctx } = await mount([record('dup', 'boot-a'), record('dup', 'boot-b')])
await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one live registry record/)
await ctx.fiber.dispose()
})
it('rejects a listing whose record carries an unusable pid', async () => {
// A record no reader could attribute to a process: `dsh list-sessions`
// renders the pid and derives liveness from it.
const { ctx } = await mount([record('ghost', 'boot-x', 0)])
await expect(ctx.sessionRegistry.list()).rejects.toThrow(/carries unusable pid/)
await ctx.fiber.dispose()
})
it('stops checking, and keeps working, when the companion unloads', async () => {
// A duplicate-id listing the mounted companion rejects, so the post-disposal
// read proves the wrapper is gone rather than merely bypassed.
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(StubRegistry)
;(ctx.sessionRegistry as StubRegistry).records = [record('dup', 'boot-a'), record('dup', 'boot-b')]
const companion = await ctx.plugin(invariant)
await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one/)
await companion.dispose()
await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(2)
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
},
{
"path": "../../util/brand"
},
{
"path": "../../core/session"
}
]
}