Merge remote-tracking branch 'origin/master' into codex/sandbox-policy-context

This commit is contained in:
NI0317
2026-07-30 22:16:50 +08:00
147 changed files with 5805 additions and 358 deletions

View File

@@ -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/README.md
README.md: 3f467641bbc9eae14a94aa2d3bff0402116a9d3f
README.zh.md: c9d11bbf6239b4239a4e037dac63b05d3a9a58f7
README.md: 11179cf6676d1b4382816e34285529b51152fe8d
README.zh.md: 100d918287613973604b2f85060572b8ee41d132

View File

@@ -39,6 +39,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |

View File

@@ -39,6 +39,7 @@
| [`session-projection/`](session-projection/README.md) | 投影 seam领域折叠单元供给全量值 | 产品:稳定表面 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |

View File

@@ -756,6 +756,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'settings',
summary: 'Abstract settings service.',
methods: [
{
signature: 'register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>',
jsDoc: '/**\n * Register a namespace schema and receive its owner scope. The registration\n * is an effect on the calling plugin\'s fiber: disposing that fiber removes\n * the namespace and its observers. An invalid stored section fails the\n * registration itself — the earliest point where the schema can judge it.\n * @param ns - unique namespace; duplicate registration fails loud.\n * @param schema - schemastery schema resolving this namespace\'s value.\n * @param options - composition `base` layer and effect timing.\n * @returns the owner scope for reads, observation, and updates.\n */',
},
{
signature: 'describe(): SettingsDescriptor[]',
jsDoc: '/**\n * Describe every registered namespace for configuration surfaces.\n * @returns one descriptor per registered namespace, in registration order.\n */',
},
{
signature: 'get(ns: SettingsNamespace): unknown',
jsDoc: '/**\n * Read one registered namespace\'s resolved value.\n * @param ns - the namespace to read.\n * @returns the resolved value, or `undefined` while unregistered.\n */',
},
{
signature: 'async update(ns: SettingsNamespace, patch: object): Promise<void>',
jsDoc: '/**\n * Merge a patch into one registered namespace\'s user layer, validate the\n * resolved candidate, persist through the provider, then commit and emit.\n * A validation failure rejects before anything is persisted. Writes to one\n * namespace are serialized: concurrent updates apply in call order, each\n * merging over the previous write\'s committed section.\n * @param ns - the registered namespace to update.\n * @param patch - plain-object patch over the user section.\n */',
},
{
signature: 'async replace(ns: SettingsNamespace, section: object): Promise<void>',
jsDoc: '/**\n * Replace one registered namespace\'s user section wholesale, validate,\n * persist, then commit and emit. Keys absent from `section` fall back to the\n * composition `base` and schema defaults — this is the removal/reset path a\n * merge-only patch cannot express (`replace({})` re-inherits everything).\n * @param ns - the registered namespace to replace.\n * @param section - the complete next user section.\n */',
},
],
},
{
key: 'skills',
summary: 'Registry of skill providers.',
@@ -1327,6 +1353,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */',
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
},
{
name: 'settings/updated',
mode: 'emit',
signature: '\'settings/updated\'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void',
jsDoc: '/**\n * Committed change to one registered namespace\'s resolved value. Emitted\n * after the provider persisted (for `update`) or published (`provider`)\n * the change; never emitted when the resolved value is deep-equal.\n * Listener failures are contained and logged — a sync throw and an async\n * rejection alike — except `INVARIANT`-coded failures, which rethrow\n * after every listener ran; that rethrow reaches the emitter only from\n * synchronous listeners, so invariant checks on this event must not be\n * async functions.\n * @param ns - the namespace whose resolved value changed.\n * @param next - the new resolved value.\n * @param prev - the previous resolved value.\n * @param source - whether the change entered through `update()` or the provider.\n * @mode emit\n */',
summary: 'Committed change to one registered namespace\'s resolved value.',
},
{
name: 'skills/change',
mode: 'emit',
@@ -2391,6 +2424,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionTitleUserMessage',
declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}',
},
{
name: 'SettingsApplies',
declaration: 'export type SettingsApplies = \'live\' | \'restart\';',
},
{
name: 'SettingsDescriptor',
declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n applies: SettingsApplies;\n}',
},
{
name: 'SettingsNamespace',
declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;',
},
{
name: 'SettingsRegisterOptions',
declaration: 'export interface SettingsRegisterOptions<T> {\n base?: Partial<T>;\n applies?: SettingsApplies;\n}',
},
{
name: 'SettingsScope',
declaration: 'export interface SettingsScope<T> {\n get(): T;\n watch(callback: (next: T, prev: T) => void | Promise<void>): () => void;\n update(patch: object): Promise<void>;\n replace(section: object): Promise<void>;\n}',
},
{
name: 'SkillCandidate',
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',

View File

@@ -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/core/session/README.md
README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412
README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989
README.md: 9fa6cf5251d480be9d2388bdb32393fa2827168c
README.zh.md: 5170d5d4b362a0f19ff6953e1636adef9aa7e8b8

View File

@@ -60,6 +60,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
- `isAppendSurfaceEvent(event)` / `isReplacementSurfaceEvent(event)` — split a formed surface event by marker variant. Append-origin events are the durable source for a human transcript, which is not the model-visible surface: a landed replacement shadows the range it summarizes, so projecting a transcript from `session.surface` erases conversation the reader already saw. Consumers that must send exactly what the model sees keep reading `session.surface`.
### Request-header reconstruction (`request-header.ts`)

View File

@@ -60,6 +60,7 @@
- `SessionSurface`:实时只读 `nodes``replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍由 `Session` 私有。
- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。
- `isSurfaceEvent(event)``isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。
- `isAppendSurfaceEvent(event)``isReplacementSurfaceEvent(event)`:按标记变体拆分形态完整的 surface 事件。追加来源的事件是人类可读记录transcript的持久来源而该记录并非模型可见的 surface已落地的替换会遮蔽它所概括的范围因此从 `session.surface` 投影记录会抹掉读者已经看到的对话。必须准确发送模型所见内容的消费方仍继续读取 `session.surface`
### 请求头重建(`request-header.ts`

View File

@@ -27,7 +27,7 @@ export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOM
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
@@ -480,7 +480,8 @@ export class Session {
* the ordered surface; `sourceEventSeqs` records provenance (the seq
* numbers of events this one derives from). REQUIRED for
* {@link SurfaceEventType} events (every message-producing event must
* declare how it joins the surface, the sole source of derived history) and
* declare how it joins the surface, the sole source of derived model
* history) and
* rejected by the compiler for non-surface types like `turn/start` or
* `assistant/chunk`.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of

View File

@@ -37,6 +37,36 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined
}
/**
* Narrow an event to an append-origin surface event: one that entered the
* surface at its own log position and was never itself a replacement copy.
*
* The model-visible surface deliberately shadows replaced ranges, so it is the
* wrong source for a human transcript — a landed replacement would erase
* conversation the user already saw. Append-origin events are that transcript's
* durable source material; replacement copies stay model-only.
* @param event - event to test.
* @returns true when the event appended to the surface tail.
*/
export function isAppendSurfaceEvent(
event: SessionEvent,
): event is SurfaceEvent & { surfaceOp: 'append' } {
return isSurfaceEvent(event) && event.surfaceOp === 'append'
}
/**
* Narrow an event to a surface replacement: a node that shadowed an existing
* surface range instead of appending to the tail. The counterpart of
* {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
* @param event - event to test.
* @returns true when the event replaced a surface range.
*/
export function isReplacementSurfaceEvent(
event: SessionEvent,
): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
return isSurfaceEvent(event) && event.surfaceOp !== 'append'
}
/** One replacement operation observed while folding a session surface. */
export interface SurfaceFoldReplacement {
/** Seq of the event that replaced the prior surface range. */

View File

@@ -4,6 +4,8 @@ import {
Session,
SessionId,
foldSurface,
isAppendSurfaceEvent,
isReplacementSurfaceEvent,
isSurfaceEligibleType,
isSurfaceEvent,
} from '@deepseek-ai/dsh-session'
@@ -861,6 +863,40 @@ describe('surface type guards', () => {
expect(isSurfaceEligibleType(markerless.type)).toBe(true)
expect(isSurfaceEvent(markerless)).toBe(false)
})
it('splits surface events into append-origin and replacement by their marker', () => {
const s = surfaceSession()
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' },
}), { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
const appended = s.events.find(e => e.type === 'user/message')!
const replacement = s.events.at(-1)!
expect(isAppendSurfaceEvent(appended)).toBe(true)
expect(isReplacementSurfaceEvent(appended)).toBe(false)
expect(isAppendSurfaceEvent(replacement)).toBe(false)
expect(isReplacementSurfaceEvent(replacement)).toBe(true)
})
it('rejects log-only and markerless events from both marker guards', () => {
const s = surfaceSession()
const turnStart = s.events.find(e => e.type === 'turn/start')!
// A surface-eligible type whose mandatory marker is absent has no origin at
// all: it never entered the surface.
const markerless: SessionEvent = {
type: 'user/message',
seq: 0,
time: 0,
data: createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}),
}
expect(isAppendSurfaceEvent(turnStart)).toBe(false)
expect(isReplacementSurfaceEvent(turnStart)).toBe(false)
expect(isAppendSurfaceEvent(markerless)).toBe(false)
expect(isReplacementSurfaceEvent(markerless)).toBe(false)
})
})
describe('SurfaceManager.replaceGeneration', () => {

View File

@@ -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/host/README.md
README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982
README.zh.md: 46516d187321029ed739d8c071f246bc1755b125
README.md: 391adb7009a01d1ec95c8dcb809e8a2065aa0b31
README.zh.md: 7fc730ed9ec3a067589b277733eb5bb2c42f8b4e

View File

@@ -11,5 +11,6 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
| `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` |
| `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) |
| `directory-picker-browse/` | Dual-face browse interaction: listing/creation primitives over Node stdlib (remote-capable) + the browser half rendering the in-app Select Workspace Directory dialog | (registers `ctx.directoryPicker`) |
| `directory-picker-auto/` | Adaptive chooser: resolves the host's situation once at boot (bind host, SSH, display) and mounts the matching dual-face backend as an in-memory Loader entry | (mounts a backend row) |
`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire.

View File

@@ -11,5 +11,6 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承
| `directory-picker/` | 工作区目录选择 seam网关的 picker RPC 委托的可辨识 `native``browse` 能力 | `ctx.directoryPicker` |
| `directory-picker-native/` | 双面原生交互OS 选择器后端osascriptPowerShellZenity+KDialog仅宿主屏幕可用+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker` |
| `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker` |
| `directory-picker-auto/` | 自适应选择器启动时一次性判定宿主处境绑定宿主、SSH、显示并把匹配的双面后端挂载为内存中的 Loader 条目 | (挂载一个后端行) |
`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。

View File

@@ -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/host/apiproxy/README.md
README.md: 0a3d29e41dd0e203c2f576192ea7e88ecb904fac
README.zh.md: b91f4e697ff04eedaaa2fc093229f1c459a1655a
README.md: 8f9deb6add7d30bf1609cc7febcb1febafe392c1
README.zh.md: 399d45208b6d3f4152c27556523b6944432bec66

View File

@@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
`session.history` pages on append-origin message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.

View File

@@ -10,6 +10,8 @@
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message``steering/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志溯源信息与引用它的替换留在同一页。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`

View File

@@ -14,7 +14,7 @@ import type {
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { lastActivityTime } from '@deepseek-ai/dsh-session'
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
@@ -60,14 +60,18 @@ import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/** Surface message event types (the pagination counting unit). */
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
/**
* Message-boundary pagination: count maxMessages surface messages backwards from
* the window tail; the cut is the starting seq of the oldest message group
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
* naturally includes the in-progress partial.
* Message-boundary pagination: count maxMessages append-origin messages
* backwards from the window tail. Replacement copies never entered the
* conversation a reader sees — they restate a shadowed range for the model
* alone — so they consume no quota; the page stays one contiguous raw range,
* which keeps a compaction's log-only provenance on the same page as its
* replacement. The cut is the starting seq of the oldest message group (chunks
* group via sourceEventSeqs — never cut mid-message). The tail page naturally
* includes the in-progress partial.
*/
function paginate(
events: readonly SessionEvent[],
@@ -79,7 +83,7 @@ function paginate(
let cut = 0
for (let i = window.length - 1; i >= 0; i--) {
const event = window[i] as SessionEvent
if (!MESSAGE_TYPES.has(event.type)) continue
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
count++
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq

View File

@@ -186,9 +186,11 @@ export interface SessionsApi {
Promise<RpcResponse<{ sessionId: SessionId }>>
/**
* Reads a window of history events; page boundaries align to message boundaries: one page =
* all raw events owned by a whole number of messages (including their chunk / tool events),
* never cut mid-message. The tail page (beforeSeq absent) additionally carries the in-flight
* Reads a window of history events; page boundaries align to append-origin message
* boundaries: one page = all raw events owned by a whole number of such messages (including
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
* page (beforeSeq absent) additionally carries the in-flight
* partial — chunk events already emitted for the last unfinalized message.
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
* presenter produced one, evaluated against the registry at pagination time); the client

View File

@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
@@ -35,6 +35,35 @@ function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'pr
})
}
/** Append a production-shaped human prompt to the session surface. */
function appendUserText(session: Session, text: string): SessionEvent {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
/** Append a production-shaped assistant message to the session surface. */
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
return session.append('assistant/message', {
turn: 1,
step,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append' })
}
/**
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
* declares no compaction vocabulary; the cast writes the real event shape without
* depending on the owning package.
*/
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
}
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -207,6 +236,55 @@ describe('mux live view computation', () => {
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = appendUserText(session, 'first prompt')
appendAssistantText(session, 'first reply', 1)
const third = appendUserText(session, 'second prompt')
appendAssistantText(session, 'second reply', 2)
const shadowed = [...session.surface.nodes]
// A compaction transaction: log-only provenance immediately followed by the
// replacement that shadows the range.
const summary = appendExtension(session, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
shadowedSeqs: shadowed,
shadowedTokenCount: 0,
provider: 'p',
model: 'm',
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
sourceEventSeqs: [...shadowed, summary.seq],
})
const response = await api.sessions.history({
rpcId: RpcId('t-hist-compact'),
payload: { sessionId: session.id, maxMessages: 2 },
})
if (!response.result.ok) throw new Error('unreachable')
const page = response.result.value.events.map(entry => entry.event)
// Two append-origin messages fill the page even though a replacement copy of
// the same event type sits in the window: the copy is model-only.
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
expect(page.some(event => event.seq === first.seq)).toBe(false)
expect(response.result.value.hasMore).toBe(true)
// The range stays contiguous, so the checkpoint's provenance is readable on
// the same page as the checkpoint itself.
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
expect(summaryIndex).toBeGreaterThan(-1)
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
})
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })

View File

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

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-host-directory-picker-auto
English | [中文](README.zh.md)
The **adaptive chooser** of the [directory-picker seam](../directory-picker/README.md): a node-half-only plugin that resolves the host's situation once at boot and mounts the matching dual-face backend — [`-native`](../directory-picker-native/README.md) or [`-browse`](../directory-picker-browse/README.md) — as a real Loader entry in the in-memory root tree (never persisted to a config file; the root tree's `write()` is a no-op). Because the backend arrives as an ordinary entry, its browser half is discovered by the client module table exactly as a config-row's would be, so the seam's one-row-swaps-both-faces invariant holds for the resolved choice. Unloading the chooser removes the entry again, unloading both faces with it.
Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse and tests. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes).
## Model Experience
None, as the chooser only composes the GUI host's directory selection; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments.
- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot.
- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once.

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-host-directory-picker-auto
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**自适应选择器**:一个只有 node 半侧的插件,在启动时一次性判定宿主处境,并把匹配的双面后端——[`-native`](../directory-picker-native/README.md) 或 [`-browse`](../directory-picker-browse/README.md)——作为真实的 Loader 条目挂进内存根树(绝不持久化到配置文件;根树的 `write()` 是 no-op。由于后端以普通条目的形式到达其 browser half 被 client 模块表发现的方式与配置行完全相同因此对判定出的选择seam 的“一行同时换两面”不变式依然成立。卸载该选择器会再次移除该条目,连同两面一起卸载。
判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用与测试。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION``SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上以及可服务的显示会话——darwinwin32 上视为存在linux 上要求 `DISPLAY``WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwinwin32linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native``-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会大声失败(重复的 `directoryPicker` 服务、`single` 洞中的重复 client 流程)。
## 模型体验
无。该选择器仅组合 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
#### KV 缓存影响
无;该包既不组装也不发送提供方请求。
## 已知限制与延期工作
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenitykdialogshell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。

View File

@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker-auto",
"description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host",
"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": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-host-directory-picker-browse": "^0.0.1",
"@deepseek-ai/dsh-host-directory-picker-native": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,71 @@
/**
* Adaptive chooser of the directory-picker seam: resolves the host's
* situation once at boot (bind host, SSH launch, display session, Linux
* chooser binary) and mounts the matching dual-face backend — `-native` or
* `-browse` — as a real Loader entry in the in-memory root tree. Because the
* backend arrives as an ordinary entry, its browser half is discovered
* exactly as a config-row's would be, so the seam's one-row-swaps-both-faces
* invariant holds for the resolved choice; pinning an interaction remains
* composing that backend row directly instead of this one.
* @module @deepseek-ai/dsh-host-directory-picker-auto
*/
import type { Context } from 'cordis'
// Empty type imports carry the `loader` and `httpServer` Context merges for the reads below.
import type {} from '@cordisjs/plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
import type { DirectoryPickerBackendKind } from './resolve.ts'
import { resolveDirectoryPickerBackend } from './resolve.ts'
export { canExecute, hasLinuxChooserBinary } from './probe.ts'
export type { DirectoryPickerBackendKind, DirectoryPickerEnv, DirectoryPickerHostFacts } from './resolve.ts'
export { resolveDirectoryPickerBackend } from './resolve.ts'
/** Cordis plugin name. */
export const name = 'directory-picker-auto'
/** Required services: the effective bind host (`httpServer`) and the entry tree the backend mounts into (`loader`). */
export const inject = ['httpServer', 'loader']
/**
* Backend package per resolved kind — fixed composition vocabulary, not a
* tunable. Exported because the reference is a runtime string the static
* config gate cannot see in a yml row: `verify-cordis-config` requires every
* app composing this chooser to declare both values as dependencies.
*/
export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
native: '@deepseek-ai/dsh-host-directory-picker-native',
browse: '@deepseek-ai/dsh-host-directory-picker-browse',
}
/**
* Resolve the backend from one boot-time sample and mount it as a Loader
* entry; the effect's disposer removes the entry and joins the backend
* fiber's teardown, so unloading this plugin returns only after both faces
* of the mounted backend (and their dependents) quiesced.
* @param ctx - cordis context carrying the injected `httpServer` and `loader`.
*/
export async function apply(ctx: Context): Promise<void> {
const backend = resolveDirectoryPickerBackend({
bindHost: ctx.httpServer.host,
platform: process.platform,
env: process.env,
linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute),
})
await ctx.effect(async () => {
// Root-tree create: the Loader root is in-memory (write() is a no-op), so
// the mounted row can never be persisted back into a config file.
const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] })
return async () => {
// Tree teardown (group.stop) can have removed the entry already;
// nothing is left to unmount or await then.
const entry = ctx.loader.store[id]
if (entry === undefined) return
const fiber = entry.fiber
ctx.loader.remove(id)
// remove() only starts the fiber's dispose; join it so the chooser's
// unload signals completion only after the backend quiesced.
await fiber?.dispose()
}
}, 'directory-picker-auto: backend entry')
}

View File

@@ -0,0 +1,25 @@
/**
* Package-owned invariant companion for the adaptive directory-picker chooser.
* @module @deepseek-ai/dsh-host-directory-picker-auto/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-auto'
/** Cordis companion plugin name. */
export const name = 'host-directory-picker-auto-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: the sole effect is one boot-time Loader-entry mount owned by the plugin fiber; the store is authoritative. */
const install: InvariantInstaller = () => {}
/**
* Register the adaptive directory-picker invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,44 @@
/**
* PATH probe for the native backend's Linux chooser binaries: one boot-time
* sampled fact for the resolver, so an attended Linux host without
* zenity/kdialog keeps the working `browse` interaction instead of a backend
* whose every pick fails.
* @module @deepseek-ai/dsh-host-directory-picker-auto/probe
*/
import { accessSync, constants } from 'node:fs'
import { delimiter, join } from 'node:path'
/** The chooser binaries the native backend can drive on Linux (zenity, KDialog fallback). */
const LINUX_CHOOSER_BINARIES = ['zenity', 'kdialog'] as const
/**
* Whether the current process may execute the candidate path.
* @param candidate - absolute or PATH-joined file path.
* @returns true only for an existing executable file.
*/
export function canExecute(candidate: string): boolean {
try {
accessSync(candidate, constants.X_OK)
} catch {
// Absent or non-executable candidate — the only signals accessSync(X_OK) emits.
return false
}
return true
}
/**
* Scan a PATH value for one of the native backend's Linux chooser binaries.
* @param pathValue - the `PATH` environment value (absent or empty scans nothing).
* @param isExecutable - executability predicate ({@link canExecute} in production; injected for deterministic tests).
* @returns whether any PATH directory holds an executable chooser binary.
*/
export function hasLinuxChooserBinary(pathValue: string | undefined, isExecutable: (candidate: string) => boolean): boolean {
for (const dir of (pathValue ?? '').split(delimiter)) {
if (dir === '') continue
for (const name of LINUX_CHOOSER_BINARIES) {
if (isExecutable(join(dir, name))) return true
}
}
return false
}

View File

@@ -0,0 +1,53 @@
/**
* Boot-time backend resolution for the adaptive directory-picker composition:
* one pure decision from sampled host facts to a concrete backend kind. The
* caller samples exactly once per boot, so the mounted capability stays
* stable for the service lifetime as the seam requires.
* @module @deepseek-ai/dsh-host-directory-picker-auto/resolve
*/
import type { Config as HttpServerConfig } from '@deepseek-ai/dsh-host-webserver'
/** Concrete interaction backend the resolver chooses between. */
export type DirectoryPickerBackendKind = 'native' | 'browse'
/** Environment keys the resolution reads (a `process.env` subset). */
export type DirectoryPickerEnv = Readonly<
Partial<Record<'SSH_CONNECTION' | 'SSH_TTY' | 'DISPLAY' | 'WAYLAND_DISPLAY', string>>
>
/** Host facts the backend choice is a pure function of, sampled once at boot. */
export interface DirectoryPickerHostFacts {
/** Effective webserver bind host (the schema's closed loopback/all-interfaces union). */
bindHost: HttpServerConfig['host']
/** Host process platform. */
platform: NodeJS.Platform
/** Environment sample; SSH marks a remote operator, DISPLAY/WAYLAND_DISPLAY a Linux display. */
env: DirectoryPickerEnv
/** Whether a Linux chooser binary the native backend can drive (zenity/kdialog) is on PATH; consulted only when `platform` is linux. */
linuxChooser: boolean
}
/** An env value counts only when set and non-blank (an empty export is "unset" by shell convention). */
const present = (value: string | undefined): boolean => value !== undefined && value !== ''
/**
* Resolve which backend serves this boot. `native` requires every signal that
* the operator can see the host display and the native backend can serve it:
* a loopback-only bind (an all-interfaces bind admits remote browsers no OS
* chooser can reach), no SSH launch (under SSH port-forwarding the chooser
* would open on the unattended server), and a servable display session —
* assumed on darwin/win32, requiring `DISPLAY`/`WAYLAND_DISPLAY` plus a
* chooser binary on linux, and never true elsewhere (the native backend
* drives exactly darwin/win32/linux). Anything ambiguous resolves to
* `browse`, which works everywhere.
* @param facts - the sampled host facts.
* @returns the backend kind to mount.
*/
export function resolveDirectoryPickerBackend(facts: DirectoryPickerHostFacts): DirectoryPickerBackendKind {
if (facts.bindHost !== '127.0.0.1') return 'browse'
if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return 'browse'
if (facts.platform === 'darwin' || facts.platform === 'win32') return 'native'
if (facts.platform !== 'linux' || !facts.linuxChooser) return 'browse'
return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? 'native' : 'browse'
}

View File

@@ -0,0 +1,176 @@
/**
* REAL-composition coverage: a test-only cordis.yml booted through the
* vendored Loader mounts the webserver row plus the adaptive chooser, and the
* assertions observe the durable outcome — which backend entry the chooser
* mounted into the Loader store, the capability the seam then serves, and
* that disposing the chooser removes the mounted entry again (HMR safety),
* joining the backend's own teardown before the disposer settles.
*/
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import HttpServer from '@deepseek-ai/dsh-host-webserver'
import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse'
import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
import * as DirectoryPickerAuto from '../src/index.ts'
const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
let root: string | undefined
let fakeBin: string | undefined
let context: Context | undefined
afterEach(async () => {
vi.unstubAllEnvs()
await context?.fiber.dispose()
context = undefined
for (const dir of [root, fakeBin]) {
// maxRetries absorbs teardown stragglers (e.g. an unawaited fiber's late
// file handle) that can otherwise race the recursive scan into ENOTEMPTY.
if (dir !== undefined) await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
}
root = undefined
fakeBin = undefined
})
/** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
const dist = join(root, 'dist')
mkdirSync(dist)
const distIndex = join(dist, 'index.html')
await writeFile(distIndex, '<head></head><body>shell</body>')
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
` host: '${bindHost}'`,
' port: 0',
` distIndex: '${distIndex}'`,
`- name: '${AUTO}'`,
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-host-webserver', HttpServer],
[AUTO, DirectoryPickerAuto],
[NATIVE, NativeDirectoryPicker],
[BROWSE, BrowseDirectoryPicker],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
return { ctx: context, configPath }
}
/** Entry names currently present in the loader store (root tree plus subtrees). */
function entryNames(ctx: Context): string[] {
return [...ctx.loader.entries()].map(entry => entry.options.name)
}
/**
* Force every signal of an attended host on any platform: no SSH launch, a
* display, and a PATH holding one executable chooser binary so the real
* probe resolves identically on hosts with and without zenity/kdialog.
*/
function stubAttendedHost(): void {
fakeBin = mkdtempSync(join(tmpdir(), 'dsh-picker-bin-'))
const zenity = join(fakeBin, 'zenity')
writeFileSync(zenity, '#!/bin/sh\n')
chmodSync(zenity, 0o755)
vi.stubEnv('PATH', fakeBin)
vi.stubEnv('SSH_CONNECTION', '')
vi.stubEnv('SSH_TTY', '')
vi.stubEnv('DISPLAY', ':0')
}
describe('real Loader composition', () => {
// The 60s budget covers this file's static imports (webserver plus both
// backend node halves through tsx), which dominate on cold caches; the
// Loader itself resolves nothing here — `loader.internal` is a module map.
it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => {
stubAttendedHost()
const { ctx, configPath } = await loadComposition('127.0.0.1')
const unloaded = [...ctx.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(entryNames(ctx)).toContain(NATIVE)
expect(entryNames(ctx)).not.toContain(BROWSE)
const picker = ctx.get('directoryPicker') as DirectoryPicker
expect(picker.capability().kind).toBe('native')
// The mounted row lives in the Loader's in-memory root tree only — the
// booted config file must never gain the resolved backend row.
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
// HMR safety: disposing the chooser's fiber removes the entry it created,
// and the disposer joins the backend's teardown — the service is gone the
// moment dispose() settles, with no further loader await.
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
await autoEntry.fiber!.dispose()
expect(entryNames(ctx)).not.toContain(NATIVE)
expect(ctx.get('directoryPicker')).toBeUndefined()
// Self-disposing an include-tree entry persists `disabled: true` (loader
// behavior, not the chooser's); await that debounced write so it cannot
// race the temp-dir removal, and pin that the persisted row is the
// chooser itself — the resolved backend still never reaches the file.
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
})
it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => {
stubAttendedHost()
vi.stubEnv('SSH_CONNECTION', '10.0.0.2 55 10.0.0.9 22')
const { ctx } = await loadComposition('127.0.0.1')
expect(entryNames(ctx)).toContain(BROWSE)
expect(entryNames(ctx)).not.toContain(NATIVE)
const picker = ctx.get('directoryPicker') as DirectoryPicker
expect(picker.capability().kind).toBe('browse')
})
it('mounts the browse backend for an all-interfaces bind even on an attended host', { timeout: 60_000 }, async () => {
stubAttendedHost()
const { ctx } = await loadComposition('0.0.0.0')
expect(entryNames(ctx)).toContain(BROWSE)
expect(entryNames(ctx)).not.toContain(NATIVE)
})
it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
stubAttendedHost()
const { ctx, configPath } = await loadComposition('127.0.0.1')
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
ctx.loader.remove(backendEntry.id)
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
expect(entryNames(ctx)).not.toContain(NATIVE)
// Same self-dispose persistence as above: let the write land before teardown.
await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
})
})

View File

@@ -0,0 +1,91 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { canExecute, hasLinuxChooserBinary } from '../src/probe.ts'
import { resolveDirectoryPickerBackend } from '../src/resolve.ts'
import type { DirectoryPickerHostFacts } from '../src/resolve.ts'
/** Baseline facts that resolve to `native`; each case overrides one signal (darwin never consults `linuxChooser`). */
const attended: DirectoryPickerHostFacts = {
bindHost: '127.0.0.1',
platform: 'darwin',
env: {},
linuxChooser: false,
}
describe('resolveDirectoryPickerBackend', () => {
it('resolves native for a loopback bind on a display platform', () => {
expect(resolveDirectoryPickerBackend(attended)).toBe('native')
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'win32' })).toBe('native')
})
it('resolves browse for an all-interfaces bind regardless of other signals', () => {
expect(resolveDirectoryPickerBackend({ ...attended, bindHost: '0.0.0.0' })).toBe('browse')
})
it('resolves browse under an SSH launch (either env marker)', () => {
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '10.0.0.2 55 10.0.0.9 22' } })).toBe('browse')
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_TTY: '/dev/pts/3' } })).toBe('browse')
})
it('requires a display session and a chooser binary on linux', () => {
const linux: DirectoryPickerHostFacts = { ...attended, platform: 'linux', linuxChooser: true }
expect(resolveDirectoryPickerBackend(linux)).toBe('browse')
expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' } })).toBe('native')
expect(resolveDirectoryPickerBackend({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('native')
expect(resolveDirectoryPickerBackend({ ...linux, env: { DISPLAY: ':0' }, linuxChooser: false })).toBe('browse')
})
it('resolves browse on platforms the native backend cannot serve, display or not', () => {
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'freebsd', env: { DISPLAY: ':0' }, linuxChooser: true })).toBe('browse')
expect(resolveDirectoryPickerBackend({ ...attended, platform: 'openbsd', env: { WAYLAND_DISPLAY: 'wayland-1' } })).toBe('browse')
})
it('treats blank env exports as unset', () => {
expect(resolveDirectoryPickerBackend({ ...attended, env: { SSH_CONNECTION: '', SSH_TTY: '' } })).toBe('native')
expect(resolveDirectoryPickerBackend({
...attended, platform: 'linux', linuxChooser: true, env: { DISPLAY: '', WAYLAND_DISPLAY: '' },
})).toBe('browse')
})
})
let probeRoot: string | undefined
afterEach(() => {
if (probeRoot !== undefined) rmSync(probeRoot, { recursive: true, force: true })
probeRoot = undefined
})
describe('hasLinuxChooserBinary', () => {
it('finds a chooser binary in any PATH segment, skipping empty segments', () => {
const seen: string[] = []
const path = ['', '/opt/none', '/usr/local/bin'].join(delimiter)
const found = hasLinuxChooserBinary(path, (candidate) => {
seen.push(candidate)
return candidate === join('/usr/local/bin', 'kdialog')
})
expect(found).toBe(true)
expect(seen).toEqual([
join('/opt/none', 'zenity'), join('/opt/none', 'kdialog'),
join('/usr/local/bin', 'zenity'), join('/usr/local/bin', 'kdialog'),
])
})
it('reports absence when no segment holds a chooser binary', () => {
expect(hasLinuxChooserBinary(['/a', '/b'].join(delimiter), () => false)).toBe(false)
expect(hasLinuxChooserBinary('', () => true)).toBe(false)
expect(hasLinuxChooserBinary(undefined, () => true)).toBe(false)
})
})
describe('canExecute', () => {
it('accepts an executable file and rejects an absent one', () => {
probeRoot = mkdtempSync(join(tmpdir(), 'dsh-picker-probe-'))
const binary = join(probeRoot, 'zenity')
writeFileSync(binary, '#!/bin/sh\n')
chmodSync(binary, 0o755)
expect(canExecute(binary)).toBe(true)
expect(canExecute(join(probeRoot, 'kdialog'))).toBe(false)
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../webserver"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -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/host/directory-picker-browse/README.md
README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7
README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f

View File

@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
## Model Experience

View File

@@ -6,7 +6,7 @@
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/``C:\``list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo``/foo`)与不完整的 UNC 前缀(`\\``\\server`)——报 `directory-unreadable``directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
**双面包**browser half`./client`)以应用内 **选择工作区目录** 对话框figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩父层级这一程失败或被截断时保持单栏落地展示根保持单个宽层级带点击即编辑路径区的面包屑其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤不区分大小写且仅作用于已列出、可能被截断的行Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory``host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`zh 默认en。因此一行 cordis.yml 同时组合浏览交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
**双面包**browser half`./client`)以应用内 **选择工作区目录** 对话框figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地展示根保持单个宽层级带点击即编辑路径区的面包屑其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤不区分大小写且仅作用于已列出、可能被截断的行Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory``host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`zh 默认en。因此一行 cordis.yml 同时组合浏览交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
## 模型体验

View File

@@ -13,6 +13,12 @@
height: min(500px, calc(100dvh - 32px));
padding: 0;
gap: 0;
/* The Modal card is an l2 surface and the columns below scroll on it:
* rebind the scrollbar indirection to the elevation pair here, on the
* surface, so it inherits down to whichever descendant scrolls (the
* rebinding contract in ui-theme styles/scrollbar.css). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
@@ -146,6 +152,8 @@
flex-direction: column;
flex: 1 1 0;
min-height: 0;
/* Anchors the floating loading pill (.loadingFloat). */
position: relative;
/* Right inset is slimmer than the left: the trailing column's own 8px
* scrollbar clearance makes up the optical difference. */
padding: 16px 16px 16px 24px;
@@ -234,6 +242,10 @@
.status,
.error {
padding: 4px;
/* The loading pill occupies the opposite corner while a stale status stays
* visible. Reserve its widest localized footprint so wrapped text cannot
* run underneath it on a narrow card. */
padding-right: 120px;
font-size: 12px;
line-height: 18px;
}
@@ -246,6 +258,23 @@
color: var(--dsw-alias-state-error-primary);
}
/* The slow-scan indicator floats over the content's bottom-RIGHT corner on
* the card background instead of occupying a row: a scan must never shift
* the columns' height, and the stale view keeps rendering beneath it (it
* only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right,
* not left: the truncated/error status rows flow at the bottom LEFT and
* stay on screen through a scan, with their reserved right padding keeping
* both legible even on a narrow card. After .status in the cascade — the
* element carries both classes and this padding must win the
* same-specificity race. */
.loadingFloat {
position: absolute;
right: 16px;
bottom: 8px;
padding: 2px 8px;
background: var(--dsw-alias-bg-layer-2);
}
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
* centered in the bar; New-folder and the show-hidden toggle pin left. */
.footerBar {

View File

@@ -5,10 +5,12 @@
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
* full-width level until a row is selected, then two columns splitting the
* row evenly (256px floor; level | selected folder's children) around a
* hairline divider. Navigations land selection-anchored: a crumb jump or a
* submitted path commits the target immediately, then re-selects it in its
* parent level once that level arrives, so stepping back keeps two panes
* away from the display root. Selecting in the
* hairline divider. Navigations land selection-anchored and quiet: the
* previous view keeps rendering while a crumb jump or a submitted path is
* scanned, then target and parent legs land as one two-pane frame (a slow
* parent leg falls back to landing the target alone and upgrading in
* place), so stepping back keeps two panes away from the display root and
* navigation never flashes an intermediate frame. Selecting in the
* right column shifts the view one level deeper. "New folder" opens a nested
* create dialog targeting the selected folder (or the level itself) and
* selects the created folder. Open adopts the selected folder, falling back
@@ -55,6 +57,24 @@ function failureText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* How long a scan may stay visually silent before the floating "Loading…"
* pill appears. The stale view keeps rendering while a scan is in flight, so
* a listing that settles inside this window swaps the panes with no
* intermediate frame at all; only a genuinely slow host (a network mount, a
* cold disk) surfaces the indicator.
*/
const SLOW_SCAN_DELAY_MS = 300
/**
* How long a navigation landing waits for its parent leg before committing
* the target alone. Inside the window both legs land as ONE two-pane frame —
* no single-pane flash between them; past it the target commits single-pane
* at once (an Enter-submitted navigation is never held hostage by a stalled
* parent) and the late parent leg upgrades the landing in place.
*/
const PARENT_LEG_WAIT_MS = 200
/**
* Breadcrumb rows for display: inside the home subtree the chain starts at a
* localized Home crumb; outside it the full ancestry shows, the root labeled
@@ -166,6 +186,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
const [child, setChild] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(false)
// Derived from `loading` and `scanWindow` by the slow-scan effect below:
// true only once the current listing call has been in flight for
// SLOW_SCAN_DELAY_MS, so fast listings never render the indicator at all.
const [slowScan, setSlowScan] = useState(false)
// Every listing call owns a fresh silence window. `loading` may stay true
// across a superseding row pick or across a navigation's target and parent
// legs, so its boolean edge cannot identify the start of each scan.
const [scanWindow, setScanWindow] = useState(0)
const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null)
@@ -209,13 +237,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
return ++requestSeq.current
}, [])
/** Hide any prior indicator and start a fresh silence window for one listing call. */
const restartSlowScanWindow = useCallback((): void => {
setSlowScan(false)
setScanWindow(value => value + 1)
}, [])
/** Launch one listing under a fresh controller so a later supersession can abort it. */
const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise<DirectoryListing> } => {
const seq = supersede()
const controller = new AbortController()
scanController.current = controller
restartSlowScanWindow()
return { seq, scan: listDirectory(path, controller.signal) }
}, [supersede, listDirectory])
}, [supersede, restartSlowScanWindow, listDirectory])
/**
* Launch a follow-up listing under the CURRENT supersession seq: a newer
@@ -224,21 +259,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
const controller = new AbortController()
scanController.current = controller
restartSlowScanWindow()
return listDirectory(path, controller.signal)
}, [listDirectory])
}, [restartSlowScanWindow, listDirectory])
/**
* Replace the whole view with a freshly navigated level. The target level
* commits the moment it arrives (single wide level: the editor closes and
* loading ends on this first settlement, so an Enter-submitted navigation
* is never withdrawn waiting on anything further). Away from the display
* root — the same collapse the crumb header renders, so crumbs and pane
* shape never disagree — a parent leg then upgrades the landing in place:
* the target's ACTUAL parent-level entry re-selected (left pane = parent,
* right pane = the target), so a crumb jump reads as stepping back one
* pane. A failed parent leg, or a truncated parent window that lacks the
* target, leaves the committed single-pane landing — the upgrade must
* never orphan the selection it exists to anchor.
* Replace the whole view with a freshly navigated level. Away from the
* display root — the same collapse the crumb header renders, so crumbs and
* pane shape never disagree — the landing is two-pane: the target's ACTUAL
* parent-level entry re-selected (left pane = parent, right pane = the
* target), so a crumb jump reads as stepping back one pane. Both legs land
* as one frame when the parent leg settles within
* {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the
* target commits alone — single wide level, the editor closes, loading
* ends — and a late parent leg still upgrades the landing in place. A
* failed parent leg, or a truncated parent window that lacks the target,
* leaves the single-pane landing — the upgrade must never orphan the
* selection it exists to anchor. Until whichever commit comes first, the
* previous view keeps rendering: navigation swaps the panes, it never
* blanks them.
*/
const navigate = useCallback((path?: string) => {
const { seq, scan } = launchListing(path)
@@ -246,16 +285,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setError(null)
scan.then((target) => {
if (seq !== requestSeq.current) return
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
// The single-pane landing; `landed` makes it first-commit-only, while
// the two-pane commit below may still upgrade an already-landed view.
let landed = false
const landSingle = (): void => {
if (landed || seq !== requestSeq.current) return
landed = true
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
}
// Arity is label-independent: only the collapsed chain's depth decides.
if (displayCrumbs(target, '').length < 2) return
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
const parentCrumb = target.crumbs.at(-2)
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
if (parentCrumb === undefined) return
if (parentCrumb === undefined) { landSingle(); return }
continueScan(parentCrumb.path).then((parentLevel) => {
if (seq !== requestSeq.current) return
// Windows resolves a typed path preserving its case; anchor on the
@@ -263,15 +309,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const sep = separatorOf(parentLevel)
const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value)
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
if (match === undefined) return
if (match === undefined) { landSingle(); return }
landed = true
setParent(parentLevel)
setSelected(match)
setChild(target)
// Idempotent on a late upgrade of a timed-out landing: reopening the
// editor or starting a newer scan supersedes this seq, so reaching
// here means the draft is closed and the loading flag is this
// navigation's own.
setLoading(false)
setPathDraft(null)
}, () => {
// Swallows the parent-leg failure (its abort included): the
// committed single-pane landing stands, and nobody asked to see
// the parent level.
// The parent-leg failure (its abort included) never surfaces: the
// target listed fine, and nobody asked to see the parent level.
landSingle()
})
window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
@@ -289,7 +343,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const pathInputRef = useRef<HTMLInputElement | null>(null)
const editZoneRef = useRef<HTMLButtonElement | null>(null)
/** Select a row of the listed level and preview its children on the right. */
/**
* Select a row of the listed level and preview its children on the right.
* Deliberately NOT one-frame like navigate(): a pick's first duty is the
* immediate selected state on the clicked row, and the pane split IS that
* feedback (aria-current pill, crumbs following the selection) — holding
* it back for the child listing would make clicks feel dropped. The quiet
* rule governs whole-view replacement, where nothing acknowledges the
* click but the swap itself.
*/
const select = useCallback((entry: DirectoryEntry) => {
const { seq, scan } = launchListing(entry.path)
// A pick while the path editor is open adopts the (filtered) row and
@@ -360,6 +422,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
return
}
supersede()
// Closing mid-scan leaves nothing to load: without this edge the
// slow-scan effect keeps arming while hidden and the reopened dialog
// would show the indicator on its first frame instead of waiting out a
// fresh silence window (reopen's navigate() produces no loading edge).
setLoading(false)
setError(null)
setPathDraft(null)
setFolderDraft(null)
@@ -396,6 +463,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// create target becomes the listed level and the new folder its selection.
const { seq, scan } = launchListing(targetPath)
setLoading(true)
// Symmetric with navigate/select: a launched scan clears the stale
// failure text (and keeps the floating indicator's corner the only
// occupant of the content's right edge while it shows).
setError(null)
scan.then((level) => {
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
if (seq !== requestSeq.current) return
@@ -415,6 +486,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
})
}
// The slow-scan gate for the loading indicator: each listing call restarts
// the timer even when a superseding scan or a navigation's parent leg keeps
// `loading` continuously true. A settle inside its own window means the swap
// happened with nothing shown.
useEffect(() => {
if (!loading) {
setSlowScan(false)
return
}
const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS)
return () => { window.clearTimeout(timer) }
}, [loading, scanWindow])
// After the hooks: a closed dialog renders nothing and evaluates no copy.
const crumbSource = child ?? parent
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
@@ -649,11 +733,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
/>
)}
</div>
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{loading && slowScan
&& <div className={clsx(css.status, css.loadingFloat)} role="status">{t('browser.loading')}</div>}
{/* The backend bounds a level at its complete-result limit; say so
* whenever a visible pane was cut instead of letting the tail of a
* huge directory go silently missing. */}
{(parent?.truncated === true || child?.truncated === true) && !loading
* huge directory go silently missing. The note describes the panes
* on screen, so an in-flight scan leaves it alone — hiding it while
* the stale view still shows the cut level would shift the columns
* on every navigation away from it. */}
{(parent?.truncated === true || child?.truncated === true)
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>

View File

@@ -111,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement {
}
describe('DirectoryBrowser', () => {
it('renders nothing and launches no listing while initially closed', () => {
const b = mount({ open: false })
expect(screen.queryByRole('dialog')).toBeNull()
expect(b.listDirectory).not.toHaveBeenCalled()
})
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -235,39 +241,249 @@ describe('DirectoryBrowser', () => {
expect(columns()).toHaveLength(1)
})
it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
const signals: (AbortSignal | undefined)[] = []
const settlers: ((value: DirectoryListing) => void)[] = []
// Only the FIRST explicit HOME request (the parent leg) hangs; the later
// home crumb jump lists normally.
let homeCalls = 0
const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => {
signals.push(signal)
if (path === HOME && ++homeCalls === 1) {
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
}
return listingFor(path)
it('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
vi.useFakeTimers()
try {
const signals: (AbortSignal | undefined)[] = []
const settlers: ((value: DirectoryListing) => void)[] = []
// Only the FIRST explicit HOME request (the parent leg) hangs; the
// later home crumb jump lists normally.
let homeCalls = 0
const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => {
signals.push(signal)
if (path === HOME && ++homeCalls === 1) {
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
}
return Promise.resolve(listingFor(path))
})
mount({ listDirectory })
await act(async () => {})
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target settled but the parent leg hangs: inside the wait bound
// nothing commits yet.
await act(async () => {})
expect(settlers).toHaveLength(1)
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
// The wait bound expires: the target commits alone — editor closed,
// single-pane DOCS level.
await act(async () => { vi.advanceTimersByTime(200) })
expect(screen.getByRole('listitem').textContent).toBe('harness')
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(columns()).toHaveLength(1)
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
// dropping its settlement.
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
expect(signals[2]?.aborted).toBe(true)
await act(async () => {})
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Its late resolution changes nothing either.
await act(async () => { settlers[0]!(listingFor(HOME)) })
expect(columns()).toHaveLength(1)
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
} finally {
vi.useRealTimers()
}
})
/**
* Listing fake whose explicit-path scans stay pending until the test
* settles them by path; the absent-path form (the initial home listing)
* resolves normally so mounting is a one-flush setup.
*/
function manualLister() {
const settlers = new Map<string, (value: DirectoryListing) => void>()
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
if (path === undefined) return Promise.resolve(listingFor(path))
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target leg commits at once: editor closed, single-pane DOCS level,
// while the parent leg (upgrade) is still in flight.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(columns()).toHaveLength(1)
await waitFor(() => { expect(settlers).toHaveLength(1) })
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
// dropping its settlement.
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
expect(signals[2]?.aborted).toBe(true)
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
// Its late resolution changes nothing either.
await act(async () => { settlers[0]!(listingFor(HOME)) })
expect(columns()).toHaveLength(1)
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
return { settlers, listDirectory }
}
it('lands a navigation as ONE two-pane frame: the stale view holds until both legs arrive', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target settles while the parent leg is still in flight: nothing
// commits yet — the editor stays open over the stale home level, and no
// single-pane DOCS frame ever renders.
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
expect(screen.queryByText('harness')).toBeNull()
// The parent leg settles inside the wait bound: one commit straight to
// the two-pane landing, editor closed.
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(columns()).toHaveLength(2)
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The wait-bound timer firing after the landing is a no-op.
await act(async () => { vi.advanceTimersByTime(200) })
expect(columns()).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('a stalled parent leg lands the target alone at the wait bound, then upgrades in place', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target can consume most of the outer scan's silence window.
await act(async () => { vi.advanceTimersByTime(250) })
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
// Its parent leg gets a fresh silence window. Crossing the original
// scan's 300ms deadline therefore cannot flash the indicator during the
// bounded landing wait.
await act(async () => { vi.advanceTimersByTime(199) })
expect(screen.queryByText('browser.loading')).toBeNull()
// The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone.
await act(async () => { vi.advanceTimersByTime(1) })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('listitem').textContent).toBe('harness')
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The late parent leg still upgrades the landing in place, exactly as
// if it had made the bound. (Reopening the editor meanwhile would
// supersede the upgrade — the editor-open handler withdraws pending
// listings — so a late upgrade can never close a resumed draft.)
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(columns()).toHaveLength(2)
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('Escape inside the landing window withdraws the submitted navigation', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: DOCS } })
fireEvent.keyDown(input, { key: 'Enter' })
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
// Nothing has committed yet; Escape supersedes the landing entirely.
fireEvent.keyDown(input, { key: 'Escape' })
await act(async () => { vi.advanceTimersByTime(200) })
expect(columns()).toHaveLength(1)
expect(screen.queryByText('harness')).toBeNull()
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => {
vi.useFakeTimers()
try {
// The home level is truncated so its note is on screen when the slow
// scan starts: dropping the note's old !loading guard means it must
// keep rendering through the scan, coexisting with the indicator.
const settlers = new Map<string, (value: DirectoryListing) => void>()
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
if (path === undefined) return Promise.resolve({ ...listingFor(path), truncated: true })
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
})
mount({ listDirectory })
await act(async () => {})
expect(screen.queryByText('browser.loading')).toBeNull()
expect(screen.getByText('browser.truncated')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// In flight but still inside the silence window: no indicator, and the
// stale level's truncated note stays put (no layout churn on launch).
expect(screen.queryByText('browser.loading')).toBeNull()
expect(screen.getByText('browser.truncated')).toBeTruthy()
await act(async () => { vi.advanceTimersByTime(300) })
// Past it: the indicator floats while the stale level — truncated note
// included — keeps rendering beneath it.
expect(screen.getByText('browser.loading')).toBeTruthy()
expect(screen.getByText('browser.truncated')).toBeTruthy()
expect(screen.getByText('Documents')).toBeTruthy()
// Landing (both legs) retires the indicator with the scan, and the
// fresh listings' own truncated state replaces the stale note.
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(screen.queryByText('browser.loading')).toBeNull()
expect(screen.queryByText('browser.truncated')).toBeNull()
expect(columns()).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('restarts the silence window when a row pick supersedes a pending scan', async () => {
vi.useFakeTimers()
try {
const pending: ((value: DirectoryListing) => void)[] = []
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
if (path === undefined) return Promise.resolve(listingFor(path))
return new Promise<DirectoryListing>((resolve) => { pending.push(resolve) })
})
mount({ listDirectory })
await act(async () => {})
const documents = rowButton(screen.getByRole('listitem'))
fireEvent.click(documents)
await act(async () => { vi.advanceTimersByTime(300) })
expect(screen.getByText('browser.loading')).toBeTruthy()
// The same row remains actionable while its preview is pending. A second
// pick starts a new listing without a false `loading` edge.
fireEvent.click(documents)
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(299) })
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(1) })
expect(screen.getByText('browser.loading')).toBeTruthy()
await act(async () => { pending.at(-1)!(listingFor(DOCS)) })
} finally {
vi.useRealTimers()
}
})
it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => {
vi.useFakeTimers()
try {
// Every home listing hangs: the initial open's scan is the one the
// close interrupts, and the reopen's scan proves the fresh window.
const settlers: ((value: DirectoryListing) => void)[] = []
const listDirectory = vi.fn((_path?: string, _signal?: AbortSignal) =>
new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) }))
const { view, props } = mount({ listDirectory })
await act(async () => { vi.advanceTimersByTime(300) })
expect(screen.getByText('browser.loading')).toBeTruthy()
// Close while the scan is in flight, then reopen: the first frame must
// wait out a fresh silence window, not inherit the armed indicator.
view.rerender(<DirectoryBrowser {...props} open={false} />)
view.rerender(<DirectoryBrowser {...props} open />)
await act(async () => {})
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(300) })
expect(screen.getByText('browser.loading')).toBeTruthy()
// The reopened scan settles normally.
await act(async () => { settlers.at(-1)!(listingFor(undefined)) })
expect(screen.queryByText('browser.loading')).toBeNull()
expect(screen.getByText('Documents')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {

View File

@@ -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/host/directory-picker/README.md
README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f
README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d
README.md: 3749b238b56578ec68610bc13550760aa084bad6
README.zh.md: 488da5129ec211c2a064156c22a9d0abf04d99be

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together.
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself.
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker``ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker``ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。
浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable``directory-exists``directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。

View File

@@ -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/host/webserver/README.md
README.md: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0
README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc
README.md: ace8c09e43dd8544a28d300f97b04610be78bc69
README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer``register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer``register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist并遵循固定语义越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 octet-stream 提供GETHEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist并遵循固定语义越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 octet-stream 提供GETHEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR热模块替换事件流则是 moduleshmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web浏览器形态Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch而不使用本服务器。该包从不打印内容URL 行属于 shell。

View File

@@ -78,6 +78,11 @@ export class HttpServerService extends Service {
return this.listenedPort
}
/** The configured bind host (the loopback or all-interfaces literal). */
get host(): Config['host'] {
return this.config.host
}
/**
* Register a named route. Duplicate (kind, path) throws — route patterns are
* a composition-level contract, so a collision is a misconfiguration.

View File

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

View File

@@ -0,0 +1,12 @@
# settings/ — user-settings capability family
English | [中文](README.zh.md)
The user-settings seam and its providers. The interface package owns the abstract `Settings` service — namespace registration, layered resolution, and change commits; providers implement raw-document storage and push external edits through the seam. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `settings/` | Settings seam: namespace registry, layered resolution, commit events | `ctx.settings` |
| `settings-local/` | File-backed provider (`settings.yaml`/`.json`) with hot reload and comment-preserving write-back | (registers `ctx.settings`) |
The interface lives at `settings/settings/`; providers are flat siblings. A network configuration-center provider (for example a nacos-style backend) joins here and registers on `ctx.settings`. Composition config stays in `cordis.yml`: a settings namespace carries only the user-editable subset, resolved as schema defaults, then the registrant's composition `base`, then the user document.

View File

@@ -0,0 +1,12 @@
# settings/ — 用户设置能力族
[English](README.md) | 中文
用户设置 seam 及其 provider。接口包拥有抽象 `Settings` 服务——namespace 注册、分层解析与变更提交provider 实现原始文档存储并把外部修改推入 seam。全部为**产品**包。
| 包 | 角色 | ctx key |
|---|---|---|
| `settings/` | 设置 seamnamespace 注册表、分层解析、提交事件 | `ctx.settings` |
| `settings-local/` | 文件 provider`settings.yaml`/`.json`),热重载与保留注释的写回 | (注册 `ctx.settings` |
接口位于 `settings/settings/`provider 平级并列。网络配置中心 provider例如 nacos 类后端)加入本组并注册到 `ctx.settings`。组合配置仍留在 `cordis.yml`settings namespace 只承载用户可编辑子集,解析顺序为 schema 默认值、注册方的组合 `base`、用户文档。

View File

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

View File

@@ -0,0 +1,43 @@
# @deepseek-ai/dsh-settings-local
English | [中文](README.zh.md)
File-backed settings provider. One YAML or JSON document carries every namespace section; external edits hot-publish through `ctx.settings`, and `update()` re-reads the document under a writer lock before writing back atomically, preserving the user's YAML comments, any section owned by a plugin that is not currently loaded, and any on-disk change this process has not observed yet.
## Config
| Field | Meaning | Default |
|---|---|---|
| `path` | Settings document path; extension picks the format (`.yaml`/`.yml`/`.json`) | `settings.yaml` under the harness home |
| `dshHome` | Harness home used when `path` is omitted | `$DSH_HOME` or `~/.dsh` |
| `watch` | Watch the document and hot-publish external edits | `true` |
| `debounceMs` | Watcher write-settle window in milliseconds | `100` |
Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension fails at load.
## Behavior
- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
- **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit.
- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling with exponential backoff, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). Readers never take the lock: the rename commit is atomic, so reloads are always consistent.
- **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure.
- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
- **The watcher's ready signal reconciles once.** The initial load races the watcher's own setup, so a change written in between never fires an event; the reconcile at ready closes that startup gap.
- **Dispose quiesces.** Teardown stops accepting watcher events, closes the watcher, then waits out any queued or in-flight operation, so nothing publishes after disposal.
- **Self-write suppression by content.** The provider caches the last good text; a watcher event whose content equals the cache (its own write included) is a no-op.
## Model Experience
Indirectly, through consumers of `ctx.settings`: this provider only stores and publishes namespace sections, and each consumer's own surface documents any model effect.
#### KV Cache effect
No direct invalidation; the consuming plugin owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Same-namespace conflicts stay last-write-wins** — the writer lock and read-modify-write keep concurrent writers from dropping each other's namespaces, but two writers editing one namespace still resolve to the later write; there is no per-value merge or revision check.
- **A missed watcher event stays unseen until the next signal** — reads never re-stat the file, so a change the watcher fails to report is only folded in by the next event, the next write, or a restart.
- **Comment preservation is YAML-only and map-shaped** — JSON documents re-serialize without comments (JSON has none), and comments inside a changed array (or attached inline to a changed scalar value) go with the value they described.
- **No value indirection** — sections hold literal values; `${env:VAR}`-style references for secrets are a deferred seam-level feature.

View File

@@ -0,0 +1,43 @@
# @deepseek-ai/dsh-settings-local
[English](README.md) | 中文
文件 settings provider。一个 YAML 或 JSON 文档承载全部 namespace 分节;外部编辑经 `ctx.settings` 热发布,`update()` 在写锁下先重读文档再原子写回,保留用户的 YAML 注释、当前未加载插件所拥有的分节,以及任何本进程尚未观察到的磁盘变更。
## 配置
| 字段 | 含义 | 默认 |
|---|---|---|
| `path` | 设置文档路径;扩展名决定格式(`.yaml`/`.yml`/`.json` | harness home 下的 `settings.yaml` |
| `dshHome` | `path` 省略时使用的 harness home | `$DSH_HOME``~/.dsh` |
| `watch` | 监听文档并热发布外部编辑 | `true` |
| `debounceMs` | watcher 写入稳定窗口(毫秒) | `100` |
默认值解析是一步显式的 `resolveSpec(config)`;不支持的扩展名在加载时报错。
## 行为
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。
- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `<file>.lock` 同级文件下运行带指数退避、2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管持有者已崩溃破锁并告警。读取方从不取锁rename 提交是原子的,重载因此始终一致。
- **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值整体替换其中的注释随之一同被换掉。JSON 重新序列化,无注释。
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
- **watcher 的 ready 信号做一次对账。** 初始加载与 watcher 自身的建立存在竞态因此其间写入的变更绝不会触发事件ready 时的对账补上这个启动缺口。
- **Dispose 保证静止。** 卸载先停止接收 watcher 事件、关闭 watcher再等完排队与进行中的操作之后不再有任何发布。
- **按内容抑制自写。** provider 缓存最后可用文本watcher 事件内容与缓存相同(含自己的写入)即为 no-op。
## Model Experience
间接生效:本 provider 只存储并发布 namespace 分节,模型效果经由 `ctx.settings` 的消费插件产生,由各消费者自己的文档描述。
#### KV Cache effect
无直接失效;请求前缀的变更由消费插件拥有。
## Known Limitations and Deferred Work
- **同 namespace 冲突仍是后写胜出** — 写锁加读-改-写让并发写入者不会丢掉彼此的 namespace但两个写入者编辑同一个 namespace 时仍以较后的写入为准;没有按值合并,也没有修订检查。
- **漏掉的 watcher 事件在下一个信号前保持不可见** — 读取从不重新 stat 文件,因此 watcher 漏报的变更只会在下一个事件、下一次写入或重启时被并入。
- **注释保留仅限 YAML 且仅限 map 形状** — JSON 文档重新序列化无注释JSON 本身没有),且被改数组内部的注释(或行内附着在被改标量值上的注释)随其所描述的值一同被换掉。
- **无值间接引用** — 分节存字面值;面向密钥的 `${env:VAR}` 式引用是 seam 层的延后特性。

View File

@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-settings-local",
"description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness",
"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-paths": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"chokidar": "^4.0.3",
"schemastery": "^3.18.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,411 @@
/**
* File-backed settings provider. One YAML or JSON document under the user's
* harness home carries every namespace section; external edits hot-publish
* through the seam, and every write re-reads the document under a
* cross-process writer lock before patching it as a comment-preserving
* leaf-level diff.
* @module @deepseek-ai/dsh-settings-local
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { randomBytes } from 'node:crypto'
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
/** Plugin config: file location and hot-reload behavior. */
export interface Config {
/** Settings document path; defaults to `settings.yaml` under the harness home. */
path?: string
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Watch the document and hot-publish external edits; defaults to true. */
watch?: boolean
/** Watcher write-settle window in milliseconds; defaults to 100. */
debounceMs?: number
}
/** Document format derived from the configured file extension. */
type SettingsFormat = 'yaml' | 'json'
const FORMATS: Record<string, SettingsFormat> = {
'.yaml': 'yaml',
'.yml': 'yaml',
'.json': 'json',
}
/** Fully resolved provider parameters; defaulting happens here, never inline. */
interface ResolvedSpec {
filename: string
format: SettingsFormat
watch: boolean
debounceMs: number
}
/**
* Resolve the runtime spec from plugin config: an explicit `path` wins,
* otherwise the document lives at `<harness home>/settings.yaml`.
* @param config - raw plugin config.
* @returns the resolved file location, format, and watch behavior.
*/
export function resolveSpec(config: Config): ResolvedSpec {
const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml'))
const format = FORMATS[extname(filename)]
if (format === undefined) {
throw new Error(`settings-local: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`)
}
return {
filename,
format,
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
}
/** Whether a parsed YAML value is a map for diffing purposes. */
function isMapLike(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Apply the difference between one node's stored and next value as minimal
* `setIn`/`deleteIn` edits, recursing through maps, so every untouched node —
* and the key node of every changed pair — keeps its comments, anchors, and
* formatting. Non-map values (arrays and scalars) replace wholesale when
* unequal, taking any comments inside them along.
*/
function patchNode(document: Document, path: readonly string[], current: unknown, next: unknown): void {
if (isMapLike(current) && isMapLike(next)) {
for (const key of Object.keys(current)) {
if (!(key in next)) document.deleteIn([...path, key])
}
for (const [key, value] of Object.entries(next)) {
patchNode(document, [...path, key], current[key], value)
}
return
}
if (!deepEqualJson(current, next)) document.setIn([...path], next)
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Whether an exclusive create failed because the path already exists. */
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
/**
* Writer-lock protocol constants. These are robustness invariants of the
* cross-process write protocol, not deployment tunables: a holder rewrites one
* small document in milliseconds, so contention resolves well inside the
* retry deadline, and a lock older than the stale age can only belong to a
* crashed holder.
*/
const LOCK_RETRY_INITIAL_MS = 20
const LOCK_RETRY_MAX_MS = 200
const LOCK_TIMEOUT_MS = 2_000
const LOCK_STALE_MS = 5_000
/** File-backed settings provider (`settings.yaml`/`.json`). */
export class SettingsLocal extends Settings {
static Config: z<Config> = z.object({
path: z.string(),
dshHome: z.string(),
watch: z.boolean().default(true),
debounceMs: z.number().min(0).default(100),
})
private readonly spec: ResolvedSpec
/**
* Raw text of the last successfully parsed or persisted document;
* `undefined` while the file is absent. Watcher events whose content equals
* this cache are no-ops, which is also the self-write suppression.
*/
private text: string | undefined
/**
* Single exclusive operation chain: watcher reloads and document writes run
* one at a time in queue order (settled tail), so a write can never render
* from text a concurrent reload is busy replacing, and a reload can never
* read a half-committed write.
*/
private operations: Promise<void> = Promise.resolve()
/** Set at dispose: refuse new watcher events and let in-flight work no-op. */
private closed = false
/** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
private isClosed(): boolean {
return this.closed
}
constructor(ctx: Context, public config: Config) {
super(ctx)
// Programmatic construction may bypass Schemastery normalization; resolve
// the same defaults in one explicit step either way.
this.spec = resolveSpec(config)
}
/** The local document is always writable through {@link Settings.update}. */
get writable(): boolean {
return true
}
protected async load(): Promise<Record<string, unknown>> {
let text: string
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) throw error
this.text = undefined
return {}
}
const doc = this.parse(text)
this.text = text
return doc
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
// One document backs every namespace, so writes from different namespace
// queues serialize with each other and with watcher reloads on the one
// operation chain: each render must see the text the previous operation
// committed, or a sibling section silently vanishes from disk.
return this.enqueue(() => this.persistSection(ns, section))
}
/** Queue one exclusive document operation behind every earlier one. */
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const task = this.operations.then(operation)
this.operations = task.then(() => undefined, () => undefined)
return task
}
/** Queue a reload; only an invariant violation escaping a commit can reject it. */
private queueRefresh(): void {
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
// Only an invariant violation escaping the commit path can reject a
// refresh; keep the operation queue alive and surface it as an error so
// one poisoned commit cannot silently end hot reloading forever.
this.ctx.logger.error('settings-local: reload commit failed at %s', this.spec.filename)
this.ctx.logger.error(error)
})
}
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
await mkdir(dirname(this.spec.filename), { recursive: true })
await this.withWriterLock(async () => {
// Read-modify-write: fold in any on-disk state this process has not
// observed yet — an external edit still inside the watcher debounce
// window, a change the watcher missed, or another process's write — so
// the render below can never resurrect a stale document. An unparsable
// on-disk document fails the write loud instead of silently overwriting
// a user's manual edit.
await this.reconcileFromDisk()
const output = this.spec.format === 'yaml'
? this.renderYaml(ns, section)
: this.renderJson(ns, section)
// Exclusive-create (`wx`) a random-suffix sibling: the open refuses to
// follow any planted symlink at a guessable temp path, and the fresh inode
// carries owner-only permissions that survive the rename — a document that
// may hold personal values is never world-readable and never a symlink.
const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp`
// TODO(settings-atomic-durability): Use a replacement that fsyncs the file
// and parent directory and preserves owner-only permissions on Windows.
try {
await writeFile(temp, output, { mode: 0o600, flag: 'wx' })
await rename(temp, this.spec.filename)
} catch (error) {
await rm(temp, { force: true })
throw error
}
this.text = output
})
}
/**
* Hold the cross-process writer lock around one read-render-rename cycle.
* The lock is a `wx`-created sibling (`<file>.lock`); the rename-based
* commit keeps readers lock-free, so only writers contend. A lock older
* than {@link LOCK_STALE_MS} is a crashed holder and is broken with a
* warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write.
*/
private async withWriterLock<T>(operation: () => Promise<T>): Promise<T> {
const lockPath = `${this.spec.filename}.lock`
const deadline = Date.now() + LOCK_TIMEOUT_MS
let delay = LOCK_RETRY_INITIAL_MS
for (;;) {
try {
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
break
} catch (error) {
if (!isEEXIST(error)) throw error
}
const ageMs = await this.lockAgeMs(lockPath)
// The holder released between the failed create and the stat: the lock
// is free right now, so retry without burning backoff or deadline.
if (ageMs === undefined) continue
if (ageMs > LOCK_STALE_MS) {
// TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe
// acquisition and release so a slow writer cannot remove a successor's lock.
this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath)
await rm(lockPath, { force: true })
continue
}
if (Date.now() >= deadline) {
throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`)
}
await new Promise(resolve => setTimeout(resolve, delay))
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
}
try {
return await operation()
} finally {
await rm(lockPath, { force: true })
}
}
/** Age of the writer lock, or `undefined` when it vanished after a failed create. */
private async lockAgeMs(lockPath: string): Promise<number | undefined> {
try {
return Date.now() - (await stat(lockPath)).mtimeMs
} catch (error) {
if (!isENOENT(error)) throw error
return undefined
}
}
override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
// The base init loads and publishes; a parse failure there is a boot
// failure: an existing-but-invalid document must fail loud, never be
// silently ignored or overwritten.
yield* super[Service.init]()
if (!this.spec.watch) return
const watcher = chokidarWatch(this.spec.filename, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
},
})
watcher.on('all', () => {
if (this.closed) return
this.queueRefresh()
})
watcher.on('ready', () => {
// The base init's load raced the watcher's own setup: a change written
// between that read and the watcher becoming active never fires an
// event. One reconcile at ready closes the gap.
if (this.closed) return
this.queueRefresh()
})
watcher.on('error', (error) => {
this.ctx.logger.warn('settings-local: watcher error on %s', this.spec.filename)
this.ctx.logger.warn(error)
})
yield async () => {
// Quiesce: stop accepting events, close the watcher, then wait out any
// queued or in-flight operation so nothing publishes after disposal.
this.closed = true
await watcher.close()
await this.operations
}
}
/** Parse one document text into raw sections, failing on a non-map root. */
private parse(text: string): Record<string, unknown> {
let root: unknown
if (this.spec.format === 'yaml') {
const document = parseDocument(text, { prettyErrors: true })
if (document.errors.length > 0) {
throw new Error(`settings-local: invalid document at ${this.spec.filename}: ${
document.errors.map(error => error.message).join('; ')}`)
}
root = document.toJS() ?? {}
} else {
root = text.trim().length === 0 ? {} : JSON.parse(text)
}
if (typeof root !== 'object' || root === null || Array.isArray(root)) {
throw new TypeError(`settings-local: ${this.spec.filename} must be a map of namespace sections`)
}
return root as Record<string, unknown>
}
/**
* Re-read the document after a watcher event. Unchanged content (including
* this provider's own writes) is a no-op; an unreadable or unparsable
* document keeps the last good sections and warns — a live hot-reload must
* never take the process down. An invariant violation escaping a commit is
* not a reload failure and propagates to the queue's error surface.
*/
private async refresh(): Promise<void> {
if (this.closed) return
try {
await this.reconcileFromDisk()
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
this.ctx.logger.warn('settings-local: reload failed at %s; keeping the last good document', this.spec.filename)
this.ctx.logger.warn(error)
}
}
/**
* Compare the on-disk text against the cache and publish any difference
* into the seam. Absence publishes the empty document; an unreadable or
* unparsable file throws, so each caller picks its policy — a reload warns
* and keeps the last good document, a write fails loud.
*/
private async reconcileFromDisk(): Promise<void> {
let text: string | undefined
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) throw error
text = undefined
}
if (text === this.text || this.isClosed()) return
if (text === undefined) {
this.text = undefined
this.publish({})
return
}
const doc = this.parse(text)
this.text = text
this.publish(doc)
}
/**
* Render the next YAML text by patching one namespace in the
* comment-preserving document. The next section lands as a leaf-level diff
* against the stored one — only changed values set, only removed keys
* delete — so comments inside the section survive edits to their siblings,
* not just comments outside it.
*/
private renderYaml(ns: SettingsNamespace, section: Record<string, unknown>): string {
if (this.text === undefined) {
return new Document({ [ns]: section }).toString()
}
// this.text only ever caches content that parsed successfully, so this
// re-parse (for the mutable comment-preserving tree) cannot fail, and
// parse() already rejected any non-map root.
const document = parseDocument(this.text)
const root: unknown = document.toJS()
patchNode(document, [ns], isMapLike(root) ? root[ns] : undefined, section)
return document.toString()
}
/** Render the next JSON text by replacing one namespace key. */
private renderJson(ns: SettingsNamespace, section: Record<string, unknown>): string {
const root = this.text === undefined
? {}
: this.parse(this.text)
root[ns] = section
return `${JSON.stringify(root, null, 2)}\n`
}
}
export default SettingsLocal

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-settings-local`.
* @module @deepseek-ai/dsh-settings-local/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local'
/** Cordis companion plugin name. */
export const name = 'settings-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this provider's contracts are file round-trip,
* watcher timing, and atomic-write behavior — IO effects proven by package
* tests; the in-process commit relation is owned by `@deepseek-ai/dsh-settings`.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,103 @@
// Cross-instance and writer-lock behavior: two providers on one document are
// the in-process equivalent of two dsh processes sharing a harness home —
// neither knows the other's cache, so only the read-modify-write cycle under
// the `<file>.lock` sibling keeps both namespaces alive on disk.
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '../src/index.ts'
const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
const BetaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lock-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('cross-instance writes', () => {
it('keeps both namespaces when two providers write the same document concurrently', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const first = await boot({ path, watch: false })
const second = await boot({ path, watch: false })
const alpha = first.settings.register(settingsNamespace('alpha'), AlphaSchema)
const beta = second.settings.register(settingsNamespace('beta'), BetaSchema)
const rounds = [1, 2, 3, 4, 5]
await Promise.all([
(async () => { for (const value of rounds) await alpha.update({ value }) })(),
(async () => { for (const value of rounds) await beta.update({ value }) })(),
])
const text = await readFile(path, 'utf8')
expect(text).toContain('alpha:')
expect(text).toContain('beta:')
// A third instance resolves both final values from the shared document.
const third = await boot({ path, watch: false })
expect(third.settings.register(settingsNamespace('alpha'), AlphaSchema).get()).toEqual({ value: 5 })
expect(third.settings.register(settingsNamespace('beta'), BetaSchema).get()).toEqual({ value: 5 })
})
})
describe('writer lock', () => {
it('waits for a busy writer lock instead of failing', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
await writeFile(`${path}.lock`, 'holder\n')
const release = setTimeout(() => { void rm(`${path}.lock`, { force: true }) }, 120)
cleanups.push(async () => { clearTimeout(release) })
await scope.update({ value: 7 })
expect(await readFile(path, 'utf8')).toContain('value: 7')
})
it('breaks a stale writer lock with a warning and writes through', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
await writeFile(`${path}.lock`, 'crashed-holder\n')
const past = (Date.now() - 60_000) / 1000
await utimes(`${path}.lock`, past, past)
await scope.update({ value: 9 })
expect(await readFile(path, 'utf8')).toContain('value: 9')
})
it('times out on a lock a live holder never releases', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
await writeFile(`${path}.lock`, 'busy-holder\n')
await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/)
}, 10_000)
it('surfaces a non-contention lock failure as the write error', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
await chmod(dir, 0o500)
cleanups.push(() => chmod(dir, 0o700))
await expect(scope.update({ value: 1 })).rejects.toThrow(/EACCES|permission/)
})
})

View File

@@ -0,0 +1,145 @@
/**
* Real-composition guard: the provider and a consumer plugin boot from a
* test-only cordis.yml through the actual Loader + Include path, an external
* edit of settings.yaml hot-publishes into the consumer's scope, and the same
* consumer booted WITHOUT a settings entry keeps its entry-config resolution —
* the documented optional-inject fallback.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import z from 'schemastery'
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
import SettingsLocal from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
interface ConsumerState {
scope: SettingsScope<ThemeConfig> | undefined
seen: ThemeConfig[]
/** What the consumer is actually running with, settings or not. */
applied: ThemeConfig | undefined
}
async function loadComposition(
options?: { withSettings?: boolean },
): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
const withSettings = options?.withSettings ?? true
root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
const state: ConsumerState = { scope: undefined, seen: [], applied: undefined }
const consumer = {
name: 'settings-consumer',
apply: (ctx: Context) => {
// The documented consumer shape: no hard dependency — entry config alone
// is the running state, and the scoped inject overlays the user layer
// only while a settings service exists.
const base: Partial<ThemeConfig> = { fontSize: 16 }
state.applied = ThemeSchema(base as ThemeConfig)
ctx.inject(['settings'], (child: Context) => {
const scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { base })
state.scope = scope
state.applied = scope.get()
scope.watch((next) => {
state.seen.push(next)
state.applied = next
})
})
},
}
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
...withSettings
? [
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
]
: [],
'- id: consumer',
' name: test-settings-consumer',
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-settings-local', SettingsLocal],
['test-settings-consumer', consumer],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, state, settingsPath }
}
describe('settings-local real composition', () => {
it('boots from cordis.yml and hot-publishes an external settings edit', async () => {
const { ctx, state, settingsPath } = await loadComposition()
// Composition resolution: user layer over the consumer's composition base.
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
})
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
})
it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => {
const { ctx, state } = await loadComposition({ withSettings: false })
// No settings service anywhere in the composition…
expect(ctx.get('settings')).toBeUndefined()
// …so the consumer runs on schema defaults plus its composition base, and
// never receives a scope.
expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 })
expect(state.scope).toBeUndefined()
expect(state.seen).toEqual([])
})
})

View File

@@ -0,0 +1,401 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal, resolveSpec } from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('resolveSpec', () => {
it('defaults watch and debounce when construction bypasses schema normalization', () => {
const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' })
expect(spec.watch).toBe(true)
expect(spec.debounceMs).toBe(100)
})
})
describe('boot and reads', () => {
it('resolves defaults over an absent file and reports writable', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
expect(ctx.settings.writable).toBe(true)
})
it('reads sections from an existing yaml document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
})
it('reads sections from a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } }))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
it('defaults the file location under the configured harness home', async () => {
const dir = await tempDir()
const ctx = await boot({ dshHome: dir, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
expect(written).toContain('theme: light')
})
it('reads an empty yaml document as no sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, '')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('reads an empty json document as no sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, '')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
})
it('fails loud at boot when the document exists but is unreadable', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
})
it('fails loud on an unsupported extension', async () => {
const dir = await tempDir()
await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
.rejects.toThrow(/not supported/)
})
it('fails loud at boot on unparsable yaml', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme: [unclosed\n')
await expect(boot({ path, watch: false })).rejects.toThrow()
})
it('fails loud at boot when the root is not a map of sections', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, '- just\n- a list\n')
await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/)
})
})
describe('persist', () => {
it('writes the merged section, creating the file with owner-only permissions', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(path, 'utf8')
expect(written).toContain('theme: light')
expect((await stat(path)).mode & 0o777).toBe(0o600)
// Atomic replace leaves no temp artifact behind.
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
})
it('serializes cross-namespace writes into one on-disk document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const alpha = ctx.settings.register(settingsNamespace('alpha'), ThemeSchema)
const beta = ctx.settings.register(settingsNamespace('beta'), ThemeSchema)
await Promise.all([
alpha.update({ theme: 'light' }),
beta.update({ fontSize: 20 }),
])
const text = await readFile(path, 'utf8')
expect(text).toContain('alpha:')
expect(text).toContain('beta:')
expect(alpha.get().theme).toBe('light')
expect(beta.get().fontSize).toBe(20)
})
it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const victim = join(dir, 'victim.txt')
await writeFile(victim, 'precious')
// A hostile sibling plants the historic fixed temp name as a symlink.
await symlink(victim, `${path}.tmp`)
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
expect(await readFile(victim, 'utf8')).toBe('precious')
expect((await lstat(path)).isSymbolicLink()).toBe(false)
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(await readFile(path, 'utf8')).toContain('theme: light')
})
it('preserves comments and unregistered sections across updates', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'# personal settings',
'ui-theme:',
' theme: light',
'# owned by a plugin that is not loaded right now',
'future-plugin:',
' keep: me',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ fontSize: 18 })
const written = await readFile(path, 'utf8')
expect(written).toContain('# personal settings')
expect(written).toContain('# owned by a plugin that is not loaded right now')
expect(written).toContain('keep: me')
expect(written).toContain('fontSize: 18')
expect(written).toContain('theme: light')
})
it('keeps comments inside the section when a sibling key changes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'ui-theme:',
' # chosen during onboarding',
' theme: light',
' fontSize: 12',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ fontSize: 18 })
const written = await readFile(path, 'utf8')
expect(written).toContain('# chosen during onboarding')
expect(written).toContain('theme: light')
expect(written).toContain('fontSize: 18')
})
it('keeps a changed key\'s own-line comment while replacing its value', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'ui-theme:',
' # chosen during onboarding',
' theme: light',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'dark' })
const written = await readFile(path, 'utf8')
expect(written).toContain('# chosen during onboarding')
expect(written).toContain('theme: dark')
})
it('deletes only the removed key on replace, keeping sibling comments', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, [
'ui-theme:',
' # chosen during onboarding',
' theme: light',
' fontSize: 12',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.replace({ theme: 'light' })
const written = await readFile(path, 'utf8')
expect(written).toContain('# chosen during onboarding')
expect(written).toContain('theme: light')
expect(written).not.toContain('fontSize')
})
it('keeps an unchanged array\'s comments and replaces a changed array wholesale', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const TagsSchema: z<{ tags: string[]; label: string }> = z.object({
tags: z.array(z.string()).default([]),
label: z.string().default(''),
})
await writeFile(path, [
'workspace:',
' tags:',
' # pinned by hand',
' - alpha',
' label: draft',
'',
].join('\n'))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('workspace'), TagsSchema)
await scope.update({ label: 'final' })
const untouched = await readFile(path, 'utf8')
expect(untouched).toContain('# pinned by hand')
expect(untouched).toContain('label: final')
// A changed array replaces wholesale; comments inside it go with it.
await scope.update({ tags: ['beta'] })
const replaced = await readFile(path, 'utf8')
expect(replaced).not.toContain('# pinned by hand')
expect(replaced).toContain('- beta')
})
it('keeps a comment-only document\'s comment when the first section lands', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
// Parses to a null root: the document exists but holds no sections yet.
await writeFile(path, '# reserved for future settings\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = await readFile(path, 'utf8')
expect(written).toContain('# reserved for future settings')
expect(written).toContain('theme: light')
})
it('creates a json document from scratch', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
})
it('rejects and leaves no temp residue when the directory turns unwritable', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await chmod(dir, 0o500)
cleanups.push(() => chmod(dir, 0o700))
await expect(scope.update({ theme: 'dark' })).rejects.toThrow()
await chmod(dir, 0o700)
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
expect(scope.get().theme).toBe('light')
// The failed persist must not poison the document write chain.
await scope.update({ theme: 'dark' })
expect(scope.get().theme).toBe('dark')
})
it('round-trips a json document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.json')
await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2))
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } })
})
})
describe('watch', () => {
it('publishes an external edit to registered scopes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get().theme).toBe('light')
await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
})
it('keeps the last good document over an invalid edit, then recovers', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await writeFile(path, 'ui-theme: [unclosed\n')
// The bad edit must never take the live tree down or reset the value.
await new Promise(resolve => setTimeout(resolve, 300))
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
await writeFile(path, 'ui-theme:\n theme: dark\n')
await vi.waitFor(() => {
expect(scope.get().theme).toBe('dark')
}, { timeout: 5000 })
})
it('treats file removal as an empty document', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await rm(path)
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
}, { timeout: 5000 })
})
it('does not republish its own persisted write', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 10 })
const events: unknown[] = []
ctx.on('settings/updated', (ns, _next, _prev, source) => {
events.push({ ns, source })
})
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: 'light' })
await new Promise(resolve => setTimeout(resolve, 300))
expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }])
})
})

View File

@@ -0,0 +1,100 @@
// Writer-lock races that cannot be timed from outside: a contender whose lock
// vanishes between the failed exclusive create and the stat, a stat failing
// for a reason other than absence, and a temp-file write failing mid-cycle.
// The fs/promises seam is partially mocked to inject exactly one failure at a
// chosen path suffix; everything else passes through to the real filesystem.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '../src/index.ts'
const state = vi.hoisted(() => ({
/** One-shot failure injections keyed by operation, matched on a path suffix. */
failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>,
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
const inject = (op: 'writeFile' | 'stat', path: unknown): void => {
const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix))
if (index === -1) return
const [failure] = state.failures.splice(index, 1)
throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code })
}
return {
...actual,
writeFile: (async (path: unknown, ...rest: never[]) => {
inject('writeFile', path)
return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
}) as typeof actual.writeFile,
stat: (async (path: unknown, ...rest: never[]) => {
inject('stat', path)
return (actual.stat as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
}) as typeof actual.stat,
}
})
const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0) })
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
state.failures.length = 0
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-lockrace-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('writer-lock races', () => {
it('retries immediately when the contending lock vanished before the stat', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
// The exclusive create loses to a holder that releases before the stat:
// no lock file actually exists, so the stat sees honest absence and the
// very next attempt takes the lock.
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
await scope.update({ value: 3 })
expect(await readFile(path, 'utf8')).toContain('value: 3')
})
it('propagates a stat failure that does not mean absence', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' })
await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/)
})
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'alpha:\n value: 1\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
state.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' })
await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/)
// The document is untouched and the writer lock was released on the way out.
expect(await readFile(path, 'utf8')).toContain('value: 1')
await expect(access(`${path}.lock`)).rejects.toThrow()
})
})

View File

@@ -0,0 +1,225 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '../src/index.ts'
// chokidar is the nondeterministic OS boundary: faking it lets these tests
// drive the event pipeline (error events, races with unreadable files)
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
vi.mock('chokidar', async () => {
const { EventEmitter } = await import('node:events')
class FakeWatcher extends EventEmitter {
close = vi.fn(() => Promise.resolve())
}
const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
return {
watch: vi.fn((path: string, options: unknown) => {
const watcher = new FakeWatcher()
instances.push({ path, options, watcher })
return watcher
}),
__instances: instances,
}
})
interface FakeChokidar {
__instances: Array<{
path: string
options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
watcher: import('node:events').EventEmitter
}>
}
async function fakeInstances(): Promise<FakeChokidar['__instances']> {
const chokidar = await import('chokidar') as unknown as FakeChokidar
return chokidar.__instances
}
const ThemeSchema: z<{ theme: string }> = z.object({
theme: z.string().default('dark'),
})
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
;(await fakeInstances()).length = 0
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-watch-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('watcher pipeline', () => {
it('clamps the write-settle poll interval for a zero debounce', async () => {
const dir = await tempDir()
await boot({ path: join(dir, 'settings.yaml'), debounceMs: 0 })
const [instance] = await fakeInstances()
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
})
it('survives a watcher error and keeps publishing later edits', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const [instance] = await fakeInstances()
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(scope.get()).toEqual({ theme: 'dark' })
await writeFile(path, 'ui-theme:\n theme: light\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get()).toEqual({ theme: 'light' })
})
})
it('keeps the last good document when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
// The warn-and-keep path is asynchronous; give the serialized refresh a turn.
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'light' })
})
it('keeps the reload queue alive after an invariant violation escapes a commit', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
let arm = true
ctx.on('settings/updated', () => {
if (!arm) return
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
const [instance] = await fakeInstances()
await writeFile(path, 'ui-theme:\n theme: broken-commit\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get().theme).toBe('broken-commit')
})
arm = false
await writeFile(path, 'ui-theme:\n theme: recovered\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(() => {
expect(scope.get().theme).toBe('recovered')
})
})
it('quiesces the refresh pipeline before dispose completes', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = new Context()
const fiber = ctx.plugin(SettingsLocal, { path, debounceMs: 5 })
await fiber
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
let disposed = false
let postDisposeCommits = 0
ctx.on('settings/updated', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'ui-theme:\n theme: darker\n')
const [instance] = await fakeInstances()
// Two queued refreshes: dispose interrupts one mid-flight and the other
// before it starts, so both closed guards must hold.
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('all', 'change', path)
await fiber.dispose()
disposed = true
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('ready')
await new Promise(resolve => setTimeout(resolve, 100))
expect(postDisposeCommits).toBe(0)
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(scope.get()).toEqual({ theme: 'dark' })
})
it('folds an unobserved external edit into a write instead of overwriting it', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const editor = ctx.settings.register(settingsNamespace('editor'), z.object({
tabWidth: z.number().default(2),
}))
// The external edit has landed on disk but its watcher event has not
// fired yet (a debounce window, or a missed event): the write must fold
// it in, not resurrect the stale document.
await writeFile(path, 'ui-theme:\n theme: light\neditor:\n tabWidth: 8\n')
await theme.update({ theme: 'darker' })
const text = await readFile(path, 'utf8')
expect(text).toContain('tabWidth: 8')
expect(text).toContain('theme: darker')
// The fold published the unobserved section before the write committed.
expect(editor.get()).toEqual({ tabWidth: 8 })
})
it('reconciles at watcher ready so a change during setup is not missed', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
// Written after the initial load but before the watcher became active:
// no 'all' event will ever fire for it.
await writeFile(path, 'ui-theme:\n theme: written-before-ready\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(() => {
expect(scope.get().theme).toBe('written-before-ready')
})
})
it('fails a write loud when the on-disk document turned invalid unobserved', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'ui-theme:\n theme: light\n')
const ctx = await boot({ path, debounceMs: 5 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const broken = 'ui-theme: [unclosed\n flow: {\n'
await writeFile(path, broken)
await expect(scope.update({ theme: 'darker' })).rejects.toThrow(/invalid document/)
// The user's manual edit stays on disk untouched and the cache keeps the
// last good value.
expect(await readFile(path, 'utf8')).toBe(broken)
expect(scope.get()).toEqual({ theme: 'light' })
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/paths"
},
{
"path": "../settings"
},
{
"path": "../../support/invariants"
}
]
}

View File

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

View File

@@ -0,0 +1,37 @@
# @deepseek-ai/dsh-settings
English | [中文](README.zh.md)
Abstract user-settings seam (`ctx.settings`). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition `base` (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings.
## Service API
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces.
- `get(ns)` — resolved value, `undefined` while unregistered.
- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
- `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults).
- Resolved values are deep-frozen snapshots. Watchers receive `(next, prev)` after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. The `settings/updated` event fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is why `INVARIANT`-coded failures rethrow only from synchronous listeners.
- Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
## Provider contract
Subclasses implement `writable`, `load()`, and `persist(ns, section)`, and push externally observed documents through the protected `publish(doc)`. The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via `yield* super[Service.init]()`. At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down — while boot-time and registration-time validation fail loud.
## Events
`settings/updated (ns, next, prev, source)` fires after each commit; `source` is `update` (in-process write) or `provider` (external change). It never fires for a deep-equal resolved value.
## Model Experience
Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect.
#### KV Cache effect
No direct invalidation; a consumer that folds a settings value into the request prefix owns that change.
## Known Limitations and Deferred Work
- **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet.
- **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins).
- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure.

View File

@@ -0,0 +1,37 @@
# @deepseek-ai/dsh-settings
[English](README.md) | 中文
抽象用户设置 seam`ctx.settings`)。一个 provider 持有按 namespace 分节的原始文档;插件注册 namespace schema 并读取分层解析值schema 默认值,然后注册方的组合 `base`(其 cordis.yml entry 配置子集),最后用户文档分节。不挂载 provider 时消费者行为不变:仍只按 entry 配置解析,因此任何组合有无 settings 都能工作。
## 服务 API
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope``get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effectdispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
- `describe()` — 每个 namespace 一条描述(`schema.toJSON()` 信封、解析值、`applies`),供配置界面使用。
- `get(ns)` — 解析值;未注册时为 `undefined`
- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经 provider 持久化后提交。patch 必须是 JSON 形状的数据Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读 provider`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
- `replace(ns, section)` — 整体替换用户分节merge 表达不了的删除/重置路径(`replace({})` 重新继承 `base` 与 schema 默认值)。
- 解析值是深冻结快照。每次提交后观察者收到 `(next, prev)`同一回调的调用异步、逐次、按提交顺序执行慢的旧调用绝不会覆盖更新的结果异常——同步抛出与异步拒绝——均被隔离。watch 的 disposer 返回后不再启动新的调用(已排队的那一次会被跳过);已启动的调用仍会结算。`settings/updated` 事件逐 listener 扇出,一个抛错的 listener 不会饿死其余 listener异步 listener 的拒绝会被隔离并记入日志,这正是 `INVARIANT` 编码的失败只从同步 listener 重新抛出的原因。
- 服务卸载先拒绝新写入与观察者调用的启动再排干全部排队写入与已启动的观察者调用后才完成registrant fiber 在写入途中被 dispose 时,该写入仍到达存储,但不向任何人提交或通知。
## Provider 契约
子类实现 `writable``load()``persist(ns, section)`,并通过受保护的 `publish(doc)` 推入外部观察到的文档。基类 service init 在服务可注入前加载并发布一次文档;自有 initwatcher、连接的 provider 先经 `yield* super[Service.init]()` 委托。publish 时每个已注册 namespace 独立重解析:非法分节保留该 namespace 的最后可用值并告警——热重载绝不拖垮进程;启动期与注册期校验则立即报错。
## 事件
`settings/updated (ns, next, prev, source)` 在每次提交后触发;`source``update`(进程内写入)或 `provider`(外部变更)。解析值深相等时绝不触发。
## Model Experience
间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。
#### KV Cache effect
无直接失效;把设置值折叠进请求前缀的消费者拥有该变更。
## Known Limitations and Deferred Work
- **单一用户层** — 解析只认识 schema 默认值、一个组合 `base` 与一个用户文档;尚无 project/managed 分层或按值溯源。
- **跨进程并发由 provider 定义** — seam 仅在进程内按 namespace 串行化写入;跨进程并发按 provider 行为收敛(本地文件 provider 在写锁下读-改-写,因此 namespace 在并发写入者下不会丢失,同 namespace 冲突按后写胜出解决)。
- **无 secret 字段脱敏** — `describe()` 原样返回解析值wire 面RPC/UI在暴露前必须对 `role('secret')` 字段脱敏。

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-settings",
"description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness",
"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",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.18.0"
}
}

View File

@@ -0,0 +1,549 @@
/**
* User-settings seam (`ctx.settings`). Providers store one raw document of
* per-namespace sections; plugins register a namespace schema and read the
* resolved value, which layers schema defaults, the registrant's composition
* `base`, and the user document section, in that order.
* @module @deepseek-ai/dsh-settings
*/
import { Context, Service } from 'cordis'
import type z from 'schemastery'
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Nominal id of one registered settings namespace. */
export type SettingsNamespace = Branded<'SettingsNamespace'>
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
/**
* Brand a raw string as a {@link SettingsNamespace}.
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
* @returns the branded namespace.
*/
export function settingsNamespace(value: string): SettingsNamespace {
if (!NAMESPACE_PATTERN.test(value)) {
throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
}
return value as SettingsNamespace
}
/** When a namespace's changes take effect for its owner. */
export type SettingsApplies = 'live' | 'restart'
/** Origin of one committed settings change. */
export type SettingsUpdateSource = 'update' | 'provider'
/** Registration options beyond the namespace schema. */
export interface SettingsRegisterOptions<T> {
/** Composition-layer values resolved below the user layer (entry-config subset). */
base?: Partial<T>
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
applies?: SettingsApplies
}
/** One registered namespace as surfaced to configuration UIs. */
export interface SettingsDescriptor {
// TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the
// public seam, provider contract, implementations, tests, and consumers.
/** The registered namespace. */
ns: SettingsNamespace
/** Serialized schemastery schema (`schema.toJSON()`). */
schema: unknown
/** Current resolved value. */
value: unknown
/** Owner's declared effect timing. */
applies: SettingsApplies
}
/** Owner-facing handle for one registered namespace. */
export interface SettingsScope<T> {
/** Current resolved value: schema defaults, then `base`, then the user layer. */
get(): T
/**
* Observe committed changes to this namespace's resolved value. Invocations
* of one callback run asynchronously, one at a time, in commit order; a
* rejection is contained and logged like a sync throw. After the disposer
* returns, no further invocation starts — one already queued is skipped;
* one already started still settles, and service disposal waits for it.
* @param callback - invoked after each commit with the next and previous values.
* @returns the disposer removing this observer.
*/
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
/**
* Merge a partial patch into this namespace's user layer and persist it.
* @param patch - plain-object patch over the user section; JSON-shaped data
* only (non-JSON values reject with their path before anything persists).
*/
update(patch: object): Promise<void>
/**
* Replace this namespace's user section wholesale; absent keys re-inherit
* the composition `base` and schema defaults (`replace({})` resets all).
* @param section - the complete next user section; JSON-shaped data only,
* as for {@link update}.
*/
replace(section: object): Promise<void>
}
declare module 'cordis' {
interface Context {
settings: Settings
}
interface Events {
/**
* Committed change to one registered namespace's resolved value. Emitted
* after the provider persisted (for `update`) or published (`provider`)
* the change; never emitted when the resolved value is deep-equal.
* Listener failures are contained and logged — a sync throw and an async
* rejection alike — except `INVARIANT`-coded failures, which rethrow
* after every listener ran; that rethrow reaches the emitter only from
* synchronous listeners, so invariant checks on this event must not be
* async functions.
* @param ns - the namespace whose resolved value changed.
* @param next - the new resolved value.
* @param prev - the previous resolved value.
* @param source - whether the change entered through `update()` or the provider.
* @mode emit
*/
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void
}
}
/**
* Deep equality over JSON-shaped data (objects, arrays, primitives) — the
* seam's single change-detection predicate, exported so the invariant
* companion checks exactly the implementation's relation.
* @param a - one JSON-shaped value.
* @param b - the other JSON-shaped value.
* @returns whether the two values are structurally equal.
*/
export function deepEqualJson(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
return a.every((entry, index) => deepEqualJson(entry, b[index]))
}
const left = a as Record<string, unknown>
const right = b as Record<string, unknown>
const keys = Object.keys(left)
if (keys.length !== Object.keys(right).length) return false
return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
}
/** Whether a value is a plain data object (not an array, null, or class instance). */
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const proto: unknown = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */
function describeRejected(value: unknown): string {
if (value === undefined) return 'undefined'
if (typeof value === 'object' && value !== null) {
const proto = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null
const name = proto?.constructor?.name
return name === undefined || name === 'Object' ? 'a non-plain object' : `a ${name}`
}
return `a ${typeof value}`
}
/**
* Detach one write input in a single walk that doubles as the durable-boundary
* shape check: only JSON data (plain objects, arrays, strings, finite numbers,
* booleans, `null`) may reach a provider document. `structuredClone` alone
* would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then
* silently distorts on the reload round-trip. `undefined` entries in objects
* are skipped — the same sparse-patch semantics as {@link mergeLayers} — while
* an `undefined` array entry is rejected rather than coerced.
* @param root - plain-object write input (caller-checked).
* @param reject - builds the boundary error from a value label and its `$`-rooted path.
* @returns the detached JSON-shaped clone.
*/
function cloneJsonShaped(
root: Record<string, unknown>,
reject: (label: string, path: string) => TypeError,
): Record<string, unknown> {
const visiting = new WeakSet<object>()
const clone = (value: unknown, path: string): unknown => {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw reject('a non-finite number', path)
return value
}
if (Array.isArray(value)) {
if (visiting.has(value)) throw reject('a circular reference', path)
visiting.add(value)
const entries = value.map((entry, index) => clone(entry, `${path}[${index}]`))
// Un-mark on exit so one object referenced twice without a cycle passes.
visiting.delete(value)
return entries
}
if (isPlainObject(value)) {
if (visiting.has(value)) throw reject('a circular reference', path)
visiting.add(value)
// TODO(settings-json-properties): Use property-safe construction here and
// in mergeLayers so valid JSON keys such as "__proto__" remain own data.
const out: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value)) {
if (entry === undefined) continue
out[key] = clone(entry, `${path}.${key}`)
}
visiting.delete(value)
return out
}
throw reject(describeRejected(value), path)
}
return clone(root, '$') as Record<string, unknown>
}
/**
* Layer `over` onto `under`: plain objects merge recursively, every other
* value (arrays included) replaces the lower layer wholesale. `over` never
* carries `undefined` entries — sections come from parsed documents and write
* snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
* cannot erase lower keys.
*/
function mergeLayers(under: unknown, over: unknown): unknown {
if (over === undefined) return under
if (!isPlainObject(under) || !isPlainObject(over)) return over
const merged: Record<string, unknown> = { ...under }
for (const [key, value] of Object.entries(over)) {
merged[key] = key in merged ? mergeLayers(merged[key], value) : value
}
return merged
}
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
function deepFreeze<T>(value: T): T {
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
for (const entry of Object.values(value)) deepFreeze(entry)
return Object.freeze(value)
}
/** One registered watcher and its serialized invocation chain. */
interface SettingsWatcher {
callback: (next: never, prev: never) => void | Promise<void>
/** Settled tail: invocations of this callback run one at a time, in commit order. */
tail: Promise<void>
/** Cleared by the disposer: a queued invocation checks this before starting. */
active: boolean
}
/** One live namespace registration owned by a registrant fiber. */
interface SettingsRegistration {
ns: SettingsNamespace
schema: z<unknown>
base: unknown
applies: SettingsApplies
resolved: unknown
watchers: Set<SettingsWatcher>
}
/**
* Abstract settings service. Providers implement raw-document storage
* (`load`/`persist`) and push external changes through {@link Settings.publish};
* the base class owns namespace registration, resolution, validation, change
* detection, and the `settings/updated` commit event.
*/
export abstract class Settings extends Service {
private readonly registrations = new Map<SettingsNamespace, SettingsRegistration>()
/** Latest published raw document; empty until the provider's first publish. */
private document: Record<string, unknown> = {}
/** Per-namespace write chains; settled tails, so a failure never poisons the queue. */
private readonly writeQueues = new Map<SettingsNamespace, Promise<unknown>>()
/** In-flight watcher invocation segments, drained by the dispose teardown. */
private readonly pendingTails = new Set<Promise<void>>()
/** Set at service dispose: refuse new writes while queued ones drain. */
private stopped = false
/** Opaque read of {@link stopped}: control flow cannot narrow it across awaits. */
private isStopped(): boolean {
return this.stopped
}
constructor(ctx: Context) {
super(ctx, 'settings')
}
/**
* Load the provider's document once and publish it before the service
* becomes injectable, and register the write-drain teardown. Providers with
* their own init (watchers, connections) delegate here first via
* `yield* super[Service.init]()`; their disposers then run before the drain.
*/
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
yield async () => {
// Teardown: refuse new writes and new watcher starts, then wait until
// every queued write chain and every started watcher invocation settles
// so disposal completes only once storage and observers are quiescent.
// Invocations queued but not yet started skip via the stopped check.
this.stopped = true
await Promise.allSettled([...this.writeQueues.values(), ...this.pendingTails])
}
this.publish(await this.load())
}
/** Whether {@link update} may persist through this provider. */
abstract readonly writable: boolean
/**
* Read the provider's current raw document (namespace to raw section).
* @returns the detached raw document.
*/
protected abstract load(): Promise<Record<string, unknown>>
/**
* Durably store one namespace's merged user section.
* @param ns - the namespace being written.
* @param section - the complete merged user section to store.
*/
protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void>
/**
* Register a namespace schema and receive its owner scope. The registration
* is an effect on the calling plugin's fiber: disposing that fiber removes
* the namespace and its observers. An invalid stored section fails the
* registration itself — the earliest point where the schema can judge it.
* @param ns - unique namespace; duplicate registration fails loud.
* @param schema - schemastery schema resolving this namespace's value.
* @param options - composition `base` layer and effect timing.
* @returns the owner scope for reads, observation, and updates.
*/
register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T> {
if (this.registrations.has(ns)) {
throw new Error(`settings namespace "${ns}" is already registered`)
}
const registration: SettingsRegistration = {
ns,
schema: schema as z<unknown>,
base: options?.base,
applies: options?.applies ?? 'live',
resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns))),
watchers: new Set(),
}
this.ctx.effect(() => {
this.registrations.set(ns, registration)
// TODO(settings-registration-quiescence): Deactivate every watcher and await
// its tail on disposal so callbacks cannot outlive the registrant fiber.
return () => this.registrations.delete(ns)
}, `settings.register(${JSON.stringify(String(ns))})`)
return {
get: () => registration.resolved as T,
watch: (callback) => {
const watcher: SettingsWatcher = { callback: callback, tail: Promise.resolve(), active: true }
registration.watchers.add(watcher)
return () => {
watcher.active = false
registration.watchers.delete(watcher)
}
},
update: patch => this.update(ns, patch),
replace: section => this.replace(ns, section),
}
}
/**
* Describe every registered namespace for configuration surfaces.
* @returns one descriptor per registered namespace, in registration order.
*/
describe(): SettingsDescriptor[] {
return [...this.registrations.values()].map(registration => ({
ns: registration.ns,
schema: registration.schema.toJSON(),
value: registration.resolved,
applies: registration.applies,
}))
}
/**
* Read one registered namespace's resolved value.
* @param ns - the namespace to read.
* @returns the resolved value, or `undefined` while unregistered.
*/
get(ns: SettingsNamespace): unknown {
return this.registrations.get(ns)?.resolved
}
/**
* Merge a patch into one registered namespace's user layer, validate the
* resolved candidate, persist through the provider, then commit and emit.
* A validation failure rejects before anything is persisted. Writes to one
* namespace are serialized: concurrent updates apply in call order, each
* merging over the previous write's committed section.
* @param ns - the registered namespace to update.
* @param patch - plain-object patch over the user section.
*/
async update(ns: SettingsNamespace, patch: object): Promise<void> {
return this.write(ns, patch, 'merge')
}
/**
* Replace one registered namespace's user section wholesale, validate,
* persist, then commit and emit. Keys absent from `section` fall back to the
* composition `base` and schema defaults — this is the removal/reset path a
* merge-only patch cannot express (`replace({})` re-inherits everything).
* @param ns - the registered namespace to replace.
* @param section - the complete next user section.
*/
async replace(ns: SettingsNamespace, section: object): Promise<void> {
return this.write(ns, section, 'replace')
}
/** Validate a write, then queue it on the namespace's serialized write chain. */
private write(ns: SettingsNamespace, input: object, mode: 'merge' | 'replace'): Promise<void> {
const verb = mode === 'merge' ? 'update' : 'replace'
const registration = this.registrations.get(ns)
if (registration === undefined) {
throw new Error(`settings namespace "${ns}" is not registered`)
}
if (this.isStopped()) {
throw new Error(`settings service is disposed: "${ns}" cannot be written`)
}
if (!this.writable) {
throw new Error(`settings provider is read-only: "${ns}" cannot be updated in-process`)
}
if (!isPlainObject(input)) {
throw new TypeError(`settings ${verb} for "${ns}" must be a plain object`)
}
// Snapshot at call time: the queue must never read a caller-owned object
// the caller may keep mutating while the write waits its turn. The same
// walk is the JSON-shape boundary check (see cloneJsonShaped).
const snapshot = cloneJsonShaped(input, (label, path) =>
new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`))
const previous = this.writeQueues.get(ns) ?? Promise.resolve()
// Chain past a failed predecessor: one rejected write must not poison the
// namespace queue for every later caller.
const run = previous.catch(() => undefined).then(async () => {
if (this.isStopped()) {
throw new Error(`settings service was disposed before the queued "${ns}" ${verb} ran`)
}
if (this.registrations.get(ns) !== registration) {
throw new Error(`settings namespace "${ns}" registration was disposed before the queued ${verb} ran`)
}
const section = mode === 'merge'
? mergeLayers(this.section(ns) ?? {}, snapshot) as Record<string, unknown>
: snapshot
const next = deepFreeze(this.resolve(registration.schema, registration.base, section))
await this.persist(ns, section)
// The write reached storage either way; the cache must say so. Commit
// only when this registration is still the namespace owner — a fiber
// disposed (or replaced) mid-persist must not receive the notification.
this.document[ns] = section
// TODO(settings-replacement-resync): Re-resolve any replacement registration
// from this persisted section so an old in-flight write cannot leave it stale.
if (this.registrations.get(ns) === registration && !this.isStopped()) {
this.commit(registration, next, 'update')
}
})
this.writeQueues.set(ns, run)
return run
}
/**
* Provider hook: commit a complete raw document observed in storage. Each
* registered namespace re-resolves; an invalid section keeps that
* namespace's last good value and warns, other namespaces still commit.
* @param doc - the detached raw document (unregistered sections preserved).
* @param source - change origin; defaults to `provider`.
*/
protected publish(doc: Record<string, unknown>, source: SettingsUpdateSource = 'provider'): void {
this.document = doc
for (const registration of this.registrations.values()) {
let next: unknown
try {
next = deepFreeze(this.resolve(registration.schema, registration.base, this.section(registration.ns)))
} catch (error) {
this.ctx.logger.warn('settings: keeping last good "%s" after invalid stored section', registration.ns)
this.ctx.logger.warn(error)
continue
}
this.commit(registration, next, source)
}
}
/** Read one namespace's raw user section, rejecting non-object sections. */
private section(ns: SettingsNamespace): Record<string, unknown> | undefined {
const section = this.document[ns]
if (section === undefined) return undefined
if (!isPlainObject(section)) {
throw new TypeError(`settings section "${ns}" must be an object of keys`)
}
return section
}
/** Resolve one namespace value: schema defaults, then `base`, then the user layer. */
private resolve<T>(schema: z<T>, base: unknown, section: Record<string, unknown> | undefined): T {
// The merged candidate is untyped by construction; the schema call is the
// runtime validation that admits it into T.
return schema(mergeLayers(base, section) as never)
}
/** Commit a resolved value when changed: swap, notify watchers, emit the event. */
private commit(registration: SettingsRegistration, next: unknown, source: SettingsUpdateSource): void {
const prev = registration.resolved
if (deepEqualJson(next, prev)) return
registration.resolved = next
for (const watcher of [...registration.watchers]) {
// Serialize per watcher: invocations of one callback run one at a time
// in commit order, so a slow stale invocation can never apply after a
// newer one. Sync throws and async rejections land in the same handler.
// The activity check runs when the queued invocation would start, so a
// disposer (or service stop) that ran while it waited prevents the
// start entirely; started invocations drain at service dispose.
const segment = watcher.tail
.then(() => {
if (!watcher.active || this.isStopped()) return
return watcher.callback(next as never, prev as never)
})
.then(() => undefined, (error: unknown) => {
this.warnWatcherFailure(registration.ns, error)
})
watcher.tail = segment
this.pendingTails.add(segment)
void segment.then(() => this.pendingTails.delete(segment))
}
// Fan the event out one listener at a time (the plain emit stops at the
// first throwing listener, starving the rest). Invariant violations are
// harness-fatal by design and rethrow after every listener ran; any other
// failure is contained so one broken observer cannot wedge the commit
// path (and, through it, a provider's reload loop).
let invariantFailure: unknown
const args = ['settings/updated', registration.ns, next, prev, source]
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
try {
const returned = listener(registration.ns, next, prev, source)
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
// An emit listener may still be an async function; its rejection
// cannot reach the synchronous INVARIANT rethrow below, so it is
// contained here instead of becoming an unhandled rejection.
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
this.warnListenerFailure(registration.ns, error)
})
}
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
invariantFailure ??= error
continue
}
this.warnListenerFailure(registration.ns, error)
}
}
if (invariantFailure !== undefined) throw invariantFailure as Error
}
/** Contained-watcher diagnostic shared by the sync and async failure paths. */
private warnWatcherFailure(ns: SettingsNamespace, error: unknown): void {
this.ctx.logger.warn('settings: watcher for "%s" failed', ns)
this.ctx.logger.warn(error)
}
/** Contained-listener diagnostic shared by the sync and async failure paths. */
private warnListenerFailure(ns: SettingsNamespace, error: unknown): void {
this.ctx.logger.warn('settings: a settings/updated listener for "%s" failed', ns)
this.ctx.logger.warn(error)
}
}
export default Settings

View File

@@ -0,0 +1,48 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-settings`.
* @module @deepseek-ai/dsh-settings/invariant
*/
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { deepEqualJson } from './index.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-settings'
/** Cordis companion plugin name. */
export const name = 'settings-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Install the commit-event contract: `settings/updated` fires only for a
* currently registered namespace, only when the resolved value changed, and
* only with the service's authoritative resolved value — all judged with the
* seam's own equality predicate.
*/
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
ctx.on('settings/updated', (ns, next, prev) => {
const settings = ctx.get('settings')
if (settings === undefined) {
fail(`settings/updated for "${ns}" emitted without a live settings service`)
}
const current = settings.get(ns)
if (current === undefined) {
fail(`settings/updated for "${ns}" emitted while the namespace is unregistered`)
}
if (!deepEqualJson(current, next)) {
fail(`settings/updated for "${ns}" does not match the authoritative resolved value`)
}
if (deepEqualJson(next, prev)) {
fail(`settings/updated for "${ns}" emitted without a resolved-value change`)
}
})
}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SettingsInvariant from '../src/invariant.ts'
import { settingsNamespace } from '../src/index.ts'
import { MemorySettings } from './memory.ts'
async function setup(withProvider: boolean): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(SettingsInvariant)
if (withProvider) await ctx.plugin(MemorySettings)
return ctx
}
describe('settings invariants', () => {
it('fails a settings/updated emission without a live settings service', async () => {
const ctx = await setup(false)
expect(() => {
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
}).toThrow(/without a live settings service/)
})
it('fails a settings/updated emission for an unregistered namespace', async () => {
const ctx = await setup(true)
expect(() => {
ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
}).toThrow(/unregistered/)
})
it('fails a settings/updated emission without a resolved-value change', async () => {
const ctx = await setup(true)
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
theme: z.string().default('dark'),
}))
expect(() => {
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update')
}).toThrow(/without a resolved-value change/)
})
it('fails a settings/updated emission whose value diverges from the authoritative state', async () => {
const ctx = await setup(true)
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
theme: z.string().default('dark'),
}))
// Fabricated next ≠ the service's current resolved value ({theme: 'dark'}).
expect(() => {
ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'forged' }, { theme: 'dark' }, 'update')
}).toThrow(/authoritative/)
})
})

View File

@@ -0,0 +1,54 @@
/**
* In-memory settings provider fixture: the smallest real subclass of the seam,
* used by the base-class behavior suite in place of a file- or network-backed
* provider. Kept in `tests/` because production providers live in their own
* packages.
*/
import { Settings, type SettingsNamespace } from '../src/index.ts'
/** In-memory provider exposing the protected seam hooks to tests. */
export class MemorySettings extends Settings {
/** Raw document the provider "storage" currently holds. */
doc: Record<string, unknown>
/** Every persist() call observed, in order. */
persisted: Array<{ ns: SettingsNamespace; section: Record<string, unknown> }> = []
/** When false, update() must reject before reaching persist(). */
writableFlag: boolean
/** Artificial persist latency so tests can interleave concurrent updates. */
persistDelayMs: number
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
doc?: Record<string, unknown>
writable?: boolean
persistDelayMs?: number
}) {
super(ctx)
this.doc = structuredClone(options?.doc ?? {})
this.writableFlag = options?.writable ?? true
this.persistDelayMs = options?.persistDelayMs ?? 0
}
get writable(): boolean {
return this.writableFlag
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected async persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
if (this.persistDelayMs > 0) {
await new Promise(resolve => setTimeout(resolve, this.persistDelayMs))
}
this.persisted.push({ ns, section: structuredClone(section) })
this.doc[ns] = structuredClone(section)
}
/** Simulate an external storage change reaching the provider. */
pushExternal(doc: Record<string, unknown>): void {
this.doc = structuredClone(doc)
this.publish(structuredClone(doc))
}
}

View File

@@ -0,0 +1,654 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
import { MemorySettings } from './memory.ts'
/** A provider implementing only the three primitives: the seam owns init. */
class BareProvider extends Settings {
doc: Record<string, unknown>
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown> }) {
super(ctx)
this.doc = structuredClone(options?.doc ?? {})
}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc[ns] = structuredClone(section)
return Promise.resolve()
}
}
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
interface NestedConfig {
retry: { attempts: number; delayMs: number }
tags: string[]
}
const NestedSchema: z<NestedConfig> = z.object({
retry: z.object({
attempts: z.number().default(2),
delayMs: z.number().default(100),
}),
tags: z.array(z.string()).default(['default']),
})
async function boot(options?: ConstructorParameters<typeof MemorySettings>[1]) {
const ctx = new Context()
const fiber = ctx.plugin(MemorySettings, options)
await fiber
const provider = ctx.get('settings') as MemorySettings
return { ctx, provider, fiber }
}
/** Record every settings/updated emission. */
function recordUpdates(ctx: Context) {
const events: Array<{ ns: string; next: unknown; prev: unknown; source: SettingsUpdateSource }> = []
ctx.on('settings/updated', (ns, next, prev, source) => {
events.push({ ns, next, prev, source })
})
return events
}
describe('settingsNamespace', () => {
it('brands lowercase kebab-case names', () => {
expect(settingsNamespace('ui-theme')).toBe('ui-theme')
})
it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => {
expect(() => settingsNamespace(value)).toThrow(TypeError)
})
})
describe('registration', () => {
it('resolves schema defaults, then composition base, then the user layer', async () => {
const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
// theme: user layer wins; fontSize: base wins over the schema default.
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
})
it('rejects a duplicate namespace loud', async () => {
const { ctx } = await boot()
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
.toThrow(/already registered/)
})
it('fails registration when the stored section is invalid for the schema', async () => {
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
})
it('fails registration when the stored section is not an object', async () => {
const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } })
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
.toThrow(/must be an object/)
})
it('describes registered namespaces with schema JSON, value, and applies', async () => {
const { ctx } = await boot()
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' })
const descriptors = ctx.settings.describe()
expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([
['ui-theme', 'live'],
['workspace', 'restart'],
])
expect(descriptors[0]!.value).toEqual({ theme: 'dark', fontSize: 14 })
// schemastery's canonical wire form: a { uid, refs } envelope whose root ref
// is the object schema — the shape schema-driven form UIs reconstruct from.
const serialized = descriptors[0]!.schema as { uid: number; refs: Record<string, { type: string }> }
expect(serialized.refs[String(serialized.uid)]?.type).toBe('object')
})
it('reads undefined for an unregistered namespace', async () => {
const { ctx } = await boot()
expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined()
})
it('hands out frozen resolved values', async () => {
const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } })
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
const value = scope.get()
expect(Object.isFrozen(value)).toBe(true)
expect(Object.isFrozen(value.retry)).toBe(true)
expect(() => { (value.retry as { attempts: number }).attempts = 0 }).toThrow(TypeError)
})
it('removes the namespace and its observers when the registrant fiber disposes', async () => {
const { ctx, provider } = await boot()
const seen: unknown[] = []
let scope: SettingsScope<ThemeConfig> | undefined
const fiber = ctx.plugin({
inject: ['settings'],
apply: (child: Context) => {
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
scope.watch((next) => { seen.push(next) })
},
})
await fiber
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 })
await fiber.dispose()
expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined()
expect(ctx.settings.describe()).toEqual([])
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(seen).toEqual([])
// The namespace is free again, and re-registration resolves the user layer
// that kept living in storage while nobody owned the namespace.
const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(again.get()).toEqual({ theme: 'light', fontSize: 14 })
})
})
describe('update', () => {
it('persists the merged user section without baking in the base layer', async () => {
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
await scope.update({ theme: 'dark' })
expect(provider.persisted).toEqual([
{ ns: 'ui-theme', section: { theme: 'dark' } },
])
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
})
it('deep-merges nested objects and replaces arrays wholesale', async () => {
const { ctx, provider } = await boot({
doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } },
})
const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
await scope.update({ retry: { attempts: 7 }, tags: ['c'] })
expect(provider.persisted[0]!.section).toEqual({
retry: { attempts: 7, delayMs: 300 },
tags: ['c'],
})
expect(scope.get()).toEqual({ retry: { attempts: 7, delayMs: 300 }, tags: ['c'] })
})
it('commits, notifies watchers, and emits with source update', async () => {
const { ctx } = await boot()
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
scope.watch(watcher)
await scope.update({ theme: 'light' })
expect(watcher).toHaveBeenCalledWith(
{ theme: 'light', fontSize: 14 },
{ theme: 'dark', fontSize: 14 },
)
expect(events).toEqual([{
ns: 'ui-theme',
next: { theme: 'light', fontSize: 14 },
prev: { theme: 'dark', fontSize: 14 },
source: 'update',
}])
})
it('rejects an invalid patch before persisting anything', async () => {
const { ctx, provider } = await boot()
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(scope.update({ fontSize: 'big' })).rejects.toThrow()
expect(provider.persisted).toEqual([])
expect(events).toEqual([])
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
// The failed write must not poison the namespace queue for later writers.
await scope.update({ fontSize: 18 })
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => {
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await scope.update({ theme: undefined, fontSize: 18 })
expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 })
expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 })
})
it('rejects a non-object patch', async () => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(scope.update([1])).rejects.toThrow(TypeError)
await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError)
await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/)
})
it('accepts a null-prototype patch object', async () => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number }
patch.fontSize = 18
await scope.update(patch)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
it('rejects an unregistered namespace', async () => {
const { ctx } = await boot()
await expect(ctx.settings.update(settingsNamespace('missing'), {}))
.rejects.toThrow(/not registered/)
})
it('rejects on a read-only provider before reaching persist', async () => {
const { ctx, provider } = await boot({ writable: false })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/)
expect(provider.persisted).toEqual([])
})
})
describe('deepEqualJson', () => {
it.each([
[{ a: [1, 2] }, { a: [1, 2] }, true],
[{ a: [1, 2] }, { a: [1] }, false],
[{ a: [1] }, { a: { 0: 1 } }, false],
[{ a: 1 }, { b: 1 }, false],
[{ a: 1 }, {}, false],
[{ a: null }, { a: null }, true],
[{ a: null }, { a: {} }, false],
])('compares %j vs %j as %s', (a, b, equal) => {
expect(deepEqualJson(a, b)).toBe(equal)
})
})
describe('review regressions', () => {
it('propagates an invariant-coded listener failure instead of containing it', async () => {
const { ctx, provider } = await boot()
ctx.on('settings/updated', () => {
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) })
.toThrow(/forged relation/)
})
it('serializes concurrent updates so neither patch is lost', async () => {
const { ctx, provider } = await boot({ persistDelayMs: 10 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await Promise.all([
scope.update({ theme: 'light' }),
scope.update({ fontSize: 20 }),
])
expect(provider.doc['ui-theme']).toEqual({ theme: 'light', fontSize: 20 })
expect(scope.get()).toEqual({ theme: 'light', fontSize: 20 })
})
it('contains a throwing settings/updated listener and keeps later commits alive', async () => {
const { ctx, provider } = await boot()
ctx.on('settings/updated', () => {
throw new Error('listener boom')
})
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow()
expect(scope.get().theme).toBe('light')
provider.pushExternal({ 'ui-theme': { theme: 'dark' } })
expect(scope.get().theme).toBe('dark')
})
it('contains an async watcher rejection', async () => {
const { ctx, provider } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
scope.watch(async () => {
throw new Error('async watcher boom')
})
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(scope.get().theme).toBe('light')
// Give the rejected watcher promise a microtask turn; containment means
// vitest observes no unhandled rejection out of this test.
await new Promise(resolve => setTimeout(resolve, 10))
})
it('loads the provider document through the base init without provider boilerplate', async () => {
const ctx = new Context()
await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 })
})
it('replaces the user section wholesale so overrides can be removed', async () => {
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
base: { fontSize: 16 },
})
await scope.replace({ theme: 'light' })
// fontSize override is gone: resolution falls back to the base layer.
expect(scope.get()).toEqual({ theme: 'light', fontSize: 16 })
expect(provider.doc['ui-theme']).toEqual({ theme: 'light' })
await scope.replace({})
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
expect(provider.doc['ui-theme']).toEqual({})
})
})
describe('second review regressions', () => {
it('runs every settings/updated listener even when an earlier one throws', async () => {
const { ctx, provider } = await boot()
ctx.on('settings/updated', () => {
throw new Error('first listener boom')
})
const second = vi.fn()
ctx.on('settings/updated', second)
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(second).toHaveBeenCalledTimes(1)
})
it('rejects an update queued after the registrant fiber disposed', async () => {
const { ctx } = await boot()
let scope: SettingsScope<ThemeConfig> | undefined
const fiber = ctx.plugin({
inject: ['settings'],
apply: (child: Context) => {
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
},
})
await fiber
await fiber.dispose()
await expect(scope!.update({ theme: 'light' })).rejects.toThrow(/disposed|not registered/)
})
it('does not notify a registrant disposed while its update was in flight', async () => {
const { ctx, provider } = await boot({ persistDelayMs: 30 })
const events = recordUpdates(ctx)
let scope: SettingsScope<ThemeConfig> | undefined
const watcher = vi.fn()
const fiber = ctx.plugin({
inject: ['settings'],
apply: (child: Context) => {
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
scope.watch(watcher)
},
})
await fiber
const pending = scope!.update({ theme: 'light' })
await new Promise(resolve => setTimeout(resolve, 5))
await fiber.dispose()
await pending.catch(() => undefined)
await new Promise(resolve => setTimeout(resolve, 10))
expect(watcher).not.toHaveBeenCalled()
expect(events).toEqual([])
// The persist was already in flight, so storage keeps the write — but no
// commit reached the disposed registration.
expect(provider.doc['ui-theme']).toEqual({ theme: 'light' })
})
it('drains in-flight writes at service dispose and rejects later ones', async () => {
const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 })
const service = ctx.settings
const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema)
const pending = scope.update({ theme: 'light' })
await new Promise(resolve => setTimeout(resolve, 5))
await fiber.dispose()
// The teardown drained the in-flight write before completing…
await pending.catch(() => undefined)
const persistedAtDispose = provider.persisted.length
expect(persistedAtDispose).toBe(1)
// …and afterwards nothing writes and new writes reject.
await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' }))
.rejects.toThrow(/disposed|not registered/)
await new Promise(resolve => setTimeout(resolve, 40))
expect(provider.persisted.length).toBe(persistedAtDispose)
})
it('serializes invocations of one async watcher in commit order', async () => {
const { ctx, provider } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const applied: number[] = []
let firstCall = true
scope.watch(async (next) => {
// The first (stale) invocation is slow; unserialised it would finish
// last and clobber the newer applied state.
const delay = firstCall ? 30 : 0
firstCall = false
await new Promise(resolve => setTimeout(resolve, delay))
applied.push(next.fontSize)
})
provider.pushExternal({ 'ui-theme': { fontSize: 1 } })
provider.pushExternal({ 'ui-theme': { fontSize: 2 } })
await vi.waitFor(() => {
expect(applied).toHaveLength(2)
})
expect(applied).toEqual([1, 2])
})
it('rejects a function value as not JSON-shaped', async () => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
await expect(scope.update({ theme: () => 'dark' }))
.rejects.toThrow(/JSON-shaped.*function at \$\.theme/)
})
it('rejects a write still queued when the service disposes', async () => {
const { ctx, fiber } = await boot({ persistDelayMs: 20 })
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const first = scope.update({ theme: 'light' })
const second = scope.update({ fontSize: 20 })
await new Promise(resolve => setTimeout(resolve, 5))
await fiber.dispose()
await first
await expect(second).rejects.toThrow(/disposed before the queued/)
})
it('rejects a write still queued when the registrant disposes', async () => {
const { ctx } = await boot({ persistDelayMs: 20 })
let scope: SettingsScope<ThemeConfig> | undefined
const fiber = ctx.plugin({
inject: ['settings'],
apply: (child: Context) => {
scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
},
})
await fiber
const first = scope!.update({ theme: 'light' })
const second = scope!.update({ fontSize: 20 })
await new Promise(resolve => setTimeout(resolve, 5))
await fiber.dispose()
await first
await expect(second).rejects.toThrow(/registration was disposed before the queued/)
})
it('snapshots the patch at call time so caller mutation cannot leak in', async () => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const patch = { fontSize: 18 }
const pending = scope.update(patch)
patch.fontSize = 99
await pending
expect(scope.get().fontSize).toBe(18)
})
})
describe('publish', () => {
it('notifies watchers of an external change with source provider', async () => {
const { ctx, provider } = await boot()
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
scope.watch(watcher)
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
await vi.waitFor(() => {
expect(watcher).toHaveBeenCalledWith(
{ theme: 'light', fontSize: 14 },
{ theme: 'dark', fontSize: 14 },
)
})
expect(events[0]!.source).toBe('provider')
})
it('stays silent when the resolved value is deep-equal', async () => {
const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
scope.watch(watcher)
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(watcher).not.toHaveBeenCalled()
expect(events).toEqual([])
})
it('keeps the last good value for an invalid section while other namespaces commit', async () => {
const { ctx, provider } = await boot()
const events = recordUpdates(ctx)
const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
provider.pushExternal({
'ui-theme': { fontSize: 'broken' },
workspace: { retry: { attempts: 9 } },
})
expect(theme.get()).toEqual({ theme: 'dark', fontSize: 14 })
expect(workspace.get()).toEqual({ retry: { attempts: 9, delayMs: 100 }, tags: ['default'] })
expect(events.map(event => event.ns)).toEqual(['workspace'])
})
it('recovers from a bad section once storage turns valid again', async () => {
const { ctx, provider } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } })
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
provider.pushExternal({ 'ui-theme': { fontSize: 18 } })
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
})
})
describe('third review regressions', () => {
it('skips a queued watch invocation whose disposer ran before it started', async () => {
const { ctx, provider } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
const dispose = scope.watch(watcher)
// The commit chains the invocation as a microtask; the disposer runs in
// the same synchronous frame, before that invocation could start.
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
dispose()
await new Promise(resolve => setTimeout(resolve, 10))
expect(watcher).not.toHaveBeenCalled()
})
it('waits for an in-flight watch invocation at service dispose', async () => {
const { ctx, provider, fiber } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
let release: (() => void) | undefined
let finished = false
scope.watch(async () => {
await new Promise<void>((resolve) => { release = resolve })
finished = true
})
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
await vi.waitFor(() => { expect(release).toBeDefined() })
let disposed = false
const disposal = fiber.dispose().then(() => { disposed = true })
await new Promise(resolve => setTimeout(resolve, 15))
expect(disposed).toBe(false)
release!()
await disposal
expect(finished).toBe(true)
})
it('rejects a Date at its path before anything persists', async () => {
const { ctx, provider } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
await expect(scope.update({ value: { at: new Date(0) } }))
.rejects.toThrow(/JSON-shaped.*Date at \$\.value\.at/)
expect(provider.persisted).toEqual([])
})
it.each([
['a Map', { value: new Map() }, /Map at \$\.value/],
['a bigint', { value: [10n] }, /bigint at \$\.value\[0\]/],
['a symbol', { value: Symbol('x') }, /symbol at \$\.value/],
['a non-finite number', { value: Number.NaN }, /non-finite number at \$\.value/],
['an undefined array entry', { value: [undefined] }, /undefined at \$\.value\[0\]/],
['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/],
])('rejects %s that structuredClone would admit', async (_label, patch, message) => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
await expect(scope.update(patch)).rejects.toThrow(message)
})
it('rejects a circular patch instead of storing an alias-looped document', async () => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
const cyclic: Record<string, unknown> = {}
cyclic['self'] = cyclic
await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/)
const loop: unknown[] = []
loop.push(loop)
await expect(scope.update({ value: loop })).rejects.toThrow(/circular reference at \$\.value\[0\]/)
})
it('accepts one object referenced twice without a cycle', async () => {
const { ctx } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
const shared = { leaf: 1 }
await scope.update({ value: { left: shared, right: shared } })
expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } })
})
it('contains an async settings/updated listener rejection and keeps other listeners running', async () => {
const { ctx, provider } = await boot()
// An async listener violates the event's synchronous signature, but an
// unlinted JS plugin can still register one. Declaring the return as
// unknown keeps this file's typed surface legal (unknown-returning
// functions are assignable to void positions) while the runtime value is
// still the rejected promise the containment guard must handle.
const boom = (): unknown => Promise.reject(new Error('async listener boom'))
ctx.on('settings/updated', boom)
const second = vi.fn()
ctx.on('settings/updated', second)
ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(second).toHaveBeenCalledTimes(1)
// Containment gives the rejection a handler; vitest observes no unhandled
// rejection out of this test.
await new Promise(resolve => setTimeout(resolve, 10))
})
})
describe('watch', () => {
it('stops after its disposer runs', async () => {
const { ctx, provider } = await boot()
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
const watcher = vi.fn()
const dispose = scope.watch(watcher)
dispose()
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
expect(watcher).not.toHaveBeenCalled()
})
it('contains a throwing watcher without blocking the commit or other watchers', async () => {
const { ctx, provider } = await boot()
const events = recordUpdates(ctx)
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
scope.watch(() => { throw new Error('watcher boom') })
const second = vi.fn()
scope.watch(second)
provider.pushExternal({ 'ui-theme': { theme: 'light' } })
await vi.waitFor(() => {
expect(second).toHaveBeenCalledTimes(1)
})
expect(events).toHaveLength(1)
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -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/tui/README.md
README.md: dc8af7796d62ca1588423dc63aa592fd3308d218
README.zh.md: 8e5fb632d8903c1915015396af20a791a9a3ab70
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d

View File

@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.

View File

@@ -12,7 +12,7 @@ DeepSeek Harness agent智能体的交互式终端入口基于 [`@earend
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect因此卸载会移除排队工作或在清理结算前关闭可见工作终端关闭会先卸载依赖项再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript使经过压缩compaction的历史不会再次出现
TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript被它遮蔽的对话仍可阅读而已落地的压缩compaction检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`

View File

@@ -35,6 +35,7 @@
"@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-compact": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
@@ -74,6 +75,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",

View File

@@ -1,6 +1,6 @@
/**
* Zero-state helpers for the interactive chat channel: prompt-directory and
* Git-branch formatting, surface/tool-call derivations over the session log,
* Git-branch formatting, transcript/tool-call derivations over the session log,
* session-reference context cards, the placeholder editor, and banner-reveal
* timing constants. None of these close over channel state.
* @module @deepseek-ai/dsh-tui/chat/helpers
@@ -15,7 +15,9 @@ import {
truncateToWidth,
visibleWidth,
} from '@earendil-works/pi-tui'
import type { Session } from '@deepseek-ai/dsh-session'
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
/** Editor that shows a placeholder without making it editable content. */
@@ -81,24 +83,16 @@ export function gitBranch(cwd: string): string | undefined {
}
/**
* Sequence numbers currently visible on the session surface.
* @param session - session whose surface nodes to read.
* @returns the set of visible event sequence numbers.
*/
export function activeSurfaceSeqs(session: Session): Set<number> {
return new Set(session.surface.nodes)
}
/**
* Tool-call ids whose owning assistant message is on the active surface.
* Tool-call ids whose owning assistant message is append-origin, so its tool
* cards stay paired in the transcript after a replacement shadowed the message
* on the model surface.
* @param session - session whose events to scan.
* @param active - sequence numbers currently on the surface.
* @returns the set of active tool-call ids.
* @returns the set of transcript tool-call ids.
*/
export function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
export function transcriptToolCallIds(session: Session): Set<string> {
const ids = new Set<string>()
for (const event of session.events) {
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue
for (const block of event.data.message.content) {
if (block.type === 'tool-call') ids.add(block.id)
}
@@ -106,6 +100,26 @@ export function activeToolCallIds(session: Session, active: ReadonlySet<number>)
return ids
}
/**
* Whether an event is a landed compaction checkpoint. Recognition goes through
* {@link isCompactCheckpointSource} — the compaction seam's backend-independent
* contract for the source every backend stamps on its replacement user message —
* rather than the shape of the replacement. Other replacements (a pruned
* `tool/result`, a regenerated `assistant/message`) rewrite one node for the
* model and mark no boundary in the conversation.
*
* Both current call sites already test the replacement themselves. The check
* keeps the exported predicate true to its name for a third caller, rather than
* making that caller repeat it.
* @param event - event to test.
* @returns true when the event compacted a surface range.
*/
export function isCompactCheckpoint(event: SessionEvent): boolean {
return event.type === 'user/message'
&& isCompactCheckpointSource(event.data.source)
&& isReplacementSurfaceEvent(event)
}
/**
* Read a session-reference context card's display labels from an event source.
* @param source - event source to inspect.

View File

@@ -35,6 +35,7 @@ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
isReplacementSurfaceEvent,
lastActivityTime,
SessionId,
type SessionEvent,
@@ -120,14 +121,14 @@ import {
} from './chat/skill-invocation.ts'
import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts'
import {
activeSurfaceSeqs,
activeToolCallIds,
BANNER_REVEAL_INTERVAL_MS,
BANNER_REVEAL_STEPS,
formatCwd,
gitBranch,
HintEditor,
isCompactCheckpoint,
sessionReferenceCard,
transcriptToolCallIds,
} from './chat/helpers.ts'
import {
createModelController,
@@ -261,6 +262,13 @@ export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'too
/** Model guidance for path-only file references selected through the TUI. */
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
/**
* Transcript row standing in for one compacted range. The conversation the
* compaction replaced stays rendered above it: the marker reports where the
* model stopped seeing that history, not that the history is gone.
*/
const COMPACTION_MARKER = '… earlier context was compacted …'
interface RunningStatus {
turn: number | undefined
timer: ReturnType<typeof setInterval>
@@ -808,6 +816,23 @@ export function createTuiChat(
}
}
const renderCompactionMarker = (): void => {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(COMPACTION_MARKER), 0, 0))
}
/**
* Replay the human transcript from the append-only log. The model-visible
* surface shadows compacted ranges, so it is not the source here: every
* append-origin message stays rendered, and a replacement contributes at most
* the compaction marker at its own log position.
*
* The `tool/call` pairing check has no live counterpart, because only replay
* can meet an orphan: `tool/call` carries no `surfaceOp` of its own, so it
* inherits transcript membership from the `assistant/message` that advertised
* it, which the live listener has necessarily just rendered. A loaded log is a
* replay boundary, so the pairing is re-derived here instead of assumed.
*/
const rebuildTranscript = (populateHistory: boolean): void => {
chat.clear()
toolCards.clear()
@@ -815,15 +840,13 @@ export function createTuiChat(
contextCards.clear()
streaming = undefined
todo.update([])
const active = activeSurfaceSeqs(agent.session)
const activeCalls = activeToolCallIds(agent.session, active)
const transcriptCalls = transcriptToolCallIds(agent.session)
for (const event of agent.session.events) {
const isSurface = event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message'
if (isSurface && !active.has(event.seq)) continue
if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
if (isReplacementSurfaceEvent(event)) {
if (isCompactCheckpoint(event)) renderCompactionMarker()
continue
}
if (event.type === 'tool/call' && !transcriptCalls.has(event.data.callId)) continue
renderEvent(event, { addHistory: populateHistory, renderChunks: false })
}
requestRender()
@@ -1475,8 +1498,11 @@ export function createTuiChat(
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
rebuildTranscript(false)
// A replacement mutates only the model surface, so the rendered transcript
// keeps what it already showed; a landed summary checkpoint adds its marker.
if (isReplacementSurfaceEvent(event)) {
if (isCompactCheckpoint(event)) renderCompactionMarker()
requestRender()
return
}
renderEvent(event, { addHistory: false, renderChunks: true })

View File

@@ -1,7 +1,7 @@
terminal 44x18 buffer=normal length=18 base=0 viewport=0
terminal 44x18 buffer=normal length=24 base=6 viewport=6
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=14 bufferRow=14
cursor hidden column=7 viewportRow=17 bufferRow=23
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -13,25 +13,39 @@ buffer
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
5| <blank>
6| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises "
8| "wrapping and stays visible after compaction."
9| <blank>
10| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
11| "$ pnpm run test:coverage "
style 0-23 dim
12| "/workspace/project "
style 0-17 dim
13| "packages/ui/tui 100% "
style 0-19 dim
14| "… +1 lines (Ctrl+O to expand) "
style 0-28 dim
15| "1 test skipped "
style 0-13 dim
16| "coverage complete "
style 0-16 dim
17| "[exit 0] "
style 0-7 dim
18| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Context · workspace-context"
style 0-26 dim
8| "Additional instructions from: "
style 0-43 dim
9| "nested/AGENTS.md "
style 0-15 dim
10| " "
11| "Render workspace context XML clearly. "
style 0-36 dim
12| <blank>
13| "/workspace/project (tui-staging) deepseek-v"
19| <blank>
20| "… earlier context was compacted … "
style 0-32 dim
21| <blank>
22| "/workspace/project (tui-staging) deepseek-v"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-43 dim
14| " dsh > "
23| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
15-17| <blank>

View File

@@ -1,7 +1,7 @@
terminal 104x30 buffer=normal length=30 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=13 bufferRow=13
cursor hidden column=7 viewportRow=22 bufferRow=22
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -13,25 +13,41 @@ buffer
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
5| <blank>
6| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
8| <blank>
9| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
10| "$ pnpm run test:coverage "
style 0-23 dim
11| "/workspace/project "
style 0-17 dim
12| "packages/ui/tui 100% "
style 0-19 dim
13| "… +1 lines (Ctrl+O to expand) "
style 0-28 dim
14| "1 test skipped "
style 0-13 dim
15| "coverage complete "
style 0-16 dim
16| "[exit 0] "
style 0-7 dim
17| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Context · workspace-context"
style 0-26 dim
8| "Additional instructions from: nested/AGENTS.md "
style 0-45 dim
9| " "
10| "Render workspace context XML clearly. "
style 0-36 dim
11| <blank>
12| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
18| <blank>
19| "… earlier context was compacted … "
style 0-32 dim
20| <blank>
21| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-57 dim
style 60-69 dim
13| " dsh > "
22| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
14-29| <blank>
23-29| <blank>

View File

@@ -1,7 +1,7 @@
terminal 80x24 buffer=normal length=24 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=20 bufferRow=20
cursor hidden column=7 viewportRow=21 bufferRow=21
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -16,35 +16,36 @@ buffer
5| <blank>
6| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping before compaction. "
8| <blank>
9| "● Tool / bash / Run the coverage gate"
7| "Old prompt with a long line that exercises wrapping and stays visible after "
8| "compaction. "
9| <blank>
10| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
10| "$ pnpm run test:coverage "
11| "$ pnpm run test:coverage "
style 0-23 dim
11| "/workspace/project "
12| "/workspace/project "
style 0-17 dim
12| "packages/ui/tui 100% "
13| "packages/ui/tui 100% "
style 0-19 dim
13| "… +1 lines (Ctrl+O to expand) "
14| "… +1 lines (Ctrl+O to expand) "
style 0-28 dim
14| "1 test skipped "
15| "1 test skipped "
style 0-13 dim
15| "coverage complete "
16| "coverage complete "
style 0-16 dim
16| "[exit 0] "
17| "[exit 0] "
style 0-7 dim
17| "Model wait 0.0s "
18| "Model wait 0.0s "
style 0-14 dim
18| <blank>
19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
19| <blank>
20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-57 dim
style 60-69 dim
20| " dsh > "
21| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
21-23| <blank>
22-23| <blank>

View File

@@ -0,0 +1,53 @@
terminal 104x30 buffer=normal length=30 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=22 bufferRow=22
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| <blank>
6| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises wrapping and stays visible after compaction. "
8| <blank>
9| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
10| "$ pnpm run test:coverage "
style 0-23 dim
11| "/workspace/project "
style 0-17 dim
12| "packages/ui/tui 100% "
style 0-19 dim
13| "… +1 lines (Ctrl+O to expand) "
style 0-28 dim
14| "1 test skipped "
style 0-13 dim
15| "coverage complete "
style 0-16 dim
16| "[exit 0] "
style 0-7 dim
17| "Model wait 0.0s "
style 0-14 dim
18| <blank>
19| "… earlier context was compacted … "
style 0-32 dim
20| <blank>
21| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-50 dim
style 53-57 dim
style 60-69 dim
22| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
23-29| <blank>

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
import { createUserMessage, CallId, type ContentBlock , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type JsonValue, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -50,6 +51,7 @@ const CHECKPOINTS = [
'surface-before-compaction',
'surface-after-compaction-narrow',
'surface-after-compaction-wide',
'surface-replayed-compaction',
'model-selector',
'model-selector-filtered',
'model-switching',
@@ -181,6 +183,67 @@ function appendToolResult(
}, { surfaceOp: 'append' })
}
/** Frozen clock for the compaction fixtures; see the live scenario for why. */
const COMPACTION_FIXTURE_TIME = new Date(2026, 6, 21, 14, 40, 0).getTime()
/** The surface range a compaction checkpoint replaces, with its provenance. */
interface CompactionRange {
start: number
end: number
sources: number[]
}
/**
* Append one prompt / tool-call / tool-result step, the history a compaction
* shadows on the model surface and the transcript must keep showing. The prompt
* text is rendered verbatim; the tool card's body comes from `bash`'s static
* presenter, so the fixtures pin that the shadowed step's card survives rather
* than the result content below.
*/
function appendPreCompactionLog(session: Session): CompactionRange {
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping and stays visible after compaction.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const assistant = session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'shadowed step tool output' }],
isError: false,
}),
}, { surfaceOp: 'append' })
return { start: user.seq, end: result.seq, sources: [user.seq, assistant.seq, result.seq] }
}
/** Land a compaction: replace the range with the framed model-only checkpoint. */
function appendCompactionCheckpoint(session: Session, range: CompactionRange): void {
session.append('user/message', createUserMessage({
content: [{
type: 'text',
text: '<context_checkpoint>\nModel-only summary payload that must never reach the transcript.\n</context_checkpoint>',
}],
source: COMPACT_CHECKPOINT_SOURCE,
}), {
surfaceOp: { op: 'replace', start: range.start, end: range.end },
sourceEventSeqs: range.sources,
})
}
function visualTool(
name: string,
call: NonNullable<ToolDefinition['presentCall']>,
@@ -684,61 +747,22 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('pins compaction surface replacement and narrow-to-wide reflow', async () => {
it('pins preserved history, the compaction marker, and narrow-to-wide reflow', async () => {
// Freeze the clock: the timing header hides zero-duration buckets, so a
// real-clock millisecond tick between the fixture appends and the render
// would flip `Tools 0.0s` in and out of the pinned header.
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime())
let replacementStart = 0
let replacementEnd = 0
let replacementSources: number[] = []
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME)
// The awaited setup always invokes beforeMount, so the range the checkpoint
// replaces is assigned by the time the appends below need it.
let compacted!: CompactionRange
const harness = await setupSnapshot({
tools: ADVANCED_CARD_TOOLS,
beforeMount(session) {
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const assistant = session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
isError: false,
}),
}, { surfaceOp: 'append' })
replacementStart = user.seq
replacementEnd = result.seq
replacementSources = [user.seq, assistant.seq, result.seq]
},
beforeMount(session) { compacted = appendPreCompactionLog(session) },
}, { columns: 80, rows: 24 })
await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.session.append('user/message', createUserMessage({
content: [{
type: 'text',
text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n</system-reminder>',
}],
source: { kind: 'plugin', plugin: 'workspace-context' },
}), {
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
sourceEventSeqs: replacementSources,
})
appendCompactionCheckpoint(harness.session, compacted)
harness.terminal.resize(44, 18)
})
await checkpoint('surface-after-compaction-narrow', harness.terminal, { includeScrollback: true })
@@ -749,6 +773,23 @@ describe('TUI terminal-state snapshots', () => {
nowSpy.mockRestore()
})
// The resume path, which is what regressed for real users: the replacement is
// already stored when the terminal mounts, so the transcript comes from replay
// rather than from live appends. Pinned against the same log the live scenario
// ends on, at its wide size, so the two fixtures are directly comparable.
it('pins a stored compaction replayed at mount', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME)
const harness = await setupSnapshot({
tools: ADVANCED_CARD_TOOLS,
beforeMount(session) {
appendCompactionCheckpoint(session, appendPreCompactionLog(session))
},
}, { columns: 104, rows: 30 })
await checkpoint('surface-replayed-compaction', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
nowSpy.mockRestore()
})
it('pins wrapped and explicit multiline shell-prompt input', async () => {
const harness = await setupSnapshot({}, { columns: 44, rows: 18 })
await renderAfter(harness, () => {

View File

@@ -19,6 +19,7 @@ import { createUserMessage,
} from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider, type SkillSummary } from '@deepseek-ai/dsh-skill'
@@ -4661,10 +4662,10 @@ describe('tool cards and surface replay', () => {
await dispose(result)
})
it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
it('keeps append-origin history and marks a landed compaction, live and on rebuild', async () => {
const result = await setup({ tools })
appendUser(result.session, 'old prompt')
const assistant = result.session.append('assistant/message', {
result.session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
@@ -4687,21 +4688,116 @@ describe('tool cards and surface replay', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
const start = result.session.surface.nodes[0] as number
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary replacement' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start, end: toolResult.seq },
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
// Result pruning rewrites one node's content in place: model-only, and no
// boundary in the conversation, so the terminal keeps the full output.
const originalResult = toolResult.data.message.content[0]
result.session.append('tool/result', {
...toolResult.data,
message: freezeMessage({
...toolResult.data.message,
content: [{ ...originalResult, content: [{ type: 'text', text: 'pruned result copy' }] }] as [typeof originalResult],
}),
}, {
surfaceOp: { op: 'replace', start: toolResult.seq, end: toolResult.seq },
sourceEventSeqs: [toolResult.seq],
})
const nodes = [...result.session.surface.nodes]
const checkpoint = result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model-only summary payload</context_checkpoint>' }],
source: COMPACT_CHECKPOINT_SOURCE,
}), {
surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number },
sourceEventSeqs: nodes,
})
// A regenerated assistant message replaces one node without summarizing
// anything, so it marks no boundary either.
const generic = result.session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'generic replacement copy' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: { op: 'replace', start: checkpoint.seq, end: checkpoint.seq }, sourceEventSeqs: [checkpoint.seq] })
// Only a checkpoint carrying the compaction seam's source marks a boundary:
// another plugin replacing a node is model-only.
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'foreign plugin replacement copy' }],
source: { kind: 'plugin', plugin: 'other' },
}), { surfaceOp: { op: 'replace', start: generic.seq, end: generic.seq }, sourceEventSeqs: [generic.seq] })
await tick()
result.terminal.resize(89)
await tick()
const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(lastFullRender).toContain('summary replacement')
expect(lastFullRender).not.toContain('old output')
const liveRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(liveRender).toContain('old prompt')
// The shadowed step keeps its card: one call row, one full result, no
// second card from the pruned copy.
expect(liveRender.split('$ printf hello')).toHaveLength(2)
expect(liveRender).toContain('third')
expect(liveRender.split('[exit 0]')).toHaveLength(2)
expect(liveRender.split('… earlier context was compacted …')).toHaveLength(2)
expect(liveRender).not.toContain('model-only summary payload')
expect(liveRender).not.toContain('generic replacement copy')
expect(liveRender).not.toContain('foreign plugin replacement copy')
// Ctrl+R toggles reasoning, which rebuilds the transcript from the log; the
// replayed projection matches what the live appends produced, including the
// shadowed assistant message's tool card.
result.terminal.send('\x12')
await tick()
result.terminal.resize(90)
await tick()
const replayRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(replayRender).toContain('old prompt')
expect(replayRender.split('$ printf hello')).toHaveLength(2)
expect(replayRender).toContain('third')
expect(replayRender.split('[exit 0]')).toHaveLength(2)
expect(replayRender.split('… earlier context was compacted …')).toHaveLength(2)
expect(replayRender).not.toContain('model-only summary payload')
expect(replayRender).not.toContain('generic replacement copy')
expect(replayRender).not.toContain('foreign plugin replacement copy')
await dispose(result)
})
it('replays a stored compaction as preserved history plus its marker', async () => {
const result = await setup({
beforeMount(session) {
appendUser(session, 'prompt before compaction')
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'reply before compaction' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
const nodes = [...session.surface.nodes]
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>stored model-only payload</context_checkpoint>' }],
source: COMPACT_CHECKPOINT_SOURCE,
}), {
surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number },
sourceEventSeqs: nodes,
})
},
})
result.terminal.resize(89)
await tick()
const mounted = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(mounted).toContain('prompt before compaction')
expect(mounted).toContain('reply before compaction')
expect(mounted.split('… earlier context was compacted …')).toHaveLength(2)
expect(mounted).not.toContain('stored model-only payload')
await dispose(result)
})
})

View File

@@ -53,6 +53,9 @@
{
"path": "../commands"
},
{
"path": "../../compact/compact"
},
{
"path": "../../skill/skill"
},