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