Merge origin/master: web permission sandbox, default pi-ai providers
This commit is contained in:
@@ -2,12 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly.
|
||||
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` |
|
||||
| `time-context/` | Durable per-step current time and elapsed-time context | (none) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) |
|
||||
| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) |
|
||||
| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) |
|
||||
|
||||
The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split.
|
||||
|
||||
6
packages/context/tmux-context/README.i18n.yaml
Normal file
6
packages/context/tmux-context/README.i18n.yaml
Normal 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/context/tmux-context/README.md
|
||||
README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa
|
||||
README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d
|
||||
68
packages/context/tmux-context/README.md
Normal file
68
packages/context/tmux-context/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# @deepseek-ai/dsh-tmux-context
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md).
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: tmux-context
|
||||
name: '@deepseek-ai/dsh-tmux-context'
|
||||
config:
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn
|
||||
```
|
||||
|
||||
`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one.
|
||||
|
||||
## How it reads tmux
|
||||
|
||||
The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam:
|
||||
|
||||
```sh
|
||||
[ -n "$TMUX_PANE" ] || exit 1
|
||||
self_tty=$(ps -o tty= -p <pid> | tr -d ' ')
|
||||
pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
|
||||
[ "$pane_tty" = "/dev/$self_tty" ] || exit 1
|
||||
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
|
||||
```
|
||||
|
||||
`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error.
|
||||
|
||||
State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing.
|
||||
|
||||
## Timing semantics
|
||||
|
||||
When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Preparation-time tmux location
|
||||
|
||||
#### What the model sees
|
||||
|
||||
On each turn whose tmux state changed, one source-tagged context message with the three lines below. `<window-layout>` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured.
|
||||
|
||||
##### Changed-turn reading
|
||||
|
||||
```markdown
|
||||
tmux location (turn <turn>):
|
||||
session <session>, window <index> "<name>", pane <index> <pane-id>
|
||||
window active=<0|1>, pane active=<0|1>, layout <window-layout>
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps.
|
||||
- **Own location only** — the plugin never captures the visible text of sibling panes.
|
||||
- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported.
|
||||
- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected.
|
||||
- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable.
|
||||
68
packages/context/tmux-context/README.zh.md
Normal file
68
packages/context/tmux-context/README.zh.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# @deepseek-ai/dsh-tmux-context
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: tmux-context
|
||||
name: '@deepseek-ai/dsh-tmux-context'
|
||||
config:
|
||||
refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn
|
||||
```
|
||||
|
||||
`refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。
|
||||
|
||||
## 如何读取 tmux
|
||||
|
||||
插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令:
|
||||
|
||||
```sh
|
||||
[ -n "$TMUX_PANE" ] || exit 1
|
||||
self_tty=$(ps -o tty= -p <pid> | tr -d ' ')
|
||||
pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
|
||||
[ "$pane_tty" = "/dev/$self_tty" ] || exit 1
|
||||
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
|
||||
```
|
||||
|
||||
仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX` 与 `$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty,而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。
|
||||
|
||||
状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。
|
||||
|
||||
## 时序语义
|
||||
|
||||
当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 准备期 tmux 位置
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`<window-layout>` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。
|
||||
|
||||
##### 变化轮次读数
|
||||
|
||||
```markdown
|
||||
tmux location (turn <turn>):
|
||||
session <session>, window <index> "<name>", pane <index> <pane-id>
|
||||
window active=<0|1>, pane active=<0|1>, layout <window-layout>
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。
|
||||
- **仅自身位置**——插件从不采集相邻 pane 的可见文本。
|
||||
- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。
|
||||
- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。
|
||||
- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE` 的 `#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX`/`$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX;在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。
|
||||
49
packages/context/tmux-context/package.json
Normal file
49
packages/context/tmux-context/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tmux-context",
|
||||
"description": "Opt-in durable per-step context with this agent's tmux pane and window location",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
227
packages/context/tmux-context/src/index.ts
Normal file
227
packages/context/tmux-context/src/index.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Opt-in request-preparation tmux-location context. Eligible step attempts
|
||||
* append durable, source-attributed context naming the tmux session, window,
|
||||
* and pane this agent process runs in, plus the window's pane-tree layout.
|
||||
*
|
||||
* The plugin pulls state once per turn, on the first step (`step === 1`), by
|
||||
* running one `tmux display-message` through the `ctx.bash` executor seam. It
|
||||
* confirms this process genuinely runs inside the pane `$TMUX_PANE` names by
|
||||
* matching the pane's `#{pane_tty}` against this process's controlling terminal,
|
||||
* so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor
|
||||
* (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects
|
||||
* only when the rendered tmux state changes since the last injection (a moved,
|
||||
* renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor
|
||||
* between injections. Absent tmux environment, an inherited-only environment,
|
||||
* absent `ctx.bash`, or a failed query is a no-op, never an error.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tmux-context
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tmux-context'
|
||||
|
||||
/** The agent registry that owns the `agent/step` lifecycle seam. */
|
||||
export const inject = ['agents']
|
||||
|
||||
/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
|
||||
export interface Config {
|
||||
/** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */
|
||||
refreshIntervalMs?: number
|
||||
}
|
||||
|
||||
/** Schemastery validation for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
refreshIntervalMs: z.number(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Tab-separated tmux format fields, in query order. Layout (`window_layout`)
|
||||
* is the pane-tree description; pane/window pixel sizes are intentionally
|
||||
* excluded (own location and layout only, per the package scope).
|
||||
*/
|
||||
const TMUX_FIELDS = [
|
||||
'#{session_name}',
|
||||
'#{window_index}',
|
||||
'#{window_name}',
|
||||
'#{pane_index}',
|
||||
'#{pane_id}',
|
||||
'#{window_active}',
|
||||
'#{pane_active}',
|
||||
'#{window_layout}',
|
||||
] as const
|
||||
|
||||
/** Structured tmux location parsed from one `display-message` reading. */
|
||||
interface TmuxLocation {
|
||||
sessionName: string
|
||||
windowIndex: string
|
||||
windowName: string
|
||||
paneIndex: string
|
||||
paneId: string
|
||||
windowActive: string
|
||||
paneActive: string
|
||||
windowLayout: string
|
||||
}
|
||||
|
||||
/** Prefix marking the volatile turn/step preamble line of a rendered reading. */
|
||||
const READING_PREFIX = 'tmux location (turn '
|
||||
|
||||
/**
|
||||
* Field separator between tmux format fields. tmux does not interpret C escapes
|
||||
* in a format, so the literal two-character sequence `\t` is emitted verbatim
|
||||
* and split back out here; this avoids embedding raw whitespace in the command.
|
||||
*/
|
||||
const FIELD_SEP = '\\t'
|
||||
|
||||
/**
|
||||
* Read this process's tmux location through the bash seam, or `undefined` when
|
||||
* this process is not genuinely running inside a tmux pane or the query fails.
|
||||
*
|
||||
* `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell
|
||||
* (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and
|
||||
* `$TMUX_PANE` from that ancestor, so the variables are present even though this
|
||||
* process does not live in that pane. The command therefore also compares the
|
||||
* pane's `#{pane_tty}` against this process's own controlling terminal
|
||||
* (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty,
|
||||
* an inherited environment names some other pane's tty. Fields are emitted only
|
||||
* on a match, so an inherited environment reads as "not in tmux" and injects
|
||||
* nothing.
|
||||
*
|
||||
* @param bash - the executor seam used to run the read-only tmux/ps commands.
|
||||
* @param processId - this agent process's pid, whose controlling tty must match the pane.
|
||||
* @param signal - abort signal forwarded to the executor.
|
||||
* @returns the parsed location, or `undefined` when not in a real pane or on any failure.
|
||||
*/
|
||||
async function queryTmuxLocation(
|
||||
bash: BashExecutor,
|
||||
processId: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<TmuxLocation | undefined> {
|
||||
const format = TMUX_FIELDS.join(FIELD_SEP)
|
||||
const command = [
|
||||
'[ -n "$TMUX_PANE" ] || exit 1',
|
||||
`self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`,
|
||||
'[ -n "$self_tty" ] || exit 1',
|
||||
'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1',
|
||||
'[ "$pane_tty" = "/dev/$self_tty" ] || exit 1',
|
||||
`exec tmux display-message -t "$TMUX_PANE" -p '${format}'`,
|
||||
].join('\n')
|
||||
const spec = bash.resolve({ command, signal })
|
||||
const result = await bash.run(spec)
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const line = result.stdout.text.split('\n', 1)[0] as string
|
||||
const parts = line.split(FIELD_SEP)
|
||||
if (parts.length !== TMUX_FIELDS.length) return undefined
|
||||
const [
|
||||
sessionName,
|
||||
windowIndex,
|
||||
windowName,
|
||||
paneIndex,
|
||||
paneId,
|
||||
windowActive,
|
||||
paneActive,
|
||||
windowLayout,
|
||||
] = parts as [string, string, string, string, string, string, string, string]
|
||||
if (paneId.length === 0) return undefined
|
||||
return {
|
||||
sessionName,
|
||||
windowIndex,
|
||||
windowName,
|
||||
paneIndex,
|
||||
paneId,
|
||||
windowActive,
|
||||
paneActive,
|
||||
windowLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stable tmux state block: the part of a reading compared for
|
||||
* change suppression. It excludes the turn preamble so re-injection is driven
|
||||
* only by tmux state, not by loop position.
|
||||
*/
|
||||
function renderState(location: TmuxLocation): string {
|
||||
return `session ${location.sessionName}, `
|
||||
+ `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, `
|
||||
+ `pane ${location.paneIndex} ${location.paneId}\n`
|
||||
+ `window active=${location.windowActive}, pane active=${location.paneActive}, `
|
||||
+ `layout ${location.windowLayout}`
|
||||
}
|
||||
|
||||
/** Render the full durable reading, including the volatile turn preamble. */
|
||||
function renderReading(location: TmuxLocation, turn: number): string {
|
||||
return `${READING_PREFIX}${turn}):\n${renderState(location)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The stable state block of this plugin's latest durable injection, or
|
||||
* `undefined` when the session has none. Scans raw durable events so the
|
||||
* schedule survives compaction and resumed processes without process-local
|
||||
* cache state.
|
||||
*/
|
||||
function latestInjectedState(agent: Agent): { state: string; time: number } | undefined {
|
||||
for (const event of [...agent.session.events].reverse()) {
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === name) {
|
||||
const [block] = event.data.content
|
||||
if (block?.type !== 'text') return undefined
|
||||
const newline = block.text.indexOf('\n')
|
||||
const state = newline === -1 ? '' : block.text.slice(newline + 1)
|
||||
return { state, time: event.time }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */
|
||||
function validateRefreshInterval(refreshIntervalMs: number | undefined): void {
|
||||
if (refreshIntervalMs !== undefined && (
|
||||
!Number.isSafeInteger(refreshIntervalMs)
|
||||
|| refreshIntervalMs < 0
|
||||
)) {
|
||||
throw new TypeError(
|
||||
`tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a prepended `agent/step` listener for the lifetime of `ctx`.
|
||||
* @param ctx - plugin context; the listener is disposed with it.
|
||||
* @param config - durable refresh scheduling configuration.
|
||||
* @throws when the refresh interval is invalid.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const refreshIntervalMs = config.refreshIntervalMs
|
||||
validateRefreshInterval(refreshIntervalMs)
|
||||
|
||||
ctx.on('agent/step', async (
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> => {
|
||||
if (signal.aborted || step !== 1) return
|
||||
const bash = ctx.get('bash')
|
||||
if (bash === undefined) return
|
||||
const previous = latestInjectedState(agent)
|
||||
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) {
|
||||
const now = Date.now()
|
||||
if (now >= previous.time && now - previous.time < refreshIntervalMs) return
|
||||
}
|
||||
const location = await queryTmuxLocation(bash, process.pid, signal)
|
||||
if (location === undefined) return
|
||||
const state = renderState(location)
|
||||
if (previous !== undefined && previous.state === state) return
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: renderReading(location, turn) }],
|
||||
source: { kind: 'plugin', plugin: name },
|
||||
}))
|
||||
}, { prepend: true })
|
||||
}
|
||||
30
packages/context/tmux-context/src/invariant.ts
Normal file
30
packages/context/tmux-context/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`.
|
||||
* @module @deepseek-ai/dsh-tmux-context/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tmux-context-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session
|
||||
* holds no cross-event relation to check; scheduling and format are owned by pipeline tests.
|
||||
*/
|
||||
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 */
|
||||
77
packages/context/tmux-context/tests/tmux-context.e2e.ts
Normal file
77
packages/context/tmux-context/tests/tmux-context.e2e.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
// Keep the Loader config under examples so both modes exercise the same deployable
|
||||
// topology: local fixture source plus bare plugins owned by the examples workspace.
|
||||
const driver = fileURLToPath(new URL(
|
||||
'../../../../examples/headless-agent/tests/fixtures/tmux-context-driver.ts',
|
||||
import.meta.url,
|
||||
))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/headless-agent/tests/fixtures/tmux-context.cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
describe('tmux-context through a real headless cordis.yml', () => {
|
||||
it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => {
|
||||
let events: SessionEvent[] = []
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'tmux-context headless smoke',
|
||||
tempDirPrefix: 'tmux-context-e2e-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
inspect: async (cwd) => {
|
||||
const logs = await jsonlFiles(join(cwd, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
|
||||
|
||||
const contexts = events.filter(
|
||||
(event): event is SessionEvent<'user/message'> =>
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'tmux-context')
|
||||
// Two identical-state turns: the location injects once and is suppressed after.
|
||||
expect(contexts).toHaveLength(1)
|
||||
|
||||
const [reading] = contexts
|
||||
if (reading === undefined) throw new Error('missing tmux-context reading')
|
||||
const starts = events.filter(event => event.type === 'step/start')
|
||||
expect(reading.seq).toBeLessThan(starts[0]!.seq)
|
||||
expect(reading.surfaceOp).toBe('append')
|
||||
|
||||
const text = reading.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('\n')
|
||||
expect(text).toBe(
|
||||
'tmux location (turn 1):\n'
|
||||
+ 'session work, window 0 "editor", pane 1 %3\n'
|
||||
+ 'window active=1, pane active=1, layout a1b2,80x24,0,0,4',
|
||||
)
|
||||
|
||||
const headers = events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).not.toContain('tmux location (turn')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
367
packages/context/tmux-context/tests/tmux-context.spec.ts
Normal file
367
packages/context/tmux-context/tests/tmux-context.spec.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
|
||||
import type { Config } from '@deepseek-ai/dsh-tmux-context'
|
||||
|
||||
const SIGNAL = new AbortController().signal
|
||||
|
||||
/** One `#{...}`-joined tmux reading line for the eight queried fields. */
|
||||
function tmuxLine(fields: {
|
||||
sessionName?: string
|
||||
windowIndex?: string
|
||||
windowName?: string
|
||||
paneIndex?: string
|
||||
paneId?: string
|
||||
windowActive?: string
|
||||
paneActive?: string
|
||||
windowLayout?: string
|
||||
} = {}): string {
|
||||
return [
|
||||
fields.sessionName ?? '0',
|
||||
fields.windowIndex ?? '1',
|
||||
fields.windowName ?? 'node',
|
||||
fields.paneIndex ?? '2',
|
||||
fields.paneId ?? '%90',
|
||||
fields.windowActive ?? '1',
|
||||
fields.paneActive ?? '0',
|
||||
fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
|
||||
].join('\\t')
|
||||
}
|
||||
|
||||
function runResult(stdout: string, overrides: Partial<BashRunResult> = {}): BashRunResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 60_000,
|
||||
stdout: { text: stdout, truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A scriptable fake `ctx.bash` recording the command it was asked to run. */
|
||||
class FakeBash extends BashExecutor {
|
||||
commands: string[] = []
|
||||
result: BashRunResult = runResult(`${tmuxLine()}\n`)
|
||||
runError?: Error
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/work',
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
signal: request.signal,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.commands.push(spec.command)
|
||||
if (this.runError) throw this.runError
|
||||
return this.result
|
||||
}
|
||||
override start(): BashProcess {
|
||||
throw new Error('tmux-context must never start a background task')
|
||||
}
|
||||
}
|
||||
|
||||
async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }>
|
||||
async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }>
|
||||
async function mount(
|
||||
config: Config = {},
|
||||
withBash = false,
|
||||
): Promise<{ ctx: Context; bash: FakeBash | undefined }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
let bash: FakeBash | undefined
|
||||
if (withBash) {
|
||||
await ctx.plugin(FakeBash)
|
||||
bash = ctx.bash as FakeBash
|
||||
}
|
||||
await ctx.plugin(tmuxContext, config)
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
return {
|
||||
id: SessionId(id),
|
||||
options: {},
|
||||
session,
|
||||
status: 'running',
|
||||
acceptsNextStep: true,
|
||||
ctx: new Context(),
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
send: () => {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function openMessageTurn(session: Session, turn: number): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function contextTexts(session: Session): string[] {
|
||||
const texts: string[] = []
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'tmux-context') {
|
||||
texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '')
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
async function fire(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
signal: AbortSignal = SIGNAL,
|
||||
): Promise<void> {
|
||||
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('tmux-context injection', () => {
|
||||
it('injects the tmux location on the first step of a turn', async () => {
|
||||
const { ctx } = await mount({}, true)
|
||||
const session = new Session(SessionId('first'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toEqual([
|
||||
'tmux location (turn 1):\n'
|
||||
+ 'session 0, window 1 "node", pane 2 %90\n'
|
||||
+ 'window active=1, pane active=0, '
|
||||
+ 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}',
|
||||
])
|
||||
const event = session.events.at(-1)
|
||||
if (event?.type !== 'user/message') throw new Error('missing tmux context')
|
||||
expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' })
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('queries the pane this process runs in and matches its controlling tty', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('command'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(bash.commands).toHaveLength(1)
|
||||
const command = bash.commands[0]!
|
||||
expect(command).toContain('[ -n "$TMUX_PANE" ]')
|
||||
expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p')
|
||||
// Guards against an inherited $TMUX_PANE: the pane's tty must equal this
|
||||
// process's controlling tty (resolved for this exact pid).
|
||||
expect(command).toContain(`ps -o tty= -p ${process.pid}`)
|
||||
// The exact fragment matters: unquoted, `#` starts a shell comment and the
|
||||
// substitution silently breaks while a substring check still passes.
|
||||
expect(command).toContain('pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1')
|
||||
expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]')
|
||||
})
|
||||
|
||||
it('does not run on later steps of a turn', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('later-step'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 2)
|
||||
|
||||
expect(bash.commands).toHaveLength(0)
|
||||
expect(contextTexts(session)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-injects a new turn only when tmux state changed', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('change'))
|
||||
const agent = sessionAgent(session)
|
||||
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, agent, 1, 1)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
// Same state on turn 2: suppressed.
|
||||
openMessageTurn(session, 2)
|
||||
await fire(ctx, agent, 2, 1)
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
|
||||
// Moved pane on turn 3: re-injected.
|
||||
bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`)
|
||||
openMessageTurn(session, 3)
|
||||
await fire(ctx, agent, 3, 1)
|
||||
|
||||
const texts = contextTexts(session)
|
||||
expect(texts).toHaveLength(2)
|
||||
expect(texts[1]).toContain('tmux location (turn 3):')
|
||||
expect(texts[1]).toContain('window 1 "shell", pane 2 %12')
|
||||
})
|
||||
|
||||
it('honors a positive refresh interval between injections', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true)
|
||||
const session = new Session(SessionId('interval'))
|
||||
const agent = sessionAgent(session)
|
||||
|
||||
openMessageTurn(session, 1)
|
||||
await fire(ctx, agent, 1, 1)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
// Changed state but inside the interval: suppressed, and never queried.
|
||||
bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`)
|
||||
vi.setSystemTime(5_000)
|
||||
openMessageTurn(session, 2)
|
||||
await fire(ctx, agent, 2, 1)
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
expect(bash.commands).toHaveLength(1)
|
||||
|
||||
// Past the interval: queried and re-injected.
|
||||
vi.setSystemTime(12_000)
|
||||
openMessageTurn(session, 3)
|
||||
await fire(ctx, agent, 3, 1)
|
||||
expect(contextTexts(session)).toHaveLength(2)
|
||||
expect(bash.commands).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tmux-context prior-reading resilience', () => {
|
||||
it('treats a prior non-text plugin reading as absent and injects afresh', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('prior-non-text'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'reasoning', text: 'not a location' }],
|
||||
source: { kind: 'plugin', plugin: 'tmux-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await fire(ctx, agent, 1, 1)
|
||||
|
||||
expect(bash.commands).toHaveLength(1)
|
||||
expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):')
|
||||
})
|
||||
|
||||
it('treats a prior single-line plugin reading (no newline) as empty state', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
const session = new Session(SessionId('prior-single-line'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'single line, no newline' }],
|
||||
source: { kind: 'plugin', plugin: 'tmux-context' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await fire(ctx, agent, 1, 1)
|
||||
|
||||
// Empty prior state never equals the multi-line reading, so it re-injects.
|
||||
expect(bash.commands).toHaveLength(1)
|
||||
expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tmux-context no-op paths', () => {
|
||||
it('is a no-op when no bash executor is mounted', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('no-bash'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
bash.result = runResult('', { exitCode: 1 })
|
||||
const session = new Session(SessionId('outside-tmux'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is a no-op when the reading has the wrong field count', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
bash.result = runResult('0\\t1\\tnode\n')
|
||||
const session = new Session(SessionId('malformed'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is a no-op when the pane id is empty', async () => {
|
||||
const { ctx, bash } = await mount({}, true)
|
||||
bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`)
|
||||
const session = new Session(SessionId('empty-pane'))
|
||||
openMessageTurn(session, 1)
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
expect(contextTexts(session)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => {
|
||||
const { ctx } = await mount({}, true)
|
||||
const session = new Session(SessionId('ordering'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session, 1)
|
||||
let ordinarySawContext = false
|
||||
ctx.on('agent/step', (subject) => {
|
||||
ordinarySawContext = subject.session.events.some(
|
||||
event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'tmux-context',
|
||||
)
|
||||
})
|
||||
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
await fire(ctx, agent, 1, 1, abort.signal)
|
||||
expect(contextTexts(session)).toHaveLength(0)
|
||||
|
||||
await fire(ctx, agent, 1, 1)
|
||||
expect(ordinarySawContext).toBe(true)
|
||||
expect(contextTexts(session)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tmux-context configuration', () => {
|
||||
it('rejects a negative refresh interval at plugin load', async () => {
|
||||
await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow(
|
||||
/refreshIntervalMs must be a non-negative safe integer/,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a non-integer refresh interval at plugin load', async () => {
|
||||
await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow(
|
||||
/refreshIntervalMs must be a non-negative safe integer/,
|
||||
)
|
||||
})
|
||||
})
|
||||
40
packages/context/tmux-context/tsconfig.json
Normal file
40
packages/context/tmux-context/tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../support/loader-smoke"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -688,6 +688,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessionRegistry',
|
||||
summary: 'Cross-process live-session registry.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'abstract register(registration: SessionRegistration): Promise<() => Promise<void>>',
|
||||
jsDoc: '/**\n * Publish this process\'s record, replacing any stale record for the same\n * session id, and prune records whose process is gone.\n * @param registration - the session, surface, and workspace to publish.\n * @returns the effect disposer that removes this record again; awaiting it\n * waits for the removal to reach durability.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract retitle(sessionId: SessionId, title: string): Promise<void>',
|
||||
jsDoc: '/**\n * Replace the recorded title of a session this process registered.\n *\n * Titles arrive after registration and can be revised, so this is the one\n * mutable field. Only a record matching this process and incarnation is\n * touched, leaving a same-id record owned by another process alone. An unknown\n * session id is a no-op rather than an error: a title can resolve after the\n * session\'s record has already been removed.\n * @param sessionId - the session whose recorded title changes.\n * @param title - the new title text.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract list(): Promise<SessionRegistryRecord[]>',
|
||||
jsDoc: '/**\n * List live sessions, pruning records whose process no longer exists.\n * @returns one record per live registered session, newest registration last.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sessions',
|
||||
summary: 'In-memory session store (`ctx.sessions`).',
|
||||
@@ -1543,6 +1561,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'BashSandboxInfo',
|
||||
declaration: 'export interface BashSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'BootId',
|
||||
declaration: 'export type BootId = Branded<\'BootId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'Branded',
|
||||
declaration: 'export type Branded<B extends string> = string & {\n readonly [BRAND]: B;\n};',
|
||||
@@ -2247,6 +2269,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionReferenceInput',
|
||||
declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionRegistration',
|
||||
declaration: 'export interface SessionRegistration {\n sessionId: SessionId;\n cwd: string;\n title?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionRegistryRecord',
|
||||
declaration: 'export interface SessionRegistryRecord {\n readonly sessionId: SessionId;\n readonly pid: number;\n readonly cwd: string;\n readonly startedAt: number;\n readonly bootId: BootId;\n readonly title?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionResultFilter',
|
||||
declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};',
|
||||
|
||||
@@ -39,15 +39,15 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
|
||||
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
|
||||
| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
|
||||
| `workspaceContext` | required | Workspace-instruction config, or `false` |
|
||||
| `persistenceRoot` | `./.sessions` | JSONL persistence root and parent of the derived `session-query.db` index |
|
||||
| `persistenceRoot` | `./.sessions` (launcher boot slot overrides) | JSONL persistence root and parent of the derived `session-query.db` index |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` |
|
||||
| `welcome` | `ready.` | TUI subtitle |
|
||||
| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff |
|
||||
| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height |
|
||||
| `resumeSessionId` | — | Exact persisted session to resume |
|
||||
|
||||
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff.
|
||||
Session identity is launcher-owned rather than configurable: a launcher provides `MAIN_SESSION_ID_KEY` on the boot context, and this app binds both the TUI and the configured agent to that id, loading persisted history only when the launcher also set `resume`. With no such slot the app mints a `main-session-<uuid>` and creates it fresh. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; a launcher may additionally provide `tuiResumeHost` for in-place process handoff and `TUI_GOODBYE_MESSAGE_KEY` for the line printed on exit.
|
||||
|
||||
`persistenceRoot` defaults to project-local `./.sessions`: an app bundle must not assume the user's shared session store. A launcher that wants one store across every cwd states that policy through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before any Loader entry mounts) — the dsh CLI provides its Harness-home root there, so its `/resume` lists sessions from every workspace. Precedence is explicit config, then the launcher slot, then the project-local default.
|
||||
|
||||
## Front door
|
||||
|
||||
|
||||
@@ -30,20 +30,20 @@
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-command-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
|
||||
"@deepseek-ai/dsh-command-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tui": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tui": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
@@ -53,21 +53,21 @@
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -30,6 +30,11 @@ import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiTui from '@deepseek-ai/dsh-tui'
|
||||
|
||||
export const name = 'tui-demo'
|
||||
|
||||
// The bundle's own fallback stays project-local: a plugin must never assume
|
||||
// the user's shared session store. The dsh launcher's SESSIONS_ROOT_KEY slot
|
||||
// (opaque here — the CLI resolves it to DSH_HOME/sessions) carries any
|
||||
// shared-store policy, and explicit config wins over both.
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
|
||||
// Each front door keeps a complete Loader contract so its deployment config is
|
||||
@@ -53,7 +58,12 @@ export interface Config {
|
||||
dshHome?: string
|
||||
/** Fallback session-title limits forwarded through agent-spine-demo. */
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
|
||||
/**
|
||||
* Directory for JSONL sessions and the derived query index. Precedence:
|
||||
* this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot
|
||||
* slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local
|
||||
* `./.sessions` fallback — the bundle itself never assumes a global store.
|
||||
*/
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
@@ -61,13 +71,6 @@ export interface Config {
|
||||
sessionReferences?: SessionReferenceConfig
|
||||
/** TUI transcript's optional first line; absent renders nothing on start. */
|
||||
welcome?: string
|
||||
/**
|
||||
* Shell command template the TUI prints on exit and lists under `/resume`,
|
||||
* with `{session}` replaced by the live session id (forwarded to the front
|
||||
* door). Set it to a command that resumes the session, e.g.
|
||||
* `dsh --resume {session}`.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
/** Full-screen TUI presentation settings. */
|
||||
ui?: uiTui.TuiConfig
|
||||
/** Skill registry, local-provider, and model-facing consumer config. */
|
||||
@@ -78,8 +81,6 @@ export interface Config {
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
|
||||
goals?: agentCore.GoalConfig | false
|
||||
/** Persisted session id to resume instead of creating a fresh session. */
|
||||
resumeSessionId?: string
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
}
|
||||
@@ -94,33 +95,38 @@ export const Config: z<Config> = z.object({
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
sessionTitle: agentCore.SessionTitleConfigSchema,
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
// No schema default: schemastery would materialize it before composeTuiApp
|
||||
// runs, shadowing the launcher's SESSIONS_ROOT_KEY slot for a Loader mount.
|
||||
persistenceRoot: z.string(),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
sessionReferences: SessionReferenceService.Config,
|
||||
welcome: z.string(),
|
||||
resumeCommand: z.string(),
|
||||
ui: uiTui.TuiConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
|
||||
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
|
||||
resumeSessionId: z.string(),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Compose the spine, TUI, JSONL persistence, and user-question tool around one
|
||||
* exact fresh or resumed session identity. The TUI subscribes to startup
|
||||
* failures before the spine creates the agent.
|
||||
* exact fresh or resumed session identity, taken from the launcher's
|
||||
* {@link uiTui.MAIN_SESSION_ID_KEY} slot. The TUI subscribes to startup failures
|
||||
* before the spine creates the agent.
|
||||
* @param ctx - context receiving the app's child plugins.
|
||||
* @param config - validated app configuration.
|
||||
*/
|
||||
export function composeTuiApp(ctx: Context, config: Config): void {
|
||||
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
|
||||
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
|
||||
// The launcher, not the deployment config, owns `main`'s session identity: it
|
||||
// reaches a Loader-mounted bundle only through this context slot. A launcher
|
||||
// that supplies an id knows whether that session already exists, so it also
|
||||
// states whether to load persisted history. No launcher means mint one here.
|
||||
const identity = ctx.get(uiTui.MAIN_SESSION_ID_KEY)
|
||||
const sessionId = SessionId(identity?.id ?? `main-session-${randomUUID()}`)
|
||||
const goals = config.goals ?? {}
|
||||
const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT
|
||||
const persistenceRoot = config.persistenceRoot ?? ctx.get(uiTui.SESSIONS_ROOT_KEY) ?? DEFAULT_PERSISTENCE_ROOT
|
||||
ctx.plugin(CommandService)
|
||||
if (goals !== false) ctx.plugin(commandGoal)
|
||||
ctx.plugin(SessionPersistenceJsonl, {
|
||||
@@ -135,7 +141,6 @@ export function composeTuiApp(ctx: Context, config: Config): void {
|
||||
ctx.plugin(uiTui, {
|
||||
...config.ui,
|
||||
...config.welcome === undefined ? {} : { welcome: config.welcome },
|
||||
...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand },
|
||||
sessionId,
|
||||
})
|
||||
ctx.plugin(agentCore, {
|
||||
@@ -146,7 +151,9 @@ export function composeTuiApp(ctx: Context, config: Config): void {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
|
||||
// `resumeSessionId` requires existing persisted history and rejects a
|
||||
// missing log, so only a launcher that asked to resume takes that path.
|
||||
...identity?.resume === true ? { resumeSessionId: sessionId } : { sessionId },
|
||||
}],
|
||||
})
|
||||
ctx.plugin(toolAskUser)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { MAIN_SESSION_ID_KEY, SESSIONS_ROOT_KEY, type MainSessionIdentity } from '@deepseek-ai/dsh-tui'
|
||||
import * as tuiAgent from '../src/index.ts'
|
||||
|
||||
interface PluginCall {
|
||||
@@ -10,12 +12,21 @@ interface PluginCall {
|
||||
readonly config: unknown
|
||||
}
|
||||
|
||||
function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } {
|
||||
/**
|
||||
* Record the composed plugin tree. `identity` stands in for the launcher-owned
|
||||
* {@link MAIN_SESSION_ID_KEY} slot; omitting it means no launcher chose a session.
|
||||
*/
|
||||
function recordingContext(
|
||||
identity?: MainSessionIdentity,
|
||||
sessionsRoot?: string,
|
||||
): { readonly ctx: Context; readonly calls: PluginCall[] } {
|
||||
const calls: PluginCall[] = []
|
||||
const ctx = {
|
||||
plugin(plugin: { name?: string }, config?: unknown) {
|
||||
calls.push({ name: plugin.name ?? '', config })
|
||||
},
|
||||
get: (key: string) => key === MAIN_SESSION_ID_KEY ? identity
|
||||
: key === SESSIONS_ROOT_KEY ? sessionsRoot : undefined,
|
||||
} as unknown as Context
|
||||
return { ctx, calls }
|
||||
}
|
||||
@@ -39,7 +50,6 @@ describe('dsh-tui-demo app', () => {
|
||||
maxReferenceBytes: 1234,
|
||||
},
|
||||
welcome: 'TUI ready',
|
||||
resumeCommand: 'dsh --resume {session}',
|
||||
ui: { theme: { color: false }, maxToolOutputLines: 3 },
|
||||
skills: { tool: { catalogDescriptionMaxLength: 8 } },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
@@ -71,7 +81,6 @@ describe('dsh-tui-demo app', () => {
|
||||
const tuiConfig = calls[8]?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({
|
||||
welcome: 'TUI ready',
|
||||
resumeCommand: 'dsh --resume {session}',
|
||||
theme: { color: false },
|
||||
maxToolOutputLines: 3,
|
||||
})
|
||||
@@ -100,16 +109,46 @@ describe('dsh-tui-demo app', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes the configured session and applies runtime defaults', () => {
|
||||
const { ctx, calls } = recordingContext()
|
||||
it('uses the launcher sessions-root slot through schema-normalized config', () => {
|
||||
// The Loader normalizes config through the schemastery Config BEFORE apply
|
||||
// runs. A schema .default() on persistenceRoot would materialize here and
|
||||
// permanently shadow the launcher slot — the regression this test pins.
|
||||
const normalized = tuiAgent.Config({
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
workspaceContext: false,
|
||||
} as never)
|
||||
expect(normalized.persistenceRoot).toBeUndefined()
|
||||
|
||||
const { ctx, calls } = recordingContext(undefined, '/launcher/sessions')
|
||||
tuiAgent.composeTuiApp(ctx, normalized)
|
||||
expect(calls[2]?.config).toMatchObject({ root: '/launcher/sessions' })
|
||||
expect(calls[4]?.config).toEqual({ path: join('/launcher/sessions', 'session-query.db') })
|
||||
})
|
||||
|
||||
it('lets an explicit persistenceRoot win over the launcher slot', () => {
|
||||
const { ctx, calls } = recordingContext(undefined, '/launcher/sessions')
|
||||
tuiAgent.composeTuiApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
persistenceRoot: '/explicit/root',
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(calls[2]?.config).toEqual({ root: '/explicit/root' })
|
||||
})
|
||||
|
||||
it('loads persisted history for a launcher-selected resume identity', () => {
|
||||
// The bundle default stays project-local: shared-store policy is the
|
||||
// launcher's, which patches `persistenceRoot` itself (the dsh CLI does).
|
||||
const { ctx, calls } = recordingContext({ id: SessionId('persisted-session'), resume: true })
|
||||
tuiAgent.composeTuiApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
resumeSessionId: 'persisted-session',
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
expect(calls[2]?.config).toEqual({ root: './.sessions' })
|
||||
expect(calls[4]?.config).toEqual({ path: join('./.sessions', 'session-query.db') })
|
||||
expect(calls[5]?.config).toEqual({})
|
||||
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
|
||||
expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' })
|
||||
@@ -119,12 +158,24 @@ describe('dsh-tui-demo app', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes an empty resume id and routes apply through the same composition', () => {
|
||||
it('creates a launcher-minted identity fresh rather than loading history', () => {
|
||||
const { ctx, calls } = recordingContext({ id: SessionId('minted-session'), resume: false })
|
||||
tuiAgent.composeTuiApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
expect(calls[8]?.config).toEqual({ sessionId: 'minted-session' })
|
||||
expect((calls[9]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
|
||||
.toMatchObject({ id: 'main', sessionId: 'minted-session' })
|
||||
})
|
||||
|
||||
it('mints a fresh session with no launcher slot and routes apply through the same composition', () => {
|
||||
const { ctx, calls } = recordingContext()
|
||||
tuiAgent.apply(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
resumeSessionId: '',
|
||||
goals: false,
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability.
|
||||
Behavioral guard plugins that watch the agent loop and correct it — some by nudging the model back on course, some by refusing an operation outright. All are **product** packages: there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/pre-execute`, `tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) |
|
||||
| `source-guard/` | Denies file edits inside a dsh staging worktree until the required skill is loaded | (listens on `ctx.tools`' waterfalls) |
|
||||
|
||||
Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log.
|
||||
An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches.
|
||||
|
||||
6
packages/guard/source-guard/README.i18n.yaml
Normal file
6
packages/guard/source-guard/README.i18n.yaml
Normal 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/guard/source-guard/README.md
|
||||
README.md: f083ef0af53c4d4f7c4a0875837ac3a3c851c54c
|
||||
README.zh.md: 7c9efa7b8d3aec1a95b62416ef624a887fd48aa0
|
||||
88
packages/guard/source-guard/README.md
Normal file
88
packages/guard/source-guard/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @deepseek-ai/dsh-source-guard
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
An enforcement gate, not a model-facing tool: it never appears in the tool list and adds exactly one behavior — it denies a `write` or `edit` whose target sits inside the dsh checkout the running harness was launched from, on that checkout's own branch, until the calling session's durable log shows a successful load of the `dsh-customize` skill. That skill requires personal changes to be implemented in a task worktree and integrated under the staging lock; this plugin turns its central rule ("do not edit the personal staging checkout directly") from prompt guidance into a boundary the model cannot cross by forgetting.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
- id: source-guard
|
||||
name: '@deepseek-ai/dsh-source-guard'
|
||||
config:
|
||||
requiredSkill: dsh-customize # default; the skill whose load lifts the denial
|
||||
tools: [write, edit] # default; the gated tool names
|
||||
protectedCheckout: /path/to/checkout # defaults to this module's own location
|
||||
```
|
||||
|
||||
Every field fails loud at plugin load: an empty `tools` list, a blank `requiredSkill`, or a relative `protectedCheckout` throws, never a silent fall-back.
|
||||
|
||||
`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout.
|
||||
|
||||
The shipped TUI composition (`examples/tui-agent/cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change.
|
||||
|
||||
## Which paths are protected
|
||||
|
||||
Protection is decided by git identity read from files — `.git`, its `gitdir:` pointer, and `HEAD` — never by path prefix and never by running `git`. Prefix matching would be wrong here: the task worktrees the skill prescribes live *inside* the staging tree, at `<staging>/.worktrees/...`, and are exactly where edits belong.
|
||||
|
||||
Resolution walks OUTWARD from the target and stops at the first enclosing worktree, so it reports the INNERMOST one. Denial needs that worktree to match the launcher's on BOTH identities: the same shared git directory and the same branch. A task worktree nested under the protected tree answers with its own task branch and passes; the launcher's own tree answers with the launcher's branch and is denied. Repository identity is compared on symlink-resolved paths, so two routes to one repository — a session cwd under `/var/...` and a configured path under `/private/var/...` on macOS — match rather than falling open.
|
||||
|
||||
Requiring the exact branch, not a name pattern, keeps the gate on the live deployment only. A stale sibling checkout left by an earlier install shares the repository but runs no launcher, so the workflow rule does not apply to it and it stays editable.
|
||||
|
||||
A `gitdir:` pointer may be absolute (what `git worktree add` writes) or relative, which git resolves against the worktree directory holding it; both resolve here. A relative `file_path` resolves against the calling session's workspace, exactly as the filesystem tools resolve it, so it is not an unguarded route to a protected file.
|
||||
|
||||
The gate is deliberately narrow:
|
||||
|
||||
- **`read` is never gated.** Inspecting the staging checkout violates nothing, so only mutating tools are candidates.
|
||||
- **`bash` is not gated.** Reliably classifying mutating shell commands is out of scope, so a determined model can still change staging through a shell.
|
||||
- **Calls without an agent are allowed.** A direct `ctx.tools.execute()` caller has no session to replay and no model to correct.
|
||||
- **Unresolvable git state fails OPEN.** A path outside any worktree, a detached HEAD on either side, a different repository or branch, a malformed `.git` pointer, or unreadable metadata all leave the call to the rest of the chain. A gate that blocked every write whenever git identity was unavailable would cause more harm than the violation it prevents.
|
||||
- **An unresolvable target is not judged.** An empty `file_path`, a non-string one, or a relative one in a session that names no workspace leaves the call to the tool's own validation.
|
||||
|
||||
Worktree identity is cached per target directory for the plugin's lifetime, so repeated writes in one directory read git metadata once; a mid-session branch switch is therefore not observed.
|
||||
|
||||
## How the denial lifts
|
||||
|
||||
Satisfaction is replayed from the session's durable log: a `tool/call` naming the `skill` tool whose arguments parse to `{name: <requiredSkill>}`, paired by call id with a non-error `tool/result`. Because the log is the only state, satisfaction survives a session resume — a resumed session that already loaded the skill is not asked again. A failed load, a differently-named skill, and malformed argument JSON all leave the denial in place.
|
||||
|
||||
Satisfaction is per session, so a subagent with its own session must load the skill itself.
|
||||
|
||||
## Enforcement point
|
||||
|
||||
The gate is a `tools/pre-execute` listener returning `{kind: 'deny', reason}`, so the call never dispatches and the file is never touched. It delegates via `next()` in every non-violating case. Denial — not an advisory reminder — is the point: an advisory nudge leaves the violation committed, and `ask` degrades to denial in a composition without approval support.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to the same repository, and unreadable metadata — to per-file 100%. The assembled-run evidence is the Loader-composition smoke (`tests/loader-composition.e2e.ts`): it boots a real headless app over `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`, seeds a staging worktree in a temporary cwd, and asserts the tool result is an error carrying the exact denial while the targeted file keeps its original bytes.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Denied filesystem call
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A gated call into a protected worktree without the required skill loaded returns an error result carrying exactly the text below. No prompt section, tool schema, or successful-call text is added, and an allowed call is indistinguishable from one made without this plugin.
|
||||
|
||||
##### Denial result
|
||||
|
||||
```markdown
|
||||
Error: Editing "<path>" directly is not allowed: it is inside the dsh checkout this session is running from, on branch <branch>. Load the <requiredSkill> skill first and follow it — implement in a task worktree, then integrate under the staging lock.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens while no denial occurs. A denial adds its small retained error result and avoids the success payload the call would have produced.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`bash` is ungated** — the guard is a boundary for the filesystem tools only; a shell command can still mutate a protected worktree.
|
||||
- **Worktree identity is cached per directory for the plugin's lifetime** — switching a protected worktree's branch mid-session does not change decisions until the next load, on either the target or the launcher side.
|
||||
- **Only the launcher's own checkout is protected** — a stale sibling checkout of the same repository stays editable, deliberately; run `dsh` from it to protect it.
|
||||
- **Disarmed outside a source checkout** — a harness running from an installed copy protects nothing unless `protectedCheckout` names a real checkout explicitly.
|
||||
- **Satisfaction is per session** — a subagent's session must load the skill itself; a parent's load does not carry over.
|
||||
- **Fail-open on unresolvable git state** — a broken or unreadable `.git` means no protection, chosen deliberately over blocking every edit.
|
||||
- **One skill lifts the whole gate for the session** — loading it does not verify the workflow was actually followed, only that the instructions were read.
|
||||
88
packages/guard/source-guard/README.zh.md
Normal file
88
packages/guard/source-guard/README.zh.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @deepseek-ai/dsh-source-guard
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是一道强制执行门禁,而非面向模型的工具:它不会出现在工具列表中,只增加一种行为。若 `write` 或 `edit` 的目标位于运行中 harness 启动来源的 dsh 检出目录内,并处于该检出目录自身的分支上,它会拒绝调用,直到调用方会话的持久日志表明已成功加载 `dsh-customize` skill(技能)。该 skill 要求在任务 worktree 中实现个人变更,并在 staging 锁保护下完成集成;本插件把其核心规则(「不要直接编辑个人 staging 检出目录」)从提示词指导变成一道模型无法因遗忘而越过的边界。
|
||||
|
||||
## 配置
|
||||
|
||||
```yaml
|
||||
- id: source-guard
|
||||
name: '@deepseek-ai/dsh-source-guard'
|
||||
config:
|
||||
requiredSkill: dsh-customize # default; the skill whose load lifts the denial
|
||||
tools: [write, edit] # default; the gated tool names
|
||||
protectedCheckout: /path/to/checkout # defaults to this module's own location
|
||||
```
|
||||
|
||||
插件加载时,每个字段都会对错误配置快速失败:`tools` 为空列表、`requiredSkill` 为空白字符串,或 `protectedCheckout` 使用相对路径时,都会抛出错误,绝不静默回退。
|
||||
|
||||
`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。
|
||||
|
||||
已交付的 TUI 组合(`examples/tui-agent/cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。
|
||||
|
||||
## 受保护的路径
|
||||
|
||||
保护范围根据从文件读取的 Git 身份确定,即 `.git`、其中的 `gitdir:` 指针和 `HEAD`;既不按路径前缀判断,也不运行 `git`。此处若匹配路径前缀就会出错:skill 要求使用的任务 worktree 位于 staging 树*内部*的 `<staging>/.worktrees/...`,而这正是应该进行编辑的位置。
|
||||
|
||||
解析过程从目标路径开始向外逐层查找,遇到第一个所属 worktree 就停止,因此返回最内层的 worktree。只有该 worktree 在两项身份上都与启动器的 worktree 匹配,才会拒绝:共用同一个共享 Git 目录,且分支相同。嵌套在受保护树下的任务 worktree 会返回自己的任务分支并获准;启动器自身所在的树会返回启动器的分支并被拒绝。仓库身份会按解析符号链接后的路径进行比较,因此指向同一仓库的两条路径——macOS 上位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径——会相互匹配,而不会触发故障放行(fail-open)。
|
||||
|
||||
要求匹配确切分支而非名称模式,可确保门禁仅作用于当前运行的部署。先前安装留下的陈旧同级检出目录虽然共享仓库,却没有运行启动器,因此该工作流规则不适用于它,它仍可编辑。
|
||||
|
||||
`gitdir:` 指针既可以是绝对路径(`git worktree add` 写入的形式),也可以是相对路径;Git 会以包含该指针的 worktree 目录为基准解析相对路径,本插件对两者都能解析。相对 `file_path` 会像文件系统工具一样,相对于调用会话的工作区解析,因此不会成为绕过门禁访问受保护文件的路径。
|
||||
|
||||
门禁刻意保持较窄的范围:
|
||||
|
||||
- **`read` 从不受门禁限制。** 检查 staging 检出不构成违规,因此只有修改类工具是候选项。
|
||||
- **`bash` 不受门禁限制。** 可靠识别会修改内容的 shell 命令不在范围内,因此执意修改的模型仍可通过 shell 修改 staging。
|
||||
- **没有 agent(智能体)的调用会被放行。** 直接调用 `ctx.tools.execute()` 的调用方没有可供回放的会话,也没有需要纠正的模型。
|
||||
- **无法解析 Git 状态时故障放行。** 不属于任何 worktree 的路径、任一侧的 HEAD 分离状态、其他仓库或分支、格式错误的 `.git` 指针或不可读的元数据,都会把调用交给链中后续环节处理。若每逢 Git 身份不可用就阻止所有写入,这道门禁造成的危害将大于它所防止的违规。
|
||||
- **无法解析的目标不会被判断。** `file_path` 为空、不是字符串,或它是相对路径而会话未指定工作区时,调用会交给工具自身校验。
|
||||
|
||||
插件会在其整个生命周期内按目标目录缓存 worktree 身份,因此同一目录中的重复写入只读取一次 Git 元数据;由此,系统不会观察到会话中途的分支切换。
|
||||
|
||||
## 如何解除拒绝
|
||||
|
||||
是否满足解锁条件由会话的持久日志回放得出:日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: <requiredSkill>}`,并且有一条调用 id 相同的非错误 `tool/result` 与之配对。由于日志是唯一状态源,恢复会话时仍能保留这一结果:若恢复的会话已经加载该 skill,系统不会再次要求加载。加载失败、skill 名称不同或参数 JSON 格式错误,都会让拒绝继续生效。
|
||||
|
||||
解锁条件按会话独立满足,因此拥有独立会话的 subagent 必须自行加载该 skill。
|
||||
|
||||
## 强制执行点
|
||||
|
||||
门禁是一个 `tools/pre-execute` 监听器,返回 `{kind: 'deny', reason}`,因此调用绝不会分派执行,文件也绝不会被修改。在所有不违规的情况下,它都会通过 `next()` 委派。这里刻意采用拒绝而非建议性提醒:建议性提醒仍会让违规落地,而在不支持批准的组合中,`ask` 会退化为拒绝。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径以及不可读元数据,达到逐文件 100% 覆盖率。组装运行层面的证据来自 Loader 组合冒烟测试(`tests/loader-composition.e2e.ts`):它通过 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` 启动一个真实的 headless 应用,在临时 cwd 中植入 staging worktree,并断言工具结果是携带精确拒绝文本的错误,同时目标文件保持原始字节不变。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 被拒绝的文件系统调用
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果未加载必需 skill 就对受保护 worktree 发起受门禁限制的调用,系统会返回错误结果,其中的文本与下文完全一致。系统不会添加提示词段、工具 schema 或成功调用文本;允许的调用与未启用此插件时的调用完全无法区分。
|
||||
|
||||
##### 拒绝结果
|
||||
|
||||
```markdown
|
||||
Error: Editing "<path>" directly is not allowed: it is inside the dsh checkout this session is running from, on branch <branch>. Load the <requiredSkill> skill first and follow it — implement in a task worktree, then integrate under the staging lock.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
未发生拒绝时为零 token。一次拒绝会添加一条会保留在历史中的短小错误结果,同时避免生成该调用原本会产生的成功载荷。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **`bash` 不受门禁限制**:此插件只为文件系统工具提供边界;shell 命令仍可修改受保护的 worktree。
|
||||
- **插件生命周期内按目录缓存 worktree 身份**:在会话中途切换受保护 worktree 的分支,不会改变判断结果,直至下次加载插件;目标侧和启动器侧都是如此。
|
||||
- **仅保护启动器自身的检出目录**:同一仓库中的陈旧同级检出目录会被刻意保留为可编辑状态;若要保护它,请从中运行 `dsh`。
|
||||
- **源码检出之外不启用**:从已安装副本运行的 harness 不保护任何内容,除非 `protectedCheckout` 明确指定真实检出目录。
|
||||
- **解锁条件按会话独立满足**:subagent 的会话必须自行加载该 skill;父会话的加载状态不会继承。
|
||||
- **无法解析 Git 状态时故障放行**:损坏或不可读的 `.git` 会使保护失效;这是刻意选择的结果,因为另一方案是阻止所有编辑。
|
||||
- **仅加载一个 skill 即可为会话解除整道门禁**:加载该 skill 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。
|
||||
56
packages/guard/source-guard/package.json
Normal file
56
packages/guard/source-guard/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-source-guard",
|
||||
"description": "Source-guard plugin: denies direct file edits inside a dsh staging worktree until the required customization skill is loaded",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
319
packages/guard/source-guard/src/index.ts
Normal file
319
packages/guard/source-guard/src/index.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Denies model-driven file mutation inside a dsh staging worktree until the
|
||||
* calling session has loaded the required customization skill. Config, git
|
||||
* resolution, and satisfaction semantics live in the package README; rationale
|
||||
* lives in the source-guard Agent Note.
|
||||
* @module @deepseek-ai/dsh-source-guard
|
||||
*/
|
||||
|
||||
import { dirname, isAbsolute, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'source-guard'
|
||||
|
||||
/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */
|
||||
export const inject = ['fs']
|
||||
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema plus the
|
||||
* load-time checks in `apply` (misconfiguration fails loud: an empty `tools`
|
||||
* list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at
|
||||
* plugin load, never a silent fall-back).
|
||||
*/
|
||||
export interface Config {
|
||||
/** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */
|
||||
requiredSkill?: string
|
||||
/** Tool names to gate (default `['write', 'edit']`). */
|
||||
tools?: string[]
|
||||
/**
|
||||
* Absolute path inside the checkout this guard protects. Its worktree
|
||||
* supplies BOTH protected identities: the repository (targets in any other
|
||||
* repository are ignored) and the exact branch (only that branch's worktree
|
||||
* is protected). Defaults to this module's own location, which resolves the
|
||||
* checkout the running harness was launched from — the live deployment,
|
||||
* whatever its branch is named. Set it explicitly to guard a different
|
||||
* checkout, or when the harness runs from an installed copy whose own
|
||||
* location is not a checkout at all.
|
||||
*/
|
||||
protectedCheckout?: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
requiredSkill: z.string().default('dsh-customize'),
|
||||
tools: z.array(z.string()).default(['write', 'edit']),
|
||||
protectedCheckout: z.string().default(fileURLToPath(import.meta.url)),
|
||||
})
|
||||
|
||||
/**
|
||||
* The tool whose successful call satisfies the guard. Fixed, not configurable:
|
||||
* this is the harness's own skill-loading tool name, so a deployment that
|
||||
* renamed it has no skill to load and nothing for this guard to observe.
|
||||
*/
|
||||
const SKILL_TOOL = 'skill'
|
||||
|
||||
/**
|
||||
* The argument key every gated tool names its target with. `write` and `edit`
|
||||
* share it (`dsh-tool-fs`), and gating a tool that does not is a
|
||||
* misconfiguration the guard reports rather than silently allowing.
|
||||
*/
|
||||
const PATH_ARGUMENT = 'file_path'
|
||||
|
||||
/**
|
||||
* The absolute `file_path` a gated call targets, or `undefined` when the
|
||||
* arguments carry no usable one. Arguments arrive as the loop's parsed model
|
||||
* JSON, so this is a model-input boundary: any shape is possible.
|
||||
*
|
||||
* A relative path resolves against the calling session's workspace, exactly as
|
||||
* the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only
|
||||
* absolute paths would leave `write` with a relative `file_path` as an
|
||||
* unguarded path to the same file.
|
||||
*/
|
||||
function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined {
|
||||
if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined
|
||||
const value = (argumentsValue as Record<string, unknown>)[PATH_ARGUMENT]
|
||||
if (typeof value !== 'string' || value.length === 0) return undefined
|
||||
if (isAbsolute(value)) return resolve(value)
|
||||
// Without a session cwd the tools fall back to a provider-owned default this
|
||||
// guard cannot observe, so the target is genuinely unresolvable here.
|
||||
return sessionCwd === undefined ? undefined : resolve(sessionCwd, value)
|
||||
}
|
||||
|
||||
/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */
|
||||
interface Worktree {
|
||||
/** Branch name from `HEAD`, or `undefined` for a detached HEAD. */
|
||||
branch: string | undefined
|
||||
/**
|
||||
* Symlink-resolved absolute path of the shared git directory, identifying the
|
||||
* repository across worktrees. Canonical because two paths reaching one
|
||||
* repository by different symlink routes must compare equal — on macOS a
|
||||
* session cwd under `/var/...` and a configured path under `/private/var/...`
|
||||
* name the same directory, and a lexical comparison would fail open.
|
||||
*/
|
||||
commonDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What one git-metadata path holds: a file's text, the fact that it is a
|
||||
* directory, or nothing resolvable. Every caller treats the unresolvable case
|
||||
* as "not a worktree" and lets the call proceed, so distinguishing absence
|
||||
* from a permission error would change no decision.
|
||||
*/
|
||||
type GitEntry =
|
||||
| { kind: 'file'; text: string }
|
||||
| { kind: 'directory' }
|
||||
| { kind: 'absent' }
|
||||
|
||||
/** Probe one git-metadata path, reading its text when it is a regular file. */
|
||||
async function readGitEntry(ctx: Context, path: string): Promise<GitEntry> {
|
||||
try {
|
||||
const target = await ctx.fs.resolve(path)
|
||||
const info = await ctx.fs.stat(target)
|
||||
if (info?.type === 'directory') return { kind: 'directory' }
|
||||
if (info?.type !== 'file') return { kind: 'absent' }
|
||||
return { kind: 'file', text: await ctx.fs.readText(target) }
|
||||
} catch {
|
||||
// Any resolve/stat/read failure (absent, denied, unreadable encoding)
|
||||
// yields no git identity. Nothing else can reach here: the guard performs
|
||||
// no other IO.
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a
|
||||
* detached HEAD holds a raw object id and has no branch, which no staging
|
||||
* pattern can match.
|
||||
*/
|
||||
function branchFromHead(head: string): string | undefined {
|
||||
const trimmed = head.trim()
|
||||
const ref = 'ref: refs/heads/'
|
||||
return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the git directory a worktree root's `.git` entry designates, plus
|
||||
* the shared common directory. A plain clone's `.git` is a directory that is
|
||||
* its own common dir; a linked worktree's `.git` is a file pointing into the
|
||||
* main repository's `worktrees/<name>`, whose common dir is two levels up.
|
||||
* A `gitdir:` pointer may be relative, which git resolves against the worktree
|
||||
* directory holding it.
|
||||
*/
|
||||
async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> {
|
||||
const dotGit = resolve(root, '.git')
|
||||
const entry = await readGitEntry(ctx, dotGit)
|
||||
// A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the
|
||||
// common dir; a linked worktree keeps a `.git` FILE pointing elsewhere.
|
||||
if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) }
|
||||
if (entry.kind === 'absent') return undefined
|
||||
const prefix = 'gitdir:'
|
||||
const trimmed = entry.text.trim()
|
||||
if (!trimmed.startsWith(prefix)) return undefined
|
||||
const pointer = trimmed.slice(prefix.length).trim()
|
||||
if (pointer.length === 0) return undefined
|
||||
const gitDir = resolve(root, pointer)
|
||||
// `<common>/worktrees/<name>` — the shared repository is two levels up.
|
||||
return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk from a path toward the filesystem root and resolve the first enclosing
|
||||
* worktree, or `undefined` when the path is inside none.
|
||||
*/
|
||||
async function findWorktree(ctx: Context, from: string): Promise<Worktree | undefined> {
|
||||
let current = from
|
||||
for (;;) {
|
||||
const dirs = await resolveGitDir(ctx, current)
|
||||
if (dirs !== undefined) {
|
||||
const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD'))
|
||||
return {
|
||||
branch: head.kind === 'file' ? branchFromHead(head.text) : undefined,
|
||||
commonDir: dirs.commonDir,
|
||||
}
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return undefined
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill name a `skill` call's raw argument JSON requested, or `undefined`
|
||||
* when the JSON is malformed or carries no string `name`. The log stores the
|
||||
* model's unparsed argument string, so this is a model-JSON boundary.
|
||||
*/
|
||||
function skillNameOf(rawArguments: string): string | undefined {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(rawArguments)
|
||||
} catch {
|
||||
// The model produced argument text that is not JSON; the call cannot have
|
||||
// named a skill. Nothing else in this try can throw.
|
||||
return undefined
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined
|
||||
const value = (parsed as Record<string, unknown>).name
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's durable log records a successful load of
|
||||
* `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so
|
||||
* satisfaction survives a session resume: the log is the only state.
|
||||
*/
|
||||
function skillLoaded(session: Session, requiredSkill: string): boolean {
|
||||
const requested = new Map<CallId, string>()
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'tool/call') {
|
||||
if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments)
|
||||
continue
|
||||
}
|
||||
const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined
|
||||
if (block === undefined || block.isError === true) continue
|
||||
const rawArguments = requested.get(block.toolCallId)
|
||||
if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The denial text a blocked call reports to the model. */
|
||||
function denialReason(path: string, branch: string, requiredSkill: string): string {
|
||||
return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. `
|
||||
+ `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the guard's listener.
|
||||
* @param ctx - plugin context; the listener is scoped to it and disposed with it.
|
||||
* @param config - validated {@link Config}; re-checked fail-loud here.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery's .default() guarantees the fields are set after validation.
|
||||
const requiredSkill = config.requiredSkill as string
|
||||
const tools = config.tools as string[]
|
||||
if (tools.length === 0) {
|
||||
throw new Error('source-guard: `tools` must not be empty')
|
||||
}
|
||||
if (requiredSkill.trim().length === 0) {
|
||||
throw new Error('source-guard: `requiredSkill` must not be blank')
|
||||
}
|
||||
const gated = new Set(tools)
|
||||
|
||||
const protectedCheckout = config.protectedCheckout as string
|
||||
if (!isAbsolute(protectedCheckout)) {
|
||||
throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`)
|
||||
}
|
||||
// Resolved once per plugin lifetime: the worktree this guard arms for, which
|
||||
// supplies both the protected repository and the protected branch. A harness
|
||||
// running from an installed copy resolves a different repository (or none)
|
||||
// and therefore guards nothing, which is correct — the rule is meaningless
|
||||
// outside a source checkout.
|
||||
let protectedRepository: Promise<Worktree | undefined> | undefined
|
||||
|
||||
/** The repository containing {@link Config.protectedCheckout}. */
|
||||
function repository(): Promise<Worktree | undefined> {
|
||||
protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout))
|
||||
return protectedRepository
|
||||
}
|
||||
|
||||
// Worktree identity per directory, cached for the plugin's lifetime: a
|
||||
// directory's repository and branch are stable in practice, and re-reading
|
||||
// git metadata on every write would repeat identical IO. A mid-session
|
||||
// branch switch is therefore not observed (see the README).
|
||||
const worktrees = new Map<string, Promise<Worktree | undefined>>()
|
||||
|
||||
/** Resolve (and memoize) the worktree enclosing a target path's directory. */
|
||||
function worktreeOf(path: string): Promise<Worktree | undefined> {
|
||||
const directory = dirname(path)
|
||||
let pending = worktrees.get(directory)
|
||||
if (pending === undefined) {
|
||||
pending = findWorktree(ctx, directory)
|
||||
worktrees.set(directory, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
/**
|
||||
* The target path and the staging branch protecting it, or `undefined` when
|
||||
* the call may proceed. Fails open on every unresolvable case: a path outside
|
||||
* any worktree, a detached HEAD, a different repository, or unreadable git
|
||||
* metadata leaves the call to the rest of the chain, because a guard that
|
||||
* blocked writes whenever git identity was unavailable would be worse than
|
||||
* the violation it prevents.
|
||||
*/
|
||||
async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> {
|
||||
if (!gated.has(exec.name)) return undefined
|
||||
const path = targetPath(exec.arguments, session.header.cwd)
|
||||
if (path === undefined) return undefined
|
||||
const launcher = await repository()
|
||||
// A detached launcher checkout names no branch to protect, so nothing is.
|
||||
if (launcher?.branch === undefined) return undefined
|
||||
// Resolution walks OUTWARD from the target, so it reports the INNERMOST
|
||||
// enclosing worktree: a task worktree nested under the protected tree
|
||||
// answers with its own task branch, which is not the launcher's. That is
|
||||
// what keeps the prescribed workflow unblocked.
|
||||
const worktree = await worktreeOf(path)
|
||||
if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined
|
||||
// Only the branch the launcher itself runs from is protected: a stale
|
||||
// sibling checkout of the same repository is not the live deployment.
|
||||
if (worktree.branch !== launcher.branch) return undefined
|
||||
return { path, branch: launcher.branch }
|
||||
}
|
||||
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
// A direct `ctx.tools.execute()` caller has no session to replay and no
|
||||
// model to correct; only agent-loop calls are gated.
|
||||
if (exec.agent === undefined) return next()
|
||||
const { session } = exec.agent
|
||||
const target = await protectedTarget(exec, session)
|
||||
if (target === undefined) return next()
|
||||
if (skillLoaded(session, requiredSkill)) return next()
|
||||
return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) }
|
||||
})
|
||||
}
|
||||
85
packages/guard/source-guard/src/invariant.ts
Normal file
85
packages/guard/source-guard/src/invariant.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`.
|
||||
* @module @deepseek-ai/dsh-source-guard/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'source-guard-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* The durable shape of this guard's refusal. The denial is the package's only
|
||||
* model-visible output, and it is actionable only when it names all three of
|
||||
* the offending path, the branch that protects it, and the skill that lifts
|
||||
* the denial — a refusal missing any of them tells the model to stop without
|
||||
* telling it how to proceed.
|
||||
*/
|
||||
const DENIAL = new RegExp(
|
||||
'^Error: Editing "(?<path>.+)" directly is not allowed: '
|
||||
+ 'it is inside the dsh checkout this session is running from, on branch (?<branch>\\S+)\\. '
|
||||
+ 'Load the (?<skill>\\S+) skill first and follow it '
|
||||
+ '— implement in a task worktree, then integrate under the staging lock\\.$',
|
||||
)
|
||||
|
||||
/** The denial prefix identifying a result this package produced, before its full shape is validated. */
|
||||
const DENIAL_PREFIX = 'Error: Editing "'
|
||||
|
||||
/** Validate one guard-produced denial result's model-facing text. */
|
||||
function validateDenial(text: string, fail: InvariantFailure): void {
|
||||
const match = DENIAL.exec(text)
|
||||
if (match === null) {
|
||||
fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it')
|
||||
}
|
||||
// The pattern's `\S+` groups already establish a non-empty branch and skill;
|
||||
// only path absoluteness remains to check.
|
||||
const { path } = match.groups as { path: string }
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate every guard denial carried by one session's durable log. */
|
||||
function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'tool/result') continue
|
||||
validateEvent(event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one durable tool result, when it carries this package's denial. */
|
||||
function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void {
|
||||
const result = event.data.message.content[0]
|
||||
if (result.isError !== true) return
|
||||
for (const block of result.content) {
|
||||
if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue
|
||||
validateDenial(block.text, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install validation for loaded and newly appended denial results. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) validateSession(session, fail)
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'tool/result') return
|
||||
validateEvent(event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* 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))
|
||||
134
packages/guard/source-guard/tests/invariant.spec.ts
Normal file
134
packages/guard/source-guard/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, createToolResultMessage, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SourceGuardInvariant from '@deepseek-ai/dsh-source-guard/invariant'
|
||||
|
||||
/**
|
||||
* The companion validates the durable shape of this package's only
|
||||
* model-visible output: its refusal must name the offending path, the branch
|
||||
* that protects it, and the skill that lifts it, so the model can act on the
|
||||
* denial instead of merely stopping.
|
||||
*/
|
||||
|
||||
const PATH = '/repo/staging/file.ts'
|
||||
|
||||
/** A well-formed denial for `path`, as the guard materializes it into a tool result. */
|
||||
function denial(path = PATH, branch = 'dsh-staging/20260101T000000Z', skill = 'dsh-customize'): string {
|
||||
return `Error: Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, `
|
||||
+ `on branch ${branch}. Load the ${skill} skill first and follow it `
|
||||
+ '— implement in a task worktree, then integrate under the staging lock.'
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(SourceGuardInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** One durable tool result carrying `content`, error-flagged unless told otherwise. */
|
||||
function result(content: unknown[], isError = true): SessionEvent {
|
||||
return {
|
||||
type: 'tool/result',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
surfaceOp: 'append',
|
||||
sourceEventSeqs: [0],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c0'),
|
||||
content: content as ContentBlock[],
|
||||
isError,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('source-guard invariants', () => {
|
||||
it('accepts a denial naming the path, branch, and skill', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('accept'))
|
||||
expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text: denial() }])) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts a Windows-style absolute path', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('accept-windows'))
|
||||
const event = result([{ type: 'text', text: denial(String.raw`C:\repo\staging\file.ts`) }])
|
||||
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a successful result that merely quotes the prefix', false],
|
||||
])('ignores %s', async (_label, isError) => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('ignore-success'))
|
||||
const event = result([{ type: 'text', text: 'Error: Editing "x" was fine' }], isError)
|
||||
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-text block', [{ type: 'image', data: 'x', mimeType: 'image/png' }]],
|
||||
['text that is not this package\'s denial', [{ type: 'text', text: 'Error: something else' }]],
|
||||
])('ignores %s', async (_label, content) => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('ignore-other'))
|
||||
expect(() => { ctx.emit('session/event', session, result(content)) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('ignores an event that is not a tool result', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('ignore-kind'))
|
||||
const event: SessionEvent = {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({ content: [{ type: 'text', text: denial() }], source: { kind: 'user' } }),
|
||||
}
|
||||
expect(() => { ctx.emit('session/event', session, event) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'omits the skill that lifts it',
|
||||
`Error: Editing "${PATH}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch main.`,
|
||||
],
|
||||
[
|
||||
'names a relative path',
|
||||
denial('relative/file.ts'),
|
||||
],
|
||||
])('rejects a denial that %s', async (_label, text) => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('reject'))
|
||||
expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text }])) }).toThrow(/source-guard denial/)
|
||||
})
|
||||
|
||||
it('rejects an invalid denial already present on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('late'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const call = session.append('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c0'), name: 'write', arguments: '{}',
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c0'),
|
||||
content: [{ type: 'text', text: denial('relative/file.ts') }],
|
||||
isError: true,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(SourceGuardInvariant).then(() => undefined)).rejects.toThrow(/source-guard denial/)
|
||||
})
|
||||
})
|
||||
93
packages/guard/source-guard/tests/loader-composition.e2e.ts
Normal file
93
packages/guard/source-guard/tests/loader-composition.e2e.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
// The Loader config lives under examples so both launch modes exercise the same
|
||||
// deployable topology: a local fixture adapter plus bare workspace plugins.
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** Every `.jsonl` session log under `dir`. */
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const paths = await Promise.all(entries.map(async (entry) => {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) return jsonlFiles(path)
|
||||
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
|
||||
}))
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write git metadata mirroring the installer layout — a master clone owning the
|
||||
* shared git directory and one linked worktree on a staging branch — and return
|
||||
* the worktree file the model will try to write.
|
||||
*/
|
||||
async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> {
|
||||
const gitDir = join(cwd, 'master', '.git')
|
||||
const worktreeGitDir = join(gitDir, 'worktrees', 'staging')
|
||||
await mkdir(worktreeGitDir, { recursive: true })
|
||||
await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n')
|
||||
await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n')
|
||||
const checkout = join(cwd, 'staging')
|
||||
await mkdir(checkout, { recursive: true })
|
||||
await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`)
|
||||
const target = join(checkout, 'guarded.ts')
|
||||
await writeFile(target, 'original\n')
|
||||
return { checkout, target }
|
||||
}
|
||||
|
||||
describe('source-guard through a real headless cordis.yml', () => {
|
||||
it('denies the model-requested write and leaves the staged file untouched', async () => {
|
||||
let events: SessionEvent[] = []
|
||||
let contents = ''
|
||||
let target = ''
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'source-guard headless smoke',
|
||||
tempDirPrefix: 'source-guard-e2e-',
|
||||
binScript,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
binArgs: ['--config', configPath, 'edit the guarded file'],
|
||||
// The isolated cwd is not known when these options are built, so the
|
||||
// config and adapter resolve their fixture paths against the child's own
|
||||
// cwd, which is that directory.
|
||||
prepare: async (cwd) => {
|
||||
// macOS puts the temp directory behind the /var -> /private/var
|
||||
// symlink; the child resolves its cwd, so compare against the same
|
||||
// real path rather than the symlinked one this process was handed.
|
||||
target = (await stagingFixture(await realpath(cwd))).target
|
||||
},
|
||||
inspect: async (cwd) => {
|
||||
const logs = await jsonlFiles(join(cwd, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
contents = await readFile(target, 'utf8')
|
||||
},
|
||||
})
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
|
||||
const results = events.filter(
|
||||
(event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(1)
|
||||
const result = results[0]?.data.message.content[0]
|
||||
expect(result?.isError).toBe(true)
|
||||
const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('')
|
||||
expect(text).toBe(
|
||||
`Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, `
|
||||
+ 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it '
|
||||
+ '— implement in a task worktree, then integrate under the staging lock.',
|
||||
)
|
||||
// Enforcement, not advice: the guard denies before dispatch, so the file
|
||||
// the model targeted still holds its original bytes.
|
||||
expect(contents).toBe('original\n')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
581
packages/guard/source-guard/tests/source-guard.spec.ts
Normal file
581
packages/guard/source-guard/tests/source-guard.spec.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as SourceGuard from '@deepseek-ai/dsh-source-guard'
|
||||
import type { Config } from '@deepseek-ai/dsh-source-guard'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Behavior suite for the staging-source guard: worktree resolution over REAL
|
||||
* git metadata fixtures (a staging worktree, a nested task worktree, a plain
|
||||
* clone, an unrelated repository, a detached HEAD), skill satisfaction replayed
|
||||
* from the durable session log, and fail-loud config validation — all driven
|
||||
* through a real agent loop against a scripted mock adapter (no network).
|
||||
*/
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
/**
|
||||
* Build a git-metadata fixture tree that mirrors the real installer layout: a
|
||||
* `master` clone holding the shared git directory, linked worktrees registered
|
||||
* under `master/.git/worktrees/<name>`, and one file per worktree to target.
|
||||
*/
|
||||
async function fixture(): Promise<{
|
||||
/** Absolute path of the fixture container. */
|
||||
root: string
|
||||
/** A file inside the staging worktree — the protected target. */
|
||||
stagingFile: string
|
||||
/** A file inside a task worktree NESTED under the staging tree. */
|
||||
taskFile: string
|
||||
/** A file inside a SIBLING staging worktree of the same repository, on another branch. */
|
||||
siblingFile: string
|
||||
/** A file inside the plain master clone. */
|
||||
masterFile: string
|
||||
/** A file inside a worktree whose HEAD is detached. */
|
||||
detachedFile: string
|
||||
/** A file inside an unrelated repository sharing no git directory. */
|
||||
outsideFile: string
|
||||
/** A file under no repository at all. */
|
||||
looseFile: string
|
||||
}> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'source-guard-'))
|
||||
roots.push(root)
|
||||
const master = join(root, 'master')
|
||||
const gitDir = join(master, '.git')
|
||||
await mkdir(join(gitDir, 'worktrees'), { recursive: true })
|
||||
await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n')
|
||||
await writeFile(join(master, 'file.ts'), 'master\n')
|
||||
|
||||
/** Register one linked worktree at `path` whose HEAD file holds `head`. */
|
||||
async function linked(path: string, name: string, head: string): Promise<string> {
|
||||
const worktreeGitDir = join(gitDir, 'worktrees', name)
|
||||
await mkdir(worktreeGitDir, { recursive: true })
|
||||
await writeFile(join(worktreeGitDir, 'HEAD'), head)
|
||||
await mkdir(path, { recursive: true })
|
||||
await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`)
|
||||
const file = join(path, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
return file
|
||||
}
|
||||
|
||||
const staging = join(root, 'staging-20260728T022827Z')
|
||||
const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n')
|
||||
// The prescribed workflow's task worktree lives INSIDE the staging tree.
|
||||
const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n')
|
||||
// A stale staging worktree from an earlier install: same repository, different branch.
|
||||
const siblingFile = await linked(
|
||||
join(root, 'staging-20260727T045831Z'),
|
||||
'staging-20260727T045831Z',
|
||||
'ref: refs/heads/dsh-staging/20260727T045831Z\n',
|
||||
)
|
||||
const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n')
|
||||
|
||||
const outside = join(root, 'outside')
|
||||
await mkdir(join(outside, '.git'), { recursive: true })
|
||||
await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n')
|
||||
const outsideFile = join(outside, 'file.ts')
|
||||
await writeFile(outsideFile, 'outside\n')
|
||||
|
||||
const loose = join(root, 'loose')
|
||||
await mkdir(loose, { recursive: true })
|
||||
const looseFile = join(loose, 'file.ts')
|
||||
await writeFile(looseFile, 'loose\n')
|
||||
|
||||
return {
|
||||
root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the core spine, a real local filesystem, and the guard, pointing
|
||||
* `protectedCheckout` at a fixture path so the guard arms for the fixture
|
||||
* repository instead of the checkout these tests actually run in.
|
||||
*/
|
||||
async function harness(protectedCheckout: string, config: Partial<Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(LocalFileSystem, {})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SourceGuard, { ...config, protectedCheckout })
|
||||
for (const name of ['write', 'edit', 'read', 'skill']) {
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name,
|
||||
description: name,
|
||||
parameters: { file_path: { type: 'string' }, name: { type: 'string' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
}))
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Every tool result in the agent's log as `{ isError, text }`, in log order. */
|
||||
function results(agent: Agent): { isError: boolean; text: string }[] {
|
||||
return [...agent.session.events]
|
||||
.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
|
||||
.map(event => event.data.message.content[0])
|
||||
.map(result => ({
|
||||
isError: result.isError === true,
|
||||
text: result.content.map(block => block.type === 'text' ? block.text : '').join(''),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable events recording completed `skill` calls, as a RESUMED session's seed:
|
||||
* the guard's satisfaction check then has nothing but the log to read, with no
|
||||
* in-memory state from an original run to fall back on.
|
||||
*/
|
||||
function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
]
|
||||
for (const [index, call] of calls.entries()) {
|
||||
const callId = CallId(`prior${index}`)
|
||||
const seq = events.length
|
||||
events.push({
|
||||
type: 'tool/call',
|
||||
seq,
|
||||
time: seq + 1,
|
||||
data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments },
|
||||
})
|
||||
events.push({
|
||||
type: 'tool/result',
|
||||
seq: seq + 1,
|
||||
time: seq + 2,
|
||||
surfaceOp: 'append',
|
||||
sourceEventSeqs: [seq],
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'loaded' }],
|
||||
isError: call.isError ?? false,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
const tail = events.length
|
||||
events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } })
|
||||
events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
return events
|
||||
}
|
||||
|
||||
/** Resume a session from durable seed events and let the model attempt one write at `path`. */
|
||||
async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise<Agent> {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse(CallId('c0'), 'write', { file_path: path }),
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const { agent } = await ctx.agentLoop.createAgent(ctx, {
|
||||
sessionId: SessionId(id),
|
||||
seed,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
/** Drive one turn whose scripted model output is the given tool calls, then a closing text. */
|
||||
async function run(
|
||||
ctx: Context,
|
||||
calls: { name: string; args: Record<string, unknown> }[],
|
||||
cwd?: string,
|
||||
): Promise<Agent> {
|
||||
const adapter = new MockAdapter([
|
||||
...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)),
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(
|
||||
SessionId('s1'),
|
||||
{ provider: 'mock', model: 'mock' },
|
||||
cwd === undefined ? {} : { cwd },
|
||||
)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
describe('staging protection', () => {
|
||||
it('denies a write inside the staging worktree and names the path, branch, and skill', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
const [result] = results(agent)
|
||||
expect(result?.isError).toBe(true)
|
||||
expect(result?.text).toBe(
|
||||
`Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, `
|
||||
+ 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it '
|
||||
+ '— implement in a task worktree, then integrate under the staging lock.',
|
||||
)
|
||||
})
|
||||
|
||||
it('denies an edit inside the staging worktree', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)[0]?.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('allows a read inside the staging worktree, since inspection never violates the skill', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write inside a task worktree nested under the staging tree', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write in the plain clone that owns the shared git directory', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write on a staging-named branch in an unrelated repository', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write under a detached HEAD, which names no branch to match', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write when the git metadata exists but cannot be read', async () => {
|
||||
const paths = await fixture()
|
||||
// A `.git` pointer that stats as a file yet fails to read leaves the guard
|
||||
// with no branch to judge; failing open beats blocking every edit.
|
||||
const unreadable = join(paths.root, 'unreadable')
|
||||
await mkdir(unreadable, { recursive: true })
|
||||
await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`)
|
||||
await chmod(join(unreadable, '.git'), 0o000)
|
||||
const file = join(unreadable, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('allows a write under no repository at all', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('arms for nothing when its own location is inside no repository', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.looseFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('arms for nothing when the launcher checkout has a detached HEAD', async () => {
|
||||
const paths = await fixture()
|
||||
// A detached launcher names no branch, so there is no branch to protect.
|
||||
const ctx = await harness(paths.detachedFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('denies when the target and the protected checkout reach one repository through different symlinks', async () => {
|
||||
const paths = await fixture()
|
||||
// macOS reaches the temp directory through both `/var/...` and
|
||||
// `/private/var/...`; a lexical repository comparison would treat the two
|
||||
// routes as different repositories and fail open on every write.
|
||||
const link = join(paths.root, 'link')
|
||||
await symlink(dirname(paths.stagingFile), link, 'dir')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }])
|
||||
expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z')
|
||||
})
|
||||
|
||||
it('denies a RELATIVE target path resolved against the session workspace', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
// The filesystem tools resolve a relative `file_path` against the session
|
||||
// cwd, so judging only absolute paths would leave this as an unguarded
|
||||
// route to the same file.
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile))
|
||||
expect(results(agent)[0]?.text).toContain('directly is not allowed')
|
||||
})
|
||||
|
||||
it('ignores a relative target path when the session names no workspace', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('ignores a call whose target path is an empty string', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-string file_path', { file_path: 7 }],
|
||||
['no file_path at all', { other: 'x' }],
|
||||
])('ignores a gated call carrying %s', async (_label, args) => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args }])
|
||||
expect(results(agent)[0]?.text).not.toContain('directly is not allowed')
|
||||
})
|
||||
|
||||
it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const callId = CallId('raw')
|
||||
const adapter = new MockAdapter([
|
||||
[
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
],
|
||||
textResponse('done'),
|
||||
])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(results(agent)[0]?.text).not.toContain('directly is not allowed')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'],
|
||||
['an empty `.git` pointer', 'gitdir:\n'],
|
||||
['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'],
|
||||
])('allows a write behind %s', async (_label, pointer) => {
|
||||
const paths = await fixture()
|
||||
const broken = join(paths.root, 'broken')
|
||||
await mkdir(broken, { recursive: true })
|
||||
await writeFile(join(broken, '.git'), pointer)
|
||||
const file = join(broken, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => {
|
||||
const paths = await fixture()
|
||||
// `git worktree add` writes an absolute pointer, but a relocated or
|
||||
// hand-written one may be relative; git accepts both, so the guard must
|
||||
// resolve both or it would fail open on a real repository layout.
|
||||
const relative = join(paths.root, 'relative-pointer')
|
||||
await mkdir(relative, { recursive: true })
|
||||
await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n')
|
||||
const file = join(relative, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
|
||||
expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z')
|
||||
})
|
||||
|
||||
it('allows a write when the worktree resolves but its HEAD is missing', async () => {
|
||||
const paths = await fixture()
|
||||
const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless')
|
||||
await mkdir(gitDir, { recursive: true })
|
||||
const headless = join(paths.root, 'headless')
|
||||
await mkdir(headless, { recursive: true })
|
||||
await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`)
|
||||
const file = join(headless, 'file.ts')
|
||||
await writeFile(file, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('reuses one resolution for sibling targets in the same directory', async () => {
|
||||
const paths = await fixture()
|
||||
const sibling = join(dirname(paths.stagingFile), 'other.ts')
|
||||
await writeFile(sibling, 'content\n')
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
{ name: 'write', args: { file_path: sibling } },
|
||||
])
|
||||
expect(results(agent).map(result => result.isError)).toEqual([true, true])
|
||||
})
|
||||
|
||||
it('protects whichever branch the launcher checkout is on, whatever its name', async () => {
|
||||
const paths = await fixture()
|
||||
// The protected branch is read from `protectedCheckout`'s own worktree, so
|
||||
// a checkout on an unconventional branch name is still protected — a
|
||||
// hardcoded name pattern would have silently guarded nothing.
|
||||
const ctx = await harness(paths.taskFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }])
|
||||
expect(results(agent)[0]?.text).toContain('on branch task/x')
|
||||
})
|
||||
|
||||
it('allows a write in a SIBLING checkout of the same repository on another branch', async () => {
|
||||
const paths = await fixture()
|
||||
// A stale staging worktree left by an earlier install shares the
|
||||
// repository but is not the live deployment, so the workflow rule the
|
||||
// guard enforces does not apply to it.
|
||||
const ctx = await harness(paths.siblingFile)
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
|
||||
it('gates only the configured tools', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile, { tools: ['edit'] })
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
{ name: 'edit', args: { file_path: paths.stagingFile } },
|
||||
])
|
||||
expect(results(agent).map(result => result.isError)).toEqual([false, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill satisfaction', () => {
|
||||
it('allows the write after a successful load of the required skill in the same turn', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'skill', args: { name: 'dsh-customize' } },
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
])
|
||||
expect(results(agent)).toEqual([
|
||||
{ isError: false, text: 'ok' },
|
||||
{ isError: false, text: 'ok' },
|
||||
])
|
||||
})
|
||||
|
||||
it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }])
|
||||
const agent = await resume(ctx, 'resumed', seed, paths.stagingFile)
|
||||
expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' })
|
||||
})
|
||||
|
||||
it('does not accept a failed skill load', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }])
|
||||
const agent = await resume(ctx, 'failed', seed, paths.stagingFile)
|
||||
expect(results(agent).at(-1)?.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('does not accept a different skill', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'skill', args: { name: 'dsh-upgrade' } },
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
])
|
||||
expect(results(agent).map(result => result.isError)).toEqual([false, true])
|
||||
})
|
||||
|
||||
it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const seed = priorSkillCalls([
|
||||
{ arguments: 'not json' },
|
||||
{ arguments: '[]' },
|
||||
{ arguments: '{"name":7}' },
|
||||
{ arguments: 'null' },
|
||||
])
|
||||
const agent = await resume(ctx, 'malformed', seed, paths.stagingFile)
|
||||
expect(results(agent).at(-1)?.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('honours a configured skill name other than the default', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' })
|
||||
const agent = await run(ctx, [
|
||||
{ name: 'skill', args: { name: 'other-skill' } },
|
||||
{ name: 'write', args: { file_path: paths.stagingFile } },
|
||||
])
|
||||
expect(results(agent).map(result => result.isError)).toEqual([false, false])
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-agent callers', () => {
|
||||
it('leaves a direct registry call ungated, having no session to replay', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = await harness(paths.stagingFile)
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('direct'),
|
||||
name: 'write',
|
||||
arguments: { file_path: paths.stagingFile },
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('config validation', () => {
|
||||
it.each([
|
||||
['tools', { tools: [] }, '`tools` must not be empty'],
|
||||
['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'],
|
||||
['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'],
|
||||
])('rejects an invalid %s at load', async (_field, config, message) => {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(LocalFileSystem, {})
|
||||
await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal', () => {
|
||||
it('stops gating once the plugin fiber is disposed', async () => {
|
||||
const paths = await fixture()
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(LocalFileSystem, {})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile })
|
||||
for (const name of ['write', 'skill']) {
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name,
|
||||
description: name,
|
||||
parameters: { file_path: { type: 'string' }, name: { type: 'string' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
}))
|
||||
}
|
||||
await fiber.dispose()
|
||||
const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
|
||||
expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
|
||||
})
|
||||
})
|
||||
42
packages/guard/source-guard/tsconfig.json
Normal file
42
packages/guard/source-guard/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/session-registry/README.i18n.yaml
Normal file
6
packages/session-registry/README.i18n.yaml
Normal 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
|
||||
15
packages/session-registry/README.md
Normal file
15
packages/session-registry/README.md
Normal 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.
|
||||
15
packages/session-registry/README.zh.md
Normal file
15
packages/session-registry/README.zh.md
Normal 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 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。
|
||||
|
||||
这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。
|
||||
@@ -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
|
||||
42
packages/session-registry/session-registry-file/README.md
Normal file
42
packages/session-registry/session-registry-file/README.md
Normal 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.
|
||||
42
packages/session-registry/session-registry-file/README.zh.md
Normal file
42
packages/session-registry/session-registry-file/README.zh.md
Normal 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 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。
|
||||
47
packages/session-registry/session-registry-file/package.json
Normal file
47
packages/session-registry/session-registry-file/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
97
packages/session-registry/session-registry-file/src/file.ts
Normal file
97
packages/session-registry/session-registry-file/src/file.ts
Normal 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`
|
||||
}
|
||||
233
packages/session-registry/session-registry-file/src/index.ts
Normal file
233
packages/session-registry/session-registry-file/src/index.ts
Normal 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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
27
packages/session-registry/session-registry-file/tests/fixtures/register-once.ts
vendored
Normal file
27
packages/session-registry/session-registry-file/tests/fixtures/register-once.ts
vendored
Normal 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) })
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
32
packages/session-registry/session-registry-live/README.md
Normal file
32
packages/session-registry/session-registry-live/README.md
Normal 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.
|
||||
32
packages/session-registry/session-registry-live/README.zh.md
Normal file
32
packages/session-registry/session-registry-live/README.zh.md
Normal 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` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。
|
||||
44
packages/session-registry/session-registry-live/package.json
Normal file
44
packages/session-registry/session-registry-live/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
84
packages/session-registry/session-registry-live/src/index.ts
Normal file
84
packages/session-registry/session-registry-live/src/index.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
30
packages/session-registry/session-registry/README.md
Normal file
30
packages/session-registry/session-registry/README.md
Normal 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.
|
||||
30
packages/session-registry/session-registry/README.zh.md
Normal file
30
packages/session-registry/session-registry/README.zh.md
Normal 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` disposer;await 它即等待移除达到持久性。
|
||||
- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。
|
||||
- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。
|
||||
|
||||
后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。
|
||||
|
||||
## 记录词汇
|
||||
|
||||
`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid`、`cwd`、`startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI,两者都不会出现在列表中。
|
||||
- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。
|
||||
41
packages/session-registry/session-registry/package.json
Normal file
41
packages/session-registry/session-registry/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
82
packages/session-registry/session-registry/src/index.ts
Normal file
82
packages/session-registry/session-registry/src/index.ts
Normal 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
|
||||
58
packages/session-registry/session-registry/src/invariant.ts
Normal file
58
packages/session-registry/session-registry/src/invariant.ts
Normal 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))
|
||||
55
packages/session-registry/session-registry/src/types.ts
Normal file
55
packages/session-registry/session-registry/src/types.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
21
packages/session-registry/session-registry/tsconfig.json
Normal file
21
packages/session-registry/session-registry/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md
|
||||
README.md: 43666d2d117170f9d7f73737fb1704efc2a55f27
|
||||
README.zh.md: 608b2490f5de7dc7ec4ecd863f41df7f609451ac
|
||||
README.md: a119656ce09863f3025f6f2ec7ffbedfb3bca4ca
|
||||
README.zh.md: 736f34fec83d48b3d783b63e7bab0dec44fe1676
|
||||
|
||||
@@ -51,7 +51,7 @@ defineAcpSnapshotSuite({
|
||||
})
|
||||
```
|
||||
|
||||
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes.
|
||||
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. `prepareCwd` runs after the `workspace/` fixture is copied and before the child boots, for world state a committed fixture cannot carry: git never tracks an entry named `.git`, and `.gitignore` excludes every `worktrees/` directory, so a repository-shaped scenario commits the file bodies under representable names and the hook assembles the real layout from them. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes.
|
||||
|
||||
Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ defineAcpSnapshotSuite({
|
||||
})
|
||||
```
|
||||
|
||||
启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。
|
||||
启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。`prepareCwd` 在复制 `workspace/` fixture 后、子级启动前运行,用于提供已提交 fixture 无法承载的环境状态:Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此仓库形态的场景会以可表示的名称提交文件内容,并由该钩子据此组装真实布局。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。
|
||||
|
||||
每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。
|
||||
|
||||
|
||||
@@ -155,6 +155,13 @@ export interface RunOptions {
|
||||
* start from an empty workspace.
|
||||
*/
|
||||
workspaceDir?: string
|
||||
/**
|
||||
* Optional setup run in the generated cwd after {@link workspaceDir} is
|
||||
* copied and before the child boots — for world state a committed fixture
|
||||
* cannot express, such as a `.git` entry (git never tracks that name, so a
|
||||
* repository-shaped fixture has to be materialized at run time).
|
||||
*/
|
||||
prepareCwd?: (cwd: string) => Promise<void>
|
||||
/**
|
||||
* Parent directory for the generated session cwd. Defaults to
|
||||
* `os.tmpdir()`. A scenario that must distinguish its workspace from the
|
||||
@@ -221,6 +228,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) {
|
||||
await cp(opts.workspaceDir, cwd, { recursive: true })
|
||||
}
|
||||
await opts.prepareCwd?.(cwd)
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...opts.env,
|
||||
DSH_SNAPSHOT: opts.mode,
|
||||
|
||||
@@ -130,6 +130,12 @@ export interface Scenario {
|
||||
* test and the scenario needs an independent project location.
|
||||
*/
|
||||
workspaceParent?: string
|
||||
/**
|
||||
* Setup run in the generated cwd after the `workspace/` fixture is copied and
|
||||
* before the child boots, for world state a committed fixture cannot express
|
||||
* — a `.git` entry, which git never tracks under that name.
|
||||
*/
|
||||
prepareCwd?: (cwd: string) => Promise<void>
|
||||
/**
|
||||
* Whether Windows additionally compares stdout with native separators against
|
||||
* `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still
|
||||
@@ -981,6 +987,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {},
|
||||
...existsSync(workspaceDir) ? { workspaceDir } : {},
|
||||
...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {},
|
||||
...scenario.prepareCwd !== undefined ? { prepareCwd: scenario.prepareCwd } : {},
|
||||
// A scenario booting an overlay tree passes its own live config; the
|
||||
// bin's replay swap derives the sibling `*cordis.snapshot.yml` from it.
|
||||
...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:prepared.marker,seed.txt"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -78,6 +78,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
|
||||
env: { DSH_PERMISSION_MODE: 'never' },
|
||||
configPath: AGENT.configPath,
|
||||
workspaceParent: tmpdir(),
|
||||
prepareCwd: async (cwd) => { await writeFile(join(cwd, 'prepared.marker'), 'prepared\n') },
|
||||
},
|
||||
{ name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' },
|
||||
{ name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' },
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
|
||||
README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad
|
||||
README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06
|
||||
README.md: 2ccf03a2b30a416334e3b8dbe806875019213a31
|
||||
README.zh.md: 15c13adad5064df5e882f55dbef367c146376c6d
|
||||
|
||||
@@ -11,8 +11,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (`prepare` is where a bin provides launcher-owned context slots a mounted app reads, such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
|
||||
| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(`prepare` 正是 bin 提供由启动器拥有、供已挂载应用读取的上下文插槽之处,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
|
||||
@@ -156,17 +156,6 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
|
||||
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
|
||||
* makes `id` readable as the bare identifier `resumeSessionId` in a config
|
||||
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
|
||||
* so resuming a session needs no environment variable. A bin that never
|
||||
* provides it leaves the identifier undeclared, so configs read it defensively
|
||||
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
|
||||
*/
|
||||
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath` and return only after the whole
|
||||
* tree settles. Entry names load through the Loader's internal module loader
|
||||
|
||||
Reference in New Issue
Block a user