Merge origin/master into codex/sandbox-policy-context
This commit is contained in:
@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
|
||||
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
|
||||
|
||||
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
|
||||
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
|
||||
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
|
||||
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
|
||||
|
||||
@@ -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/client/ui-conversation/README.md
|
||||
README.md: 06fd6b963639390164582e70476b6a80f189bc61
|
||||
README.zh.md: 41047d563f5541aaddc87bc7b448f678538ef91a
|
||||
README.md: 855c42b3377e80b0d8f21a418da0a591782439e1
|
||||
README.zh.md: 31cf2c7b5a9a0740c2be9079ce55d897d175a6d0
|
||||
|
||||
@@ -16,7 +16,7 @@ A tool call declaring the `terminal` render intent renders its command output in
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
|
||||
the later composer sibling paints its surface and shadow over this edge. */
|
||||
margin: 0 auto -10px;
|
||||
/* Flex gap still applies after this item; subtract it together with the
|
||||
design's overlap so the later composer paints over the queue edge. */
|
||||
margin: 0 auto calc(
|
||||
0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap)
|
||||
);
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,14 +162,14 @@ export const queueDockEntry = {
|
||||
name: 'conversation-queue-dock',
|
||||
inject: ['slots', 'conversation', 'sessions'],
|
||||
/**
|
||||
* Register the queue strip into the input dock (list entry, order 0).
|
||||
* Register the queue strip as the terminal input-dock entry (order 20).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'queue',
|
||||
order: 0,
|
||||
order: 20,
|
||||
inject: (sessionId: SessionId): QueueDockInjected => {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)
|
||||
|
||||
@@ -127,14 +127,15 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Composer stack: dock strips above the input card (design §6 MIX order).
|
||||
The stack owns the vertical rhythm: one gap here, entries carry no outer
|
||||
margins — an entry that renders null costs nothing, so spacing stays
|
||||
correct for any dock combination. */
|
||||
/* Composer context stack (Figma 9:937): standalone dock cards share one
|
||||
rhythm; the terminal queue strip additionally tucks under the input card. */
|
||||
.composerStack {
|
||||
--dsh-composer-stack-gap: 6px;
|
||||
--dsh-queue-composer-overlap: 5px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--dsh-composer-stack-gap);
|
||||
}
|
||||
|
||||
/* Common seat for the composer chain (fallback + elected overlay siblings). */
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
|
||||
tip surface, 14px radius, status icons + secondary item labels. Column is
|
||||
calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
|
||||
stack owns the gap. */
|
||||
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
|
||||
14px radius, status icons + secondary item labels. */
|
||||
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
@@ -21,13 +20,11 @@
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Compact scale (GoalBar reference): collapsed header totals the goal
|
||||
strip's 38px (8+8 pad + 20 line + 2 border). */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
padding: 9px 15px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -44,8 +41,8 @@
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -135,10 +135,10 @@ export const todoDockEntry = {
|
||||
name: 'conversation-todo-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the plan strip into the input dock (list entry, above the queue rows).
|
||||
* Register the plan strip between the goal and queue entries (order 10).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -197,9 +197,14 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('ships the session-scoped registrant plugin shape', () => {
|
||||
it('registers as the terminal composer-context entry', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
expect(typeof queueDockEntry.apply).toBe('function')
|
||||
const register = vi.fn()
|
||||
queueDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
|
||||
QueueDock,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,12 +92,12 @@ describe('TodoDock', () => {
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
|
||||
it('registers between the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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/client/ui-goal/README.md
|
||||
README.md: fed4870f73277b22760417297d668853b8afb2db
|
||||
README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca
|
||||
README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
|
||||
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
|
||||
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
|
||||
|
||||
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
/* GoalBar: the goal strip docked above the composer card. The dock's 44px
|
||||
side padding and the bar's 752px cap match the todo strip's column
|
||||
(TodoPanel.module.css), 24px inside the composer card's edges. The
|
||||
negative bottom margin cancels the composer stack's 8px gap and tucks the
|
||||
bar's square bottom edge 2px under the composer card's top edge (the
|
||||
card, later in DOM order, paints over it). Surface matches the todo
|
||||
strip: tip fill, l1 border — no bottom edge where it disappears under the
|
||||
card. All states share one fixed 38px height so switching between them
|
||||
never resizes the strip. */
|
||||
/* GoalBar: the first standalone card in the composer context stack (Figma
|
||||
9:939). Its 752px column matches Todo and the Queue panel. */
|
||||
|
||||
.dock {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 44px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 752px;
|
||||
height: 38px;
|
||||
margin: 0 auto -10px;
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
margin: 0 auto;
|
||||
padding: 4px 5px 4px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-bottom: none;
|
||||
border-radius: 14px 14px 0 0;
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-tip);
|
||||
}
|
||||
|
||||
@@ -37,8 +32,8 @@
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.objective {
|
||||
@@ -47,7 +42,7 @@
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -92,7 +87,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -100,11 +95,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -59,7 +59,7 @@ export function apply(ctx: ClientContext): void {
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 1,
|
||||
order: 0,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 1 })
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 0 })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
|
||||
@@ -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/client/ui-layout/README.md
|
||||
README.md: 9354f4b79f7b1af7d8a20a295e77913ff443c2e4
|
||||
README.zh.md: c949236557e7eb3eed0c698566fb5aa9e9cdd18a
|
||||
README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d
|
||||
README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
|
||||
|
||||
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts both panels at their default widths and never reads or writes `localStorage`. Hero and other unselected states derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session opens at the default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
|
||||
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`.
|
||||
|
||||
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Panel geometry is transient** — reload restores both panels to their defaults; switching between distinct Session ids closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
|
||||
- **Panel geometry is transient** — reload restores the sidebar default and details closed; switching between distinct Session ids also closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
|
||||
- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth.
|
||||
- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
|
||||
|
||||
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,两个面板均以默认宽度启动,且从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话以默认宽度打开;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
|
||||
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
|
||||
|
||||
`/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。
|
||||
|
||||
@@ -18,6 +18,6 @@ AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionPr
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **面板几何信息是瞬时状态**:重新加载会将两个面板恢复为默认值;在不同会话 id 之间切换会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
|
||||
- **面板几何信息是瞬时状态**:重新加载会恢复侧边栏默认值,并使详情栏保持关闭;在不同会话 id 之间切换同样会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
|
||||
- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。
|
||||
- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。
|
||||
|
||||
@@ -38,7 +38,7 @@ type LayoutActions = {
|
||||
*/
|
||||
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
|
||||
const handle = defineStore({
|
||||
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }),
|
||||
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
|
||||
actions: {
|
||||
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
|
||||
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },
|
||||
|
||||
@@ -139,7 +139,7 @@ afterEach(() => {
|
||||
describe('AppFrame', () => {
|
||||
it('renders three tracks from store state', () => {
|
||||
const { frame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
|
||||
@@ -174,6 +174,9 @@ describe('AppFrame', () => {
|
||||
|
||||
it('ignores unselected states and closes only when the Session id changes', () => {
|
||||
const { frame, instance, rerenderFrame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
|
||||
act(() => { instance.actions.openDetails() })
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
|
||||
selectedSession.current = 's-next' as SessionId
|
||||
@@ -200,15 +203,15 @@ describe('AppFrame', () => {
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('keeps the default details width when the first Session materializes', () => {
|
||||
it('keeps details closed when the first Session materializes', () => {
|
||||
selectedSession.current = undefined
|
||||
const { frame, instance, rerenderFrame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(instance.getSnapshot().details).toBe(360)
|
||||
expect(instance.getSnapshot().details).toBe(0)
|
||||
|
||||
selectedSession.current = 's-first' as SessionId
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
@@ -224,7 +227,8 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('details drag widens leftward (negative dx grows the panel)', () => {
|
||||
const { frame } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.openDetails() })
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 1560, 1500)
|
||||
expect(tracks(frame)[1]).toBe(420)
|
||||
@@ -233,6 +237,7 @@ describe('AppFrame', () => {
|
||||
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
|
||||
frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.openDetails() })
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
const handles = frame.querySelectorAll('[class*="handle"]')
|
||||
drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
|
||||
@@ -240,8 +245,7 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('details column stays mounted at zero width', () => {
|
||||
const { frame, instance, getByTestId } = mountFrame()
|
||||
act(() => { instance.actions.closeDetails() })
|
||||
const { frame, getByTestId } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(getByTestId('details-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
|
||||
@@ -250,7 +254,7 @@ describe('AppFrame', () => {
|
||||
it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
|
||||
const { frame, instance, slotCalls, getByTestId } = mountFrame()
|
||||
act(() => { instance.actions.toggleSidebar() })
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
|
||||
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
|
||||
expect(getByTestId('sidebar-content')).toBeTruthy()
|
||||
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
|
||||
const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
|
||||
@@ -258,7 +262,8 @@ describe('AppFrame', () => {
|
||||
})
|
||||
|
||||
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
|
||||
const { frame } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.openDetails() })
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
@@ -269,6 +274,8 @@ describe('AppFrame', () => {
|
||||
|
||||
it('drag handles disappear for collapsed columns', () => {
|
||||
const { frame, instance } = mountFrame()
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
||||
act(() => { instance.actions.openDetails() })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
|
||||
act(() => { instance.actions.closeDetails() })
|
||||
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
|
||||
@@ -323,7 +330,7 @@ describe('AppFrame — guard branches', () => {
|
||||
frameWidth = 0
|
||||
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
// Track template still reflects the last non-zero viewport.
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -338,7 +345,8 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
|
||||
})
|
||||
|
||||
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
|
||||
const { frame } = mountFrame()
|
||||
const { frame, instance } = mountFrame()
|
||||
act(() => { instance.actions.openDetails() })
|
||||
frameWidth = 1250
|
||||
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
|
||||
expect(tracks(frame)).toEqual([280, 330])
|
||||
|
||||
@@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels'
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
describe('createLayoutStore', () => {
|
||||
it('initializes both panels at their default widths', () => {
|
||||
it('initializes the sidebar at its default width and details closed', () => {
|
||||
const { store } = createLayoutStore().create()
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT })
|
||||
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
|
||||
})
|
||||
|
||||
it('each create() is an independent instance (factory is not a singleton)', () => {
|
||||
@@ -50,9 +50,8 @@ describe('createLayoutStore', () => {
|
||||
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
|
||||
})
|
||||
|
||||
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
|
||||
it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => {
|
||||
const { store, actions } = createLayoutStore().create()
|
||||
actions.closeDetails()
|
||||
actions.openDetails()
|
||||
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
|
||||
actions.setDetails(500)
|
||||
@@ -65,13 +64,14 @@ describe('createLayoutStore', () => {
|
||||
it('does not persist panel geometry', () => {
|
||||
const first = createLayoutStore().create()
|
||||
first.actions.setSidebar(400)
|
||||
first.actions.closeDetails()
|
||||
first.actions.openDetails()
|
||||
first.actions.setDetails(500)
|
||||
expect(localStorage.getItem(PERSIST_KEY)).toBeNull()
|
||||
|
||||
const second = createLayoutStore().create()
|
||||
expect(second.store.getSnapshot()).toEqual({
|
||||
sidebar: SIDEBAR_DEFAULT,
|
||||
details: DETAILS_DEFAULT,
|
||||
details: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,6 +134,15 @@ interface PreparedAgent {
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
/**
|
||||
* Launcher-owned exact session identities for configured agents, keyed by
|
||||
* the agent's config `id` and set with `ctx.provide()` before any Loader
|
||||
* entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
|
||||
* owns identity because only it knows whether the session already exists,
|
||||
* while the `cordis.yml` row keeps the model route as ordinary patchable
|
||||
* config. An entry with no matching key keeps its configured identity.
|
||||
*/
|
||||
configuredAgentIdentities?: ConfiguredAgentIdentities
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
@@ -151,6 +160,53 @@ declare module 'cordis' {
|
||||
|
||||
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
|
||||
|
||||
/**
|
||||
* One launcher-selected session identity for a configured agent. `resume`
|
||||
* distinguishes rehydrating existing persisted history from creating the
|
||||
* session fresh under that exact id, which the two config keys express as
|
||||
* `resumeSessionId` and `sessionId`.
|
||||
*/
|
||||
export interface LauncherAgentIdentity {
|
||||
/** Exact session id to create fresh or resume. */
|
||||
id: SessionId
|
||||
/** Resume existing persisted history instead of creating the session fresh. */
|
||||
resume: boolean
|
||||
}
|
||||
|
||||
/** Launcher-selected identities keyed by the configured agent's `id`. */
|
||||
export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
|
||||
* configured agents' session identities without a config key, so an overlay
|
||||
* repointing the row's model route cannot drop them.
|
||||
*/
|
||||
export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
|
||||
|
||||
/**
|
||||
* Apply launcher-owned identities over the configured agents, replacing both
|
||||
* identity keys for every entry the launcher named so a config-supplied
|
||||
* identity can never survive alongside a launcher-supplied one.
|
||||
* @param agents - the configured agent entries.
|
||||
* @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
|
||||
* @returns the entries with launcher-owned identities applied.
|
||||
*/
|
||||
function applyLauncherIdentities(
|
||||
agents: Config['agents'],
|
||||
identities: ConfiguredAgentIdentities | undefined,
|
||||
): Config['agents'] {
|
||||
if (identities === undefined) return agents
|
||||
return agents.map((agent) => {
|
||||
const identity = identities[agent.id]
|
||||
if (identity === undefined) return agent
|
||||
const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
|
||||
return identity.resume
|
||||
? { ...rest, resumeSessionId: identity.id }
|
||||
: { ...rest, sessionId: identity.id }
|
||||
})
|
||||
}
|
||||
|
||||
/** Agent-loop plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
@@ -220,6 +276,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
super(ctx, 'agentLoop')
|
||||
this.config = {
|
||||
...config,
|
||||
agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
|
||||
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
|
||||
}
|
||||
validateConfiguredAgents(this.config.agents)
|
||||
|
||||
@@ -11,7 +11,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -36,6 +36,26 @@ async function makeCoreContext(): Promise<Context> {
|
||||
}
|
||||
|
||||
describe('config-driven session id', () => {
|
||||
it('applies launcher identities by configured id without changing unmatched entries', async () => {
|
||||
const ctx = await makeCoreContext()
|
||||
ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, {
|
||||
fresh: { id: SessionId('launcher-fresh'), resume: false },
|
||||
resumed: { id: SessionId('launcher-resumed'), resume: true },
|
||||
})
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [
|
||||
{ id: 'fresh', sessionId: SessionId('config-fresh'), model: 'mock' },
|
||||
{ id: 'resumed', sessionId: SessionId('config-resumed'), model: 'mock' },
|
||||
{ id: 'unchanged', sessionId: SessionId('config-unchanged'), model: 'mock' },
|
||||
],
|
||||
})
|
||||
expect(ctx.agents.get(SessionId('launcher-fresh'))?.session.id).toBe('launcher-fresh')
|
||||
expect(ctx.agents.get(SessionId('launcher-resumed'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-resumed'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-unchanged'))?.session.id).toBe('config-unchanged')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an empty exact id before publishing an agent', async () => {
|
||||
const ctx = await makeCoreContext()
|
||||
await expect(ctx.plugin(AgentLoop, {
|
||||
|
||||
@@ -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/examples/README.md
|
||||
README.md: c229cef22087ac290bf862d6b3e31fdb533858c4
|
||||
README.zh.md: 208b9a138506785ea1dd2d83ddfbba29e4b7968e
|
||||
README.md: d3ad432e71036db0d21f059e52f5d32e58010c42
|
||||
README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60
|
||||
|
||||
@@ -7,12 +7,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|
||||
| Package | npm name | Role |
|
||||
|---|---|---|
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
|
||||
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI |
|
||||
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
|
||||
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` |
|
||||
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
|
||||
|
||||
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
`agent-spine-demo` is the shared bundle; `cli-demo` and `acp-demo` compose it with headless one-shot and ACP automation front doors, and own their boot bins. The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: its TUI and web surfaces are a shared `base.cordis.yml` plus one overlay each. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
|
||||
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
|
||||
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包(package);npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK 运行时](../../python/sdk-runtime/README.md) 是消费方;每个消费方都只包含可替换后端和一个组合包入口。
|
||||
预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。
|
||||
|
||||
| 包 | npm 名称 | 角色 |
|
||||
|---|---|---|
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent(智能体)主干,打包为一个组合包插件,带后备会话标题和可选择启用的持久化目标栈 |
|
||||
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | 全屏终端应用组合包:主干 + 持久化目标 + `/goal` 命令 + JSONL 持久化 + `dsh-tui` + 预创建的 `main` agent;没有 bin,由 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动 |
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 |
|
||||
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent,提供文本和 DSH 原生 JSON 输出 |
|
||||
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化服务器应用:主干 + 持久化目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` |
|
||||
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的运行时,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 |
|
||||
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP 自动化服务器应用:主干 + 持久目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` |
|
||||
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的 runtime,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 |
|
||||
|
||||
`agent-spine-demo` 是共享组合包;`tui-demo`、`cli-demo` 和 `acp-demo` 分别将它与全屏终端、无头单次和 ACP 自动化前端入口组合。`cli-demo` 与 `acp-demo` 拥有各自的启动 bin;`tui-demo` 只交付组合包插件,产品 [`dsh`](../../apps/cli/README.md) CLI 是它的终端前端入口。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK 运行时会启动它。
|
||||
`agent-spine-demo` 是共享组合包;`cli-demo` 和 `acp-demo` 分别将它与无头单次和 ACP 自动化前端入口组合,并拥有各自的启动 bin。产品 [`dsh`](../../apps/cli/README.md) CLI 不使用组合包:其 TUI 与 web surface 都是一份共享的 `base.cordis.yml` 加各自一份 overlay。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK runtime 会启动它。
|
||||
|
||||
这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md),人类/SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。
|
||||
|
||||
@@ -20,4 +19,4 @@
|
||||
|
||||
## jsonrpc bin/exe 名称是历史遗留
|
||||
|
||||
`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的运行时启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。
|
||||
`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的 runtime 启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。
|
||||
|
||||
@@ -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/examples/agent-spine-demo/README.md
|
||||
README.md: 6bbd99217bcdce0e8a0e8fd22a8d39d0c64224b9
|
||||
README.zh.md: 4a7e7a1b7eced9317f94eeddf2442ded9a3e0e13
|
||||
README.md: 34d68b0791746c28528124853a4d8ea82b68138d
|
||||
README.zh.md: 1b0a644595e35d8703d0600812268b0950b79182
|
||||
|
||||
@@ -47,7 +47,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
|
||||
- **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider.
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
|
||||
- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-cli-demo`](../cli-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
|
||||
|
||||
This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。
|
||||
- **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。
|
||||
- **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。
|
||||
- **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。
|
||||
- **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。
|
||||
|
||||
这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/examples/tui-demo/README.md
|
||||
README.md: 058ebe87af5f041bd19fbfb205a97753ccacf6b9
|
||||
README.zh.md: 43681e9c77cbd77459ec0539b69e0ab73274d2c1
|
||||
@@ -1,112 +0,0 @@
|
||||
# @deepseek-ai/dsh-tui-demo
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config.
|
||||
|
||||
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback.
|
||||
|
||||
## What it bakes in
|
||||
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent |
|
||||
| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins |
|
||||
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
|
||||
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; model-facing query tools remain a leaf opt-in |
|
||||
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
|
||||
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool |
|
||||
|
||||
Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Routed to |
|
||||
|---|---|---|
|
||||
| `provider` | required | Configured `main` agent provider |
|
||||
| `model` | required | Configured `main` agent model |
|
||||
| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap |
|
||||
| `persona` | — | System-prompt persona template |
|
||||
| `toolOrder` | lexicographic | Explicit model-facing tool order |
|
||||
| `tools` | owner default | Tool presentation mode |
|
||||
| `dshHome` | owner default | Harness home used by bash and skills |
|
||||
| `sessionTitle` | spine example limits | Fallback title word/byte limits |
|
||||
| `skills` | owner defaults | Skill registry, local provider, and tool config |
|
||||
| `toolBash` | owner defaults | Model-facing bash tool config |
|
||||
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
|
||||
| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
|
||||
| `workspaceContext` | required | Workspace-instruction config, or `false` |
|
||||
| `persistenceRoot` | `./.sessions` | JSONL persistence root and parent of the derived `session-query.db` index |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` |
|
||||
| `welcome` | `ready.` | TUI subtitle |
|
||||
| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff |
|
||||
| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height |
|
||||
| `resumeSessionId` | — | Exact persisted session to resume |
|
||||
|
||||
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff.
|
||||
|
||||
## Front door
|
||||
|
||||
This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config <path-to-cordis.yml>` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
|
||||
|
||||
## Example leaf
|
||||
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
- id: tui-agent
|
||||
name: '@deepseek-ai/dsh-tui-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
welcome: 'Coding agent ready.'
|
||||
ui:
|
||||
showReasoning: true
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Interactive terminal turn
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, and visible tool schemas. TUI rendering itself is not model-visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token.
|
||||
|
||||
### Human-question answer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the completed or failed tool result adds retained tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; the answer follows the reusable request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`.
|
||||
- **One configured terminal session** — the transcript and editor bind to one exact session id.
|
||||
- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition.
|
||||
- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer.
|
||||
@@ -1,112 +0,0 @@
|
||||
# @deepseek-ai/dsh-tui-demo
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
全屏终端应用组合包:一个 Cordis 插件,组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、持久化的同会话目标、人类命令注册表与 `/goal` 生产方、JSONL 持久化、键盘支持的用户交互、预创建的 `main` agent(智能体),以及 [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md)。一份 `cordis.yml` 将它作为一个 Cordis 配置项挂载;[`dsh`](../../../apps/cli/README.md) CLI(命令行界面)是启动此类配置的入口。
|
||||
|
||||
管道、脚本和其他非交互式运行应使用 [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md)。此组合包需要一对 TTY,不提供面向行的回退。
|
||||
|
||||
## 内置组件
|
||||
|
||||
| 插件 | 设置在此处的原因 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | 共享服务、面向模型的工具,以及一个已配置的 `main` agent |
|
||||
| `@deepseek-ai/dsh-commands` | 供 TUI 和命令插件消费、仅面向人类的命令发现与分发 |
|
||||
| `@deepseek-ai/dsh-command-goal` | 直接在主干的持久化目标栈上提供 `/goal` 状态与变更 |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | 位于 `persistenceRoot` 下的持久会话日志 |
|
||||
| `@deepseek-ai/dsh-session-checkpoint-policy` | 模型请求和顶层工具 effect 前的语义持久性屏障,以及已完成步骤的检查点 |
|
||||
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | TUI 消费的组合式精确/FTS 会话查询与有界 `@session` 快照;面向模型的查询工具仍由叶节点选用 |
|
||||
| `@deepseek-ai/dsh-user-interaction` | 与提供方无关的人类问题服务 |
|
||||
| `@deepseek-ai/dsh-tui` | 全屏 transcript(文本记录)、编辑器、工具卡片、计划与问题 overlay |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | 面向模型的 `ask_user_question` 工具 |
|
||||
|
||||
可替换的 LLM(大语言模型)、bash、文件系统和其他能力提供方仍留在叶节点配置中。`@cordisjs/plugin-hmr` 也仍是仅叶节点使用的开发条目,因为它需要 Loader 内部实现。
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 默认值 | 路由目标 |
|
||||
|---|---|---|
|
||||
| `provider` | 必填 | 已配置 `main` agent 的提供方 |
|
||||
| `model` | 必填 | 已配置 `main` agent 的模型 |
|
||||
| `maxParallelToolCalls` | agent-loop 默认值 | 组合包内循环的并发上限 |
|
||||
| `persona` | 无 | 系统提示词 persona 模板 |
|
||||
| `toolOrder` | 字典序 | 显式的面向模型工具顺序 |
|
||||
| `tools` | 拥有者默认值 | 工具呈现模式 |
|
||||
| `dshHome` | 拥有者默认值 | bash 与 skill(技能)使用的 harness 主目录 |
|
||||
| `sessionTitle` | 主干示例限制 | 后备标题词数/字节限制 |
|
||||
| `skills` | 拥有者默认值 | skill 注册表、本地提供方和工具配置 |
|
||||
| `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置 |
|
||||
| `toolTasks` | 拥有者默认值 | 后台任务控制工具配置,或 `false` |
|
||||
| `goals` | 拥有者默认值 | 持久目标领域与模型工具配置;`false` 会移除目标栈与 `/goal` 生产方 |
|
||||
| `workspaceContext` | 必填 | Workspace 指令配置,或 `false` |
|
||||
| `persistenceRoot` | `./.sessions` | JSONL 持久化根目录,以及派生 `session-query.db` 索引的父目录 |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL 产物编码(`'zstd'` 或原始 `'none'`) |
|
||||
| `sessionReferences` | 服务默认值 | 路由到 `dsh-session-reference` 的跨会话候选项与快照限制 |
|
||||
| `welcome` | `ready.` | TUI 副标题 |
|
||||
| `resumeCommand` | 无 | 退出和无宿主回退的命令模板;选择器本身使用会话查询与宿主移交 |
|
||||
| `ui` | 拥有者默认值 | 推理(reasoning)、颜色、卡片高度等 TUI 呈现设置 |
|
||||
| `resumeSessionId` | 无 | 要恢复的确切持久化会话 |
|
||||
|
||||
新运行会创建 `main-session-<uuid>` 会话 id,并将它同时传给 TUI 与已配置的 agent。恢复运行会将两个组件都绑定到 `resumeSessionId`。TUI 先于主干挂载,因此它可以渲染匹配的配置启动失败,而不会留下空白终端。应用为 `/resume` 组合持久化和会话查询;嵌入宿主还可以提供 `tuiResumeHost`,用于原地移交进程。
|
||||
|
||||
## 入口
|
||||
|
||||
此包(package)不交付 bin。[`dsh`](../../../apps/cli/README.md) CLI 是终端入口:裸 `dsh` 启动已交付的 `examples/tui-agent/cordis.yml`(它挂载此组合包),而 `dsh --config <path-to-cordis.yml>` 启动另一个挂载此组合包的叶节点配置。它加载 cwd 下可选的 `.env`,驱动 Cordis Loader,并等待完整插件树。仓库安装了 Loader 的可选原生辅助程序,因此裸包说明符可以在纯 Node 下解析。
|
||||
|
||||
## 叶节点示例
|
||||
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
- id: tui-agent
|
||||
name: '@deepseek-ai/dsh-tui-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
welcome: 'Coding agent ready.'
|
||||
ui:
|
||||
showReasoning: true
|
||||
```
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 交互式终端轮次
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每次非空、非命令的编辑器提交都会成为用户消息;运行中轮次内的提交成为 steering(中途引导)。斜杠命令输入和输出仍只面向人类,而已接受的 `/goal` 变更会追加领域拥有的模型可见状态。共享主干提供已配置的 persona、workspace 指令、skill 目录、目标控制和可见工具 schema。TUI 渲染本身对模型不可见。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
用户、assistant 与工具历史按常规会话和压缩(compaction)规则增长。Header、卡片、计划、Markdown 样式和快捷键不增加 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要组合后的提示词、schema、路由和保留历史前缀保持稳定,就保持仅追加。组合方式变更与 compaction 可能从第一个变化的 token 起使复用失效。
|
||||
|
||||
### 人类问题答案
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`ask_user_question` 会保留工具调用,以及 `dsh-tool-ask-user` 定义的精简答案或稳定中断错误。问题 overlay 只在终端显示。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有已完成或失败的工具结果会增加保留 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;答案跟在可复用请求前缀之后。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只支持 TTY**:stdin 与 stdout 都必须是终端;自动化使用 `dsh-cli-demo`。
|
||||
- **一个已配置的终端会话**:transcript 与编辑器绑定到一个确切会话 id。
|
||||
- **应用集群固定不变**:JSONL 持久化与 ask-user 工具内置;不同策略需要另一种组合。
|
||||
- **批准机制独立存在**:此应用回答 `ctx.userInteraction`,而不是 `ctx.approval`;权限提示需要批准服务和回答方。
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tui-demo",
|
||||
"description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)",
|
||||
"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-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-command-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tui": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"schemastery": "^3.17.0"
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo})
|
||||
* plus persisted goals, human commands, JSONL persistence, keyboard-backed
|
||||
* user interaction, and one pre-created agent whose exact session identity the
|
||||
* TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin
|
||||
* intentionally exposes named exports only; a default export would hide its
|
||||
* `Config` schema (see docs/postmortem/0001).
|
||||
* @module @deepseek-ai/dsh-tui-demo
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import z from 'schemastery'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiTui from '@deepseek-ai/dsh-tui'
|
||||
|
||||
export const name = 'tui-demo'
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
|
||||
// Each front door keeps a complete Loader contract so its deployment config is
|
||||
// readable without a cross-package facade.
|
||||
/* jscpd:ignore-start */
|
||||
/** App config routed to the spine, TUI, configured agent, and JSONL backend. */
|
||||
export interface Config {
|
||||
/** Provider route for the `main` agent. */
|
||||
provider: string
|
||||
/** Model name for the `main` agent; a matching adapter must be registered. */
|
||||
model: string
|
||||
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
|
||||
maxParallelToolCalls?: number
|
||||
/** Deployment persona forwarded to the system-prompt plugin. */
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order forwarded to the system-prompt plugin. */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry presentation config forwarded through agent-spine-demo. */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Fallback session-title limits forwarded through agent-spine-demo. */
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** Cross-session reference discovery and snapshot byte budgets. */
|
||||
sessionReferences?: SessionReferenceConfig
|
||||
/** TUI transcript's optional first line; absent renders nothing on start. */
|
||||
welcome?: string
|
||||
/**
|
||||
* Shell command template the TUI prints on exit and lists under `/resume`,
|
||||
* with `{session}` replaced by the live session id (forwarded to the front
|
||||
* door). Set it to a command that resumes the session, e.g.
|
||||
* `dsh --resume {session}`.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
/** Full-screen TUI presentation settings. */
|
||||
ui?: uiTui.TuiConfig
|
||||
/** Skill registry, local-provider, and model-facing consumer config. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-spine-demo. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
|
||||
goals?: agentCore.GoalConfig | false
|
||||
/** Persisted session id to resume instead of creating a fresh session. */
|
||||
resumeSessionId?: string
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
maxParallelToolCalls: z.number().step(1).min(1),
|
||||
persona: z.string(),
|
||||
// Absent means lexicographic order; schemastery's native array default is [].
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
sessionTitle: agentCore.SessionTitleConfigSchema,
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
sessionReferences: SessionReferenceService.Config,
|
||||
welcome: z.string(),
|
||||
resumeCommand: z.string(),
|
||||
ui: uiTui.TuiConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
|
||||
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
|
||||
resumeSessionId: z.string(),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Compose the spine, TUI, JSONL persistence, and user-question tool around one
|
||||
* exact fresh or resumed session identity. The TUI subscribes to startup
|
||||
* failures before the spine creates the agent.
|
||||
* @param ctx - context receiving the app's child plugins.
|
||||
* @param config - validated app configuration.
|
||||
*/
|
||||
export function composeTuiApp(ctx: Context, config: Config): void {
|
||||
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
|
||||
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
|
||||
const goals = config.goals ?? {}
|
||||
const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT
|
||||
ctx.plugin(CommandService)
|
||||
if (goals !== false) ctx.plugin(commandGoal)
|
||||
ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: persistenceRoot,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(sessionCheckpointPolicy)
|
||||
ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') })
|
||||
ctx.plugin(SessionReferenceService, config.sessionReferences ?? {})
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(uiTui.TuiPromptService)
|
||||
ctx.plugin(uiTui, {
|
||||
...config.ui,
|
||||
...config.welcome === undefined ? {} : { welcome: config.welcome },
|
||||
...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand },
|
||||
sessionId,
|
||||
})
|
||||
ctx.plugin(agentCore, {
|
||||
...agentCore.pickSpineConfig(config),
|
||||
goals,
|
||||
agents: [{
|
||||
id: SessionId('main'),
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
|
||||
}],
|
||||
})
|
||||
ctx.plugin(toolAskUser)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the configured full-screen terminal app.
|
||||
* @param ctx - context receiving the app's child plugins.
|
||||
* @param config - validated app configuration.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
composeTuiApp(ctx, config)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tui-demo`.
|
||||
* @module @deepseek-ai/dsh-tui-demo/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tui-demo'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tui-demo-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this composition-only package delegates mutable state and event streams
|
||||
* to the agent spine, persistence, and TUI packages that own their checks.
|
||||
*/
|
||||
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 */
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as tuiAgent from '../src/index.ts'
|
||||
|
||||
interface PluginCall {
|
||||
readonly name: string
|
||||
readonly config: unknown
|
||||
}
|
||||
|
||||
function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } {
|
||||
const calls: PluginCall[] = []
|
||||
const ctx = {
|
||||
plugin(plugin: { name?: string }, config?: unknown) {
|
||||
calls.push({ name: plugin.name ?? '', config })
|
||||
},
|
||||
} as unknown as Context
|
||||
return { ctx, calls }
|
||||
}
|
||||
|
||||
describe('dsh-tui-demo app', () => {
|
||||
it('composes the TUI cluster around one fresh exact session identity', () => {
|
||||
const { ctx, calls } = recordingContext()
|
||||
tuiAgent.composeTuiApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
maxParallelToolCalls: 3,
|
||||
persona: 'test persona',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
tools: { mode: 'code' },
|
||||
dshHome: '/tmp/dsh-home',
|
||||
persistenceRoot: '/tmp/tui-sessions',
|
||||
persistenceCompression: 'none',
|
||||
sessionReferences: {
|
||||
maxReferences: 2,
|
||||
candidateLimit: 7,
|
||||
maxReferenceBytes: 1234,
|
||||
},
|
||||
welcome: 'TUI ready',
|
||||
resumeCommand: 'dsh --resume {session}',
|
||||
ui: { theme: { color: false }, maxToolOutputLines: 3 },
|
||||
skills: { tool: { catalogDescriptionMaxLength: 8 } },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
expect(calls.map(call => call.name)).toEqual([
|
||||
'CommandService',
|
||||
'command-goal',
|
||||
'SessionPersistenceJsonl',
|
||||
'session-checkpoint-policy',
|
||||
'SessionQuerySqlite',
|
||||
'SessionReferenceService',
|
||||
'UserInteractionService',
|
||||
'TuiPromptService',
|
||||
'ui-tui',
|
||||
'agent-spine-demo',
|
||||
'tool-ask-user',
|
||||
])
|
||||
expect(calls[0]?.config).toBeUndefined()
|
||||
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
|
||||
expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') })
|
||||
expect(calls[5]?.config).toEqual({
|
||||
maxReferences: 2,
|
||||
candidateLimit: 7,
|
||||
maxReferenceBytes: 1234,
|
||||
})
|
||||
const tuiConfig = calls[8]?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({
|
||||
welcome: 'TUI ready',
|
||||
resumeCommand: 'dsh --resume {session}',
|
||||
theme: { color: false },
|
||||
maxToolOutputLines: 3,
|
||||
})
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
const spineConfig = calls[9]?.config as {
|
||||
readonly agents: Array<Record<string, unknown>>
|
||||
readonly goals: Record<string, never>
|
||||
readonly maxParallelToolCalls: number
|
||||
readonly persona: string
|
||||
readonly toolOrder: string[]
|
||||
readonly tools: { mode: string }
|
||||
}
|
||||
expect(spineConfig).toMatchObject({
|
||||
maxParallelToolCalls: 3,
|
||||
persona: 'test persona',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
tools: { mode: 'code' },
|
||||
goals: {},
|
||||
})
|
||||
expect(spineConfig.agents[0]).toMatchObject({
|
||||
id: 'main',
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
cwd: process.cwd(),
|
||||
sessionId: tuiConfig.sessionId,
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes the configured session and applies runtime defaults', () => {
|
||||
const { ctx, calls } = recordingContext()
|
||||
tuiAgent.composeTuiApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
resumeSessionId: 'persisted-session',
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
expect(calls[2]?.config).toEqual({ root: './.sessions' })
|
||||
expect(calls[5]?.config).toEqual({})
|
||||
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
|
||||
expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' })
|
||||
expect((calls[9]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
|
||||
id: 'main',
|
||||
resumeSessionId: 'persisted-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes an empty resume id and routes apply through the same composition', () => {
|
||||
const { ctx, calls } = recordingContext()
|
||||
tuiAgent.apply(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
resumeSessionId: '',
|
||||
goals: false,
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
const tuiConfig = calls[7]?.config as { sessionId: string }
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
expect((calls[8]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
|
||||
.toMatchObject({ sessionId: tuiConfig.sessionId })
|
||||
expect(calls.map(call => call.name)).not.toContain('command-goal')
|
||||
expect(calls[8]?.config).toMatchObject({ goals: false })
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {
|
||||
expect(tuiAgent.name).toBe('tui-demo')
|
||||
expect(tuiAgent.Config).toBeDefined()
|
||||
expect('default' in tuiAgent).toBe(false)
|
||||
expect(typeof tuiAgent.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tuiAgent) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tuiAgent)
|
||||
expect(unwrapped.name).toBe('tui-demo')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query-sqlite"
|
||||
},
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/command-goal"
|
||||
},
|
||||
{
|
||||
"path": "../agent-spine-demo"
|
||||
},
|
||||
{
|
||||
"path": "../../context/workspace-context"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/tui"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-checkpoint-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* tui-demo ships the plugin (`index`) and its invariant companion; the CLI
|
||||
* front door is `dsh` (apps/cli), which mounts this bundle through its config.
|
||||
* The root tsdown builds only `lib/types/index.js`, so this override adds the
|
||||
* invariant entry. Declarations come from `tsc -b` (dts: false), matching
|
||||
* every package.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
})
|
||||
@@ -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: d44770f70be16c12f44b78155089e092a3e9bba0
|
||||
README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014
|
||||
README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982
|
||||
README.zh.md: 46516d187321029ed739d8c071f246bc1755b125
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
|
||||
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/config/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
|
||||
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
|
||||
|
||||
| 包 | 角色 | ctx 键 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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: bf5d0d762365deae83d004e00b94a0ba805d6a6f
|
||||
README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928
|
||||
README.md: 0a3d29e41dd0e203c2f576192ea7e88ecb904fac
|
||||
README.zh.md: b91f4e697ff04eedaaa2fc093229f1c459a1655a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml).
|
||||
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml).
|
||||
|
||||
## Contract layer (`/api`)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。
|
||||
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
|
||||
|
||||
## 契约层(`/api`)
|
||||
|
||||
|
||||
@@ -62,6 +62,16 @@ export {
|
||||
type JournalMode,
|
||||
} from './schema.ts'
|
||||
|
||||
/** Boot-context slot for a launcher-owned absolute path to this process's derived query index. */
|
||||
export const SESSION_QUERY_SQLITE_PATH_KEY = 'launcherSessionQueryPath'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Launcher-owned absolute path to this process's disposable derived query index. */
|
||||
launcherSessionQueryPath?: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Default result page size. */
|
||||
export const SESSION_QUERY_SQLITE_DEFAULT_LIMIT = 20
|
||||
/** Maximum accepted result page size. */
|
||||
|
||||
@@ -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/todo/README.md
|
||||
README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe
|
||||
README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219
|
||||
README.md: e16d1a3ff413d13d47f9b08a3cfddfedb76b5254
|
||||
README.zh.md: e3307d4f0acdb3f50db3f1e010286ad11668906a
|
||||
|
||||
@@ -8,4 +8,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|
||||
|---|---|---|
|
||||
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI app](../examples/tui-demo) and the host/client runtime render the durable list from session events.
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI app](../ui/tui) and the host/client runtime render the durable list from session events.
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
|---|---|---|
|
||||
| `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) |
|
||||
|
||||
列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。
|
||||
列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。
|
||||
|
||||
@@ -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/todo/tool-todo/README.md
|
||||
README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef
|
||||
README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2
|
||||
README.md: 91f4bc6abd0c08f44f0a0a50393e4cdd5ddada70
|
||||
README.zh.md: b9582307ff5590bf34be91b776fa06841a68b41d
|
||||
|
||||
@@ -20,7 +20,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
|
||||
|
||||
## Rendering
|
||||
|
||||
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
|
||||
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../ui/tui) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)).
|
||||
|
||||
## Session projection
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
## 渲染
|
||||
|
||||
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[TUI 应用](../../examples/tui-demo)与 [web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条(web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
|
||||
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[TUI 应用](../../ui/tui)与 [web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条(web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。
|
||||
|
||||
## 会话投影
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
|
||||
README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad
|
||||
README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06
|
||||
README.md: 4d8c7de65515a251f227075c7baf041fc1b210c8
|
||||
README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db
|
||||
|
||||
@@ -10,9 +10,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
@@ -24,10 +25,10 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
## Personal config
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
|
||||
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
|
||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||
|
||||
@@ -45,4 +46,3 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
|
||||
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
|
||||
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
|
||||
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.
|
||||
- **Personal patches see only the booted file's own entries** — an overlay leaf that reaches its base through a nested include entry (the Code Mode configs) resolves personal patch ids against the overlay's top-level entries, not the included subtree.
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
|
||||
| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
@@ -24,10 +25,10 @@
|
||||
|
||||
## 个人配置
|
||||
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
|
||||
- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
|
||||
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
|
||||
|
||||
@@ -45,4 +46,3 @@
|
||||
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
|
||||
- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。
|
||||
- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。
|
||||
- **个人 patch 只能看到已启动文件自身的条目**:如果 overlay 叶子通过嵌套 include 条目访问其基础配置(例如 Code Mode 配置),个人 patch id 只会在 overlay 的顶层条目中解析,不会进入被 include 的子树。
|
||||
|
||||
@@ -10,7 +10,7 @@ import { pathToFileURL } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, type FiberState } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
@@ -94,20 +94,56 @@ export function loadPersonalPatches(
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
|
||||
throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
|
||||
}
|
||||
return parsePatchList(binName, file, content, 'personal patches')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a
|
||||
* `--config <path>` overlay applied over the shared base. Same file format as
|
||||
* {@link loadPersonalPatches}, but a missing file throws, because the caller
|
||||
* named this file — its absence is a misconfiguration, not "no overlay".
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - absolute path of the overlay file.
|
||||
* @returns the parsed patch list.
|
||||
*/
|
||||
export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(file, 'utf8')
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
|
||||
}
|
||||
return parsePatchList(binName, file, content, 'overlay')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one loader patch list: a top-level YAML array of
|
||||
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
|
||||
* `insert` lists, `!!js` expressions allowed). Every shape failure throws,
|
||||
* because a patch file that cannot be applied at all is a misconfiguration; a
|
||||
* single patch whose target row is absent stays a per-entry Loader warning, so
|
||||
* one overlay shared across surfaces does not have to match every tree.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - the source path, quoted in errors.
|
||||
* @param content - the file's text.
|
||||
* @param label - what to call this list in errors (`personal patches`, `overlay`).
|
||||
* @returns the parsed patch list.
|
||||
*/
|
||||
function parsePatchList(
|
||||
binName: string, file: string, content: string, label: string,
|
||||
): PatchOptions[] {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: personalPatchesSchema })
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`)
|
||||
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`)
|
||||
throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
|
||||
}
|
||||
// A present personal config that cannot apply is a misconfiguration and must
|
||||
// fail loud here — the include only warns per entry at mount.
|
||||
parsed.forEach((entry, index) => {
|
||||
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
||||
throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
|
||||
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
|
||||
}
|
||||
})
|
||||
return parsed as PatchOptions[]
|
||||
@@ -156,16 +192,30 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime mirrors for Cordis's erased const-enum fiber states. */
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
const FIBER_PENDING = 0 as FiberState.PENDING
|
||||
|
||||
/**
|
||||
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
|
||||
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
|
||||
* makes `id` readable as the bare identifier `resumeSessionId` in a config
|
||||
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
|
||||
* so resuming a session needs no environment variable. A bin that never
|
||||
* provides it leaves the identifier undeclared, so configs read it defensively
|
||||
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
|
||||
* Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
|
||||
* @param ctx - The settled application root.
|
||||
* @param binName - Diagnostic prefix.
|
||||
*/
|
||||
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
|
||||
export function assertEntriesActive(ctx: Context, binName: string): void {
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
|
||||
if (entry.fiber.state === FIBER_PENDING) {
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath` and return only after the whole
|
||||
@@ -183,7 +233,7 @@ export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
|
||||
* (see {@link resolveConfigPath}).
|
||||
* @param patches - optional overlay patches applied over the included tree
|
||||
* (see {@link loadPersonalPatches}); an empty list mounts none.
|
||||
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
|
||||
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
|
||||
* @returns the root context once every entry has started.
|
||||
*/
|
||||
export async function boot(
|
||||
@@ -193,10 +243,10 @@ export async function boot(
|
||||
prepare?: (ctx: Context) => Promise<void> | void,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await prepare?.(ctx)
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await prepare?.(ctx)
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: {
|
||||
@@ -206,6 +256,7 @@ export async function boot(
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
assertEntriesActive(ctx, binName)
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
|
||||
addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -157,6 +157,25 @@ describe('assertEntriesLoaded', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadOverlayPatches', () => {
|
||||
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
|
||||
const dir = tmp()
|
||||
const valid = join(dir, 'valid.yml')
|
||||
writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
|
||||
expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
|
||||
expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
|
||||
const malformed = join(dir, 'malformed.yml')
|
||||
writeFileSync(malformed, ': bad')
|
||||
expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
|
||||
const mapping = join(dir, 'mapping.yml')
|
||||
writeFileSync(mapping, 'id: target\n')
|
||||
expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
|
||||
const scalar = join(dir, 'scalar.yml')
|
||||
writeFileSync(scalar, '- scalar\n')
|
||||
expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot', () => {
|
||||
it('boots a leaf config through the real Loader and settles the tree', async () => {
|
||||
const dir = tmp()
|
||||
@@ -176,7 +195,11 @@ describe('boot', () => {
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
const prepared: Context[] = []
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
|
||||
expect(hostCtx.loader).toBeDefined()
|
||||
expect([...hostCtx.loader.entries()]).toEqual([])
|
||||
prepared.push(hostCtx)
|
||||
})
|
||||
try {
|
||||
expect(prepared).toEqual([ctx])
|
||||
} finally {
|
||||
@@ -189,6 +212,33 @@ describe('boot', () => {
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
})
|
||||
|
||||
it('rejects a settled tree with a pending inject and names every missing service', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
|
||||
})
|
||||
|
||||
it('uses singular diagnostics for one missing pending dependency', () => {
|
||||
const ctx = {
|
||||
loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
|
||||
})
|
||||
|
||||
it('reports unknown pending dependencies and unexpected fiber states', () => {
|
||||
const entries = [
|
||||
{ disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
|
||||
{ disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
|
||||
]
|
||||
const ctx = {
|
||||
loader: { entries: () => entries },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addHarnessSourceSection', () => {
|
||||
|
||||
@@ -126,3 +126,52 @@ describe('include refresh with overlay patches', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include patches layered over one base', () => {
|
||||
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
|
||||
// The surface/`--config`/personal composition: `dsh` includes one shared
|
||||
// base and applies each source as its own patch list at the SAME include
|
||||
// level, because patches never cross an include boundary. A later layer
|
||||
// must therefore be able to reach a row an earlier layer inserted —
|
||||
// otherwise every surface-only row (the whole TUI front door) would be
|
||||
// invisible to the user's `~/.dsh/config.yaml`.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: base',
|
||||
" name: 'cordis:include'",
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
// Layer 1 (a surface overlay): patch a base row and add two of its own.
|
||||
' - id: shared',
|
||||
' config:',
|
||||
' value: surface',
|
||||
' - insert:',
|
||||
' - id: surface-kept',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: surface-default',
|
||||
' - id: surface-dropped',
|
||||
' name: ./noop.mjs',
|
||||
// Layer 2 (the user): reconfigure one inserted row and disable the other.
|
||||
' - id: surface-kept',
|
||||
' config:',
|
||||
' value: personal',
|
||||
' - id: surface-dropped',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
|
||||
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
|
||||
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
|
||||
expect(dropped?.options.disabled).toBe(true)
|
||||
expect(dropped?.fiber).toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: 99d76d21828bc6b1eb0220e11362885652b3cefd
|
||||
README.zh.md: 71b3b0546fed70a89f34a2ca4eee423be5909afd
|
||||
README.md: dc8af7796d62ca1588423dc63aa592fd3308d218
|
||||
README.zh.md: 8e5fb632d8903c1915015396af20a791a9a3ab70
|
||||
|
||||
@@ -75,7 +75,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. A user exit disposes the application root so sibling resources close, then exits; a five-second fallback prevents one stuck disposer from trapping the process.
|
||||
|
||||
## Color
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
|
||||
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。用户退出会先 dispose 应用根上下文以关闭同级资源,再退出进程;五秒兜底可避免某个卡住的 disposer 困住进程。
|
||||
|
||||
## 颜色
|
||||
|
||||
|
||||
@@ -29,7 +29,13 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
|
||||
export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
readonly agent: Agent
|
||||
readonly runtime: TuiRuntime
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
/**
|
||||
* The optional session-query service, re-read at each use. `sessionQuery` is
|
||||
* mounted by an independent plugin, and a flat config tree gives no ordering
|
||||
* guarantee between it and this front door, so a value captured once at
|
||||
* construction can be `undefined` even though the service arrives moments later.
|
||||
*/
|
||||
readonly sessionQuery: (this: void) => SessionQueryService | undefined
|
||||
readonly ui: TUI
|
||||
readonly editor: HintEditor
|
||||
/** Current agent status, re-read at each resume precondition point. */
|
||||
@@ -74,9 +80,11 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
events: live.events.map(event => structuredClone(event)),
|
||||
}
|
||||
} else {
|
||||
/* v8 ignore next -- caller checks the optional service before mapping records */
|
||||
if (sessionQuery === undefined) throw new Error('session query is unavailable')
|
||||
snapshot = await sessionQuery.readSession(record.header.id)
|
||||
const readQuery = sessionQuery()
|
||||
/* v8 ignore start -- caller proves the optional service before mapping records */
|
||||
if (readQuery === undefined) throw new Error('session query is unavailable')
|
||||
/* v8 ignore stop */
|
||||
snapshot = await readQuery.readSession(record.header.id)
|
||||
}
|
||||
return summarizeResumeCandidate(
|
||||
record,
|
||||
@@ -104,11 +112,13 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
* resolve the exact identity and workspace the host will re-exec into.
|
||||
*/
|
||||
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
|
||||
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
|
||||
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
|
||||
const query = sessionQuery()
|
||||
/* v8 ignore start -- showResume alone calls this after proving the optional service exists */
|
||||
if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
|
||||
/* v8 ignore stop */
|
||||
const initialStatus = deps.agentStatus()
|
||||
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
|
||||
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
|
||||
const candidate = await readResumeCandidate(
|
||||
record,
|
||||
@@ -177,13 +187,14 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
|
||||
return
|
||||
}
|
||||
if (sessionQuery === undefined) {
|
||||
const listQuery = sessionQuery()
|
||||
if (listQuery === undefined) {
|
||||
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
|
||||
return
|
||||
}
|
||||
const scan = ++resumeScan
|
||||
void resumeOverlay?.close()
|
||||
void sessionQuery.listSessions().then(async (records) => {
|
||||
void listQuery.listSessions().then(async (records) => {
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
// Every workspace in the store is summarized; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type SlashCommand,
|
||||
type TerminalColorScheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import { Service, type Context, type Fiber } from 'cordis'
|
||||
import { Service, type Context, type Fiber, type FiberState } from 'cordis'
|
||||
import {
|
||||
assembleContextFor,
|
||||
installAgentLlmTarget,
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
TuiExtensionServiceImpl,
|
||||
TuiOverlayManager,
|
||||
} from './extension/overlay-manager.ts'
|
||||
|
||||
import {
|
||||
parseTuiPromptTemplate,
|
||||
renderTuiPromptTemplate,
|
||||
@@ -171,6 +172,9 @@ export type {
|
||||
TuiViewport,
|
||||
} from './extension/types.ts'
|
||||
|
||||
/** First terminal Cordis state: FAILED, DISPOSED, and UNLOADING are unusable. */
|
||||
const FIBER_FAILED = 3 as FiberState.FAILED
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Terminal-only interaction service, available only while a TUI is mounted. */
|
||||
@@ -183,8 +187,6 @@ declare module 'cordis' {
|
||||
tuiGoodbyeMessage: string | undefined
|
||||
/** Skill the launcher wants auto-invoked as the fresh session's first turn; absent leaves it to the user. */
|
||||
tuiInitialSkill: string | undefined
|
||||
/** Launcher-owned session-store root the app bundle defaults to; absent keeps the bundle's project-local default. */
|
||||
launcherSessionsRoot: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,16 +231,6 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
|
||||
*/
|
||||
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(SESSIONS_ROOT_KEY, root)`) to supply its session-store root as
|
||||
* the app bundle's default persistence root. Shared-store policy (one store
|
||||
* across every cwd) belongs to the launcher — the dsh CLI resolves it under the
|
||||
* Harness home — never to a plugin; a bundle without this slot keeps its own
|
||||
* project-local default, and an explicit `persistenceRoot` config still wins.
|
||||
*/
|
||||
export const SESSIONS_ROOT_KEY = 'launcherSessionsRoot'
|
||||
|
||||
/**
|
||||
* Optional terminal-local interaction service provided by one mounted TUI.
|
||||
*
|
||||
@@ -307,7 +299,6 @@ export function createTuiChat(
|
||||
const sessionId = SessionId(config.sessionId ?? 'main')
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
|
||||
const sessionQuery = ctx.get('sessionQuery')
|
||||
const resolved = resolveTuiConfig(config)
|
||||
const palette = createPalette(resolved.theme.color)
|
||||
const mdTheme = markdownTheme(palette)
|
||||
@@ -854,7 +845,14 @@ export function createTuiChat(
|
||||
resolved,
|
||||
palette,
|
||||
overlayManager,
|
||||
sessionQuery,
|
||||
// Optional and independently mounted. Cordis transiently leaves this sibling
|
||||
// non-ACTIVE during command callbacks, so the non-strict read is intentional;
|
||||
// terminal fiber states still exclude failed, closing, and closed providers.
|
||||
sessionQuery: () => {
|
||||
const implementation = ctx.reflect._getImpl('sessionQuery', false)
|
||||
if (implementation === undefined || implementation.fiber.state >= FIBER_FAILED) return undefined
|
||||
return ctx.get('sessionQuery', false)
|
||||
},
|
||||
ui,
|
||||
editor,
|
||||
appendNotice,
|
||||
@@ -1663,9 +1661,35 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
|
||||
if (existing !== undefined) start(existing)
|
||||
}
|
||||
|
||||
const ROOT_DISPOSE_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* Dispose the whole application before process exit, with a bounded fallback.
|
||||
* @param ctx - The TUI plugin context whose root owns sibling resources.
|
||||
* @param code - Process status to report.
|
||||
* @param exit - Exit boundary, replaceable by tests.
|
||||
*/
|
||||
export function disposeRootAndExit(
|
||||
ctx: Context,
|
||||
code: number,
|
||||
exit: (status: number) => void = (status) => { process.exit(status) },
|
||||
): void {
|
||||
let exited = false
|
||||
const exitOnce = (): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
exit(code)
|
||||
}
|
||||
const timeout = setTimeout(exitOnce, ROOT_DISPOSE_TIMEOUT_MS)
|
||||
void ctx.root.fiber.dispose().then(
|
||||
() => { clearTimeout(timeout); exitOnce() },
|
||||
() => { clearTimeout(timeout); exitOnce() },
|
||||
)
|
||||
}
|
||||
|
||||
/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
|
||||
/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
|
||||
and the tui-agent PTY smoke covers the real entry */
|
||||
and apps/cli PTY smokes cover the real entry */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes')
|
||||
@@ -1685,7 +1709,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
initialSkill === undefined ? {} : { initialSkill },
|
||||
), {
|
||||
terminal: new ProcessTerminal(),
|
||||
exit: code => process.exit(code),
|
||||
exit: (code) => { disposeRootAndExit(ctx, code) },
|
||||
...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) },
|
||||
...goodbyeMessage === undefined ? {} : { goodbyeMessage },
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import {
|
||||
createTuiChat,
|
||||
disposeRootAndExit,
|
||||
FILE_REFERENCE_PROMPT,
|
||||
mountTui,
|
||||
renderSkillInvocation,
|
||||
@@ -502,6 +503,41 @@ describe('goodbye message and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('allows a transient session-query state but rejects a terminal state', async () => {
|
||||
let queryCtx: Context | undefined
|
||||
let listCalls = 0
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
async configureContext(ctx) {
|
||||
await ctx.plugin({
|
||||
apply(child: Context) {
|
||||
queryCtx = child
|
||||
child.provide('sessionQuery', {
|
||||
listSessions: async () => { listCalls++; return [] },
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
if (queryCtx === undefined) throw new Error('query provider did not mount')
|
||||
const activeState = queryCtx.fiber.state
|
||||
queryCtx.fiber.state = 0
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(listCalls).toBe(1)
|
||||
result.terminal.send('\u001B')
|
||||
await tick()
|
||||
queryCtx.fiber.state = 5
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('session query is not mounted')
|
||||
expect(listCalls).toBe(1)
|
||||
queryCtx.fiber.state = activeState
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps persisted query records readable without a persistence service', async () => {
|
||||
const target = header('query-only-persisted', 10, '/workspace')
|
||||
const result = await setup({
|
||||
@@ -4962,6 +4998,59 @@ describe('TUI extension service', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('application exit', () => {
|
||||
it('disposes the root fiber rather than only the TUI child before exiting', async () => {
|
||||
const rootDispose = vi.fn(() => Promise.resolve())
|
||||
const childDispose = vi.fn(() => Promise.resolve())
|
||||
const ctx = {
|
||||
root: { fiber: { dispose: rootDispose } },
|
||||
fiber: { dispose: childDispose },
|
||||
} as unknown as Context
|
||||
const exit = vi.fn()
|
||||
disposeRootAndExit(ctx, 7, exit)
|
||||
await Promise.resolve()
|
||||
expect(rootDispose).toHaveBeenCalledOnce()
|
||||
expect(childDispose).not.toHaveBeenCalled()
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('forces exit when root disposal does not settle', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let settle!: () => void
|
||||
const disposal = new Promise<void>((resolve) => { settle = resolve })
|
||||
const ctx = {
|
||||
root: { fiber: { dispose: () => disposal } },
|
||||
} as unknown as Context
|
||||
const exit = vi.fn()
|
||||
disposeRootAndExit(ctx, 9, exit)
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(9)
|
||||
settle()
|
||||
await disposal
|
||||
await Promise.resolve()
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('exits after a rejected root disposal without an unhandled rejection', async () => {
|
||||
const ctx = {
|
||||
root: { fiber: { dispose: () => Promise.reject(new Error('cleanup failed')) } },
|
||||
} as unknown as Context
|
||||
const exit = vi.fn()
|
||||
disposeRootAndExit(ctx, 5, exit)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(exit).toHaveBeenCalledWith(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal mounting', () => {
|
||||
it('starts immediately when the configured agent already exists', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
Reference in New Issue
Block a user