Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

Resolve additive conflicts in the api-remotes client assembly by keeping
both the message-feedback remote mount and master's forwarded-event
allowlist, and regenerate the module graph.
This commit is contained in:
Chinesezjc
2026-08-11 21:37:55 +08:00
733 changed files with 22553 additions and 3440 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: aea083505cf84207a12086361e5d7f41176c0241
README.zh.md: 013806e802f524b34757bb2de073625eb8b0f768
README.md: 7a1fce6e361a47cf4ac6f02a76107e049411662e
README.zh.md: 9ea5953299874e7f27b8a2fedb8c06790e83065a

View File

@@ -14,6 +14,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable API |
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable API |
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable API |
| [`schedule/`](schedule/README.md) | Session-local scheduled follow-ups | Product — stable API |
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable API |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API |
| [`e2b/`](e2b/README.md) | E2B providers | POC |

View File

@@ -14,6 +14,7 @@ npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通
| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定接口 |
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定接口 |
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定接口 |
| [`schedule/`](schedule/README.md) | 仅限会话内的定时后续轮次 | 产品:稳定接口 |
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定接口 |
| [`llm/`](llm/README.md) | LLM大语言模型能力系列抽象服务 + 提供方适配器 | 产品:稳定接口 |
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/gateway/README.md
README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502
README.zh.md: f8c01b489f51fb5e78b608dd9c24a36c7bc64c3a
README.md: 96f55eead6aec50e2f39f5bcefe71b51cc853298
README.zh.md: 8985fd1f57833bc05f045e72135bb69a5a198d50

View File

@@ -20,6 +20,8 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. Delivery is one-way and follows registration order; a listener that throws is logged and isolated from the remaining listeners, which never affects the frame pump. `ctx.remote.$dispatch()` is the other half of that surface, and it is the carrier's: the Client half owning the Host frame sink hands each decoded frame over, and an event name nobody subscribes to is dropped, since the wire carries whatever the Host selected. A consumer subscribes and never calls it.
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
## Model Experience
@@ -37,3 +39,4 @@ No direct effect; invoked business Services own any model-visible result.
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
- Forwarded events reach `$on` exactly as the Host emitted them: no payload projection or redaction, no Scope-bound subscription, and no replay after a reconnect.

View File

@@ -20,6 +20,8 @@ Connection 可用时Host 入口会在 Connection 共享的 `/api` FetchHandle
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber并随该 fiber 一起消失。投递是单向的,并按注册顺序进行;抛错的 listener 会被记录并与其余 listener 隔离,绝不影响帧泵。`ctx.remote.$dispatch()` 是该面的另一半,且属于载体:持有 Host 帧 sink 的 Client 半把每个解码后的帧交进来,收到无人订阅的事件名即丢弃,因为 wire 上出现什么取决于 Host 的转发选择。消费方只订阅,绝不调用它。
生成的声明合并通过共享的 `TypeRTClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
## 模型体验
@@ -37,3 +39,4 @@ Connection 可用时Host 入口会在 Connection 共享的 `/api` FetchHandle
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
- 被转发的事件原样到达 `$on`:没有载荷投影或脱敏,不支持 Scope 化订阅,重连后也不重放。

View File

@@ -5,7 +5,7 @@
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { Context, Events } from '@deepseek-ai/cordis'
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
import type {
InvocationDescriptor,
@@ -13,6 +13,7 @@ import type {
TypeRTCodec,
TypeRTDisposer,
TypeRTRemoteContribution,
TypeRTRemoteEvent,
} from '@deepseek-ai/dsh-type-meta'
interface MountToken {
@@ -71,14 +72,28 @@ export function apply(ctx: Context): void {
new ClientRemoteService(ctx)
}
/** One subscribed listener after `$on` erased its per-event argument list. */
type RemoteEventListener = (...args: never[]) => void
/**
* One subscription, identified by the registration rather than by its listener:
* two fibers may subscribe the same function object to the same event, and each
* disposer must retire only its own registration.
*/
interface RemoteEventSubscription {
readonly listener: RemoteEventListener
}
class ClientRemoteService extends Service implements TypeRTClientRemote {
private readonly ownerCtx: Context
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
private readonly subscriptions = new Map<string, RemoteEventSubscription[]>()
private mutations = Promise.resolve()
constructor(ctx: Context) {
super(ctx, 'remote')
this.ownerCtx = ctx
ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions')
}
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
@@ -91,6 +106,64 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
return async () => { await owned() }
}
$on<Event extends TypeRTRemoteEvent>(
event: Event,
listener: Events[Event],
): ReturnType<TypeRTClientRemote['$on']> {
// The table is keyed by the runtime event name, so the argument list this
// signature pins per event cannot survive in it; `$deliver` restores it
// from the frame the Host emitted for that same name.
const subscription: RemoteEventSubscription = { listener }
const owned = this.ctx.effect(() => {
const listeners = this.listeners(event)
listeners.push(subscription)
return () => {
const at = listeners.indexOf(subscription)
/* v8 ignore next -- listener */
if (at >= 0) listeners.splice(at, 1)
}
}, `api-gateway.client.$on(${JSON.stringify(event)})`)
return () => { void owned() }
}
/**
* Deliver one forwarded event in registration order, isolating a listener
* that fails either synchronously or by rejecting a returned promise; see
* {@link TypeRTClientRemote.$dispatch} for the caller contract.
*/
$dispatch(event: string, args: readonly unknown[]): void {
const listeners = this.subscriptions.get(event)
if (listeners === undefined) return
// Snapshot: a listener may subscribe or dispose during delivery, and this
// round's recipients are the ones registered when the frame arrived.
for (const { listener } of [...listeners]) {
const report = (error: unknown): void => {
console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error)
}
try {
/* oxlint-disable-next-line typescript/no-confusing-void-expression --
* The declared return is void, so nobody awaits an async listener; the
* runtime value is still a promise, and reading it is the only way to
* keep its rejection inside this containment instead of surfacing as an
* unhandled one. */
const settled: unknown = listener(...args as never[])
if (settled instanceof Promise) settled.catch(report)
} catch (error) {
report(error)
}
}
}
/** Subscriptions for one event name; empty arrays are retained, bounded by the Host's selection. */
private listeners(event: string): RemoteEventSubscription[] {
let listeners = this.subscriptions.get(event)
if (listeners === undefined) {
listeners = []
this.subscriptions.set(event, listeners)
}
return listeners
}
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
const result = this.mutations.then(operation, operation)
this.mutations = result.then(() => undefined, () => undefined)

View File

@@ -16,7 +16,8 @@ export const inject = ['invariants']
/**
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
* state, while Client methods and descriptors mutate in one owned effect.
* state, while Client methods, descriptors, and `$on` subscriptions mutate in
* one owned effect.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,5 +1,6 @@
import { Context, Service } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { Fiber } from '@deepseek-ai/cordis'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { z } from 'zod'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {
@@ -10,9 +11,32 @@ import type {
TypeRTRemoteNamespace,
} from '@deepseek-ai/dsh-type-meta'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import type { ClientRemote } from '../src/client/index.ts'
import { apply, inject } from '../src/client/index.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Test-only forwarded Host event.
* @param namespace - marker payload recorded by listeners.
*/
'fixture/changed'(namespace: string): void
/**
* Test-only forwarded Host event nobody subscribes to.
* @param count - marker payload never observed.
*/
'fixture/idle'(count: number): void
/**
* Test-only event the Host assembly does not forward.
* @param flag - marker payload never delivered.
*/
'fixture/unselected'(flag: boolean): void
}
}
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {}
interface TypeRTContextMap {
fixture: TypeRTContext<string>
}
@@ -43,6 +67,19 @@ type FixtureContext = Omit<Context, 'remote'> & {
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
}
// Compile-time contract of `$on`: the key face is the forwarding selection and
// the listener signature is the owning package's own Cordis declaration.
function remoteEventContracts(remote: ClientRemote): void {
remote.$on('fixture/changed', (namespace) => { void namespace })
// @ts-expect-error -- declared in Events but outside the forwarding selection.
remote.$on('fixture/unselected', () => {})
// @ts-expect-error -- not declared in Events at all.
remote.$on('fixture/absent', () => {})
// @ts-expect-error -- the listener signature comes from the event declaration.
remote.$on('fixture/changed', (count: number) => { void count })
}
void remoteEventContracts
const idSchema = z.string().min(1)
const requestSchema = z.object({ objective: z.string().min(1) })
const createResultSchema = z.object({ ref: z.string().min(1) })
@@ -96,11 +133,19 @@ function contextDescriptor(): InvocationDescriptor {
}
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
const { ctx } = await benchFiber(call)
return ctx
}
async function benchFiber(
call: ConnectionHandle['rpc']['call'],
): Promise<{ readonly ctx: Context; readonly client: Fiber }> {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
await ctx.plugin({ inject, apply })
return ctx
const client = ctx.plugin({ inject, apply })
await client
return { ctx, client }
}
describe('Client TypeRT API', () => {
@@ -570,4 +615,113 @@ describe('Client TypeRT API', () => {
expect(failure.message).toContain('internal: host failed')
expect(failure.cause).toBe(rpcError)
})
it('owns each $on subscription in the calling fiber', async () => {
const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
const seen: string[] = []
const subscriber = ctx.plugin(Object.assign(
(scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
{ inject: ['remote'] },
))
await subscriber
ctx.remote.$dispatch('fixture/changed', ['settings'])
expect(seen).toEqual(['settings'])
await subscriber.dispose()
ctx.remote.$dispatch('fixture/changed', ['after fiber disposal'])
expect(seen).toEqual(['settings'])
await client.dispose()
expect(ctx.get('remote')).toBeUndefined()
})
it('isolates a throwing listener from the rest of the same event', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const seen: string[] = []
const disposeFirst = ctx.remote.$on('fixture/changed', () => {
throw new Error('fixture listener failure')
})
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
try {
ctx.remote.$dispatch('fixture/changed', ['credentials'])
expect(seen).toEqual(['credentials'])
expect(consoleError).toHaveBeenCalledWith(
'client api: Remote event "fixture/changed" listener threw:',
expect.any(Error),
)
disposeFirst()
ctx.remote.$dispatch('fixture/changed', ['commands'])
expect(seen).toEqual(['credentials', 'commands'])
expect(consoleError).toHaveBeenCalledTimes(1)
} finally {
consoleError.mockRestore()
}
})
it('contains an async listener whose promise rejects', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const seen: string[] = []
// The declared return is void, so nobody awaits an async listener: the
// rejection has to be contained here or it escapes as an unhandled one.
ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
try {
ctx.remote.$dispatch('fixture/changed', ['credentials'])
await Promise.resolve()
await Promise.resolve()
expect(seen).toEqual(['credentials'])
expect(consoleError).toHaveBeenCalledWith(
'client api: Remote event "fixture/changed" listener threw:',
expect.any(Error),
)
} finally {
consoleError.mockRestore()
}
})
it('retires only its own registration when one listener subscribes twice', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const seen: string[] = []
// One function object, two registrations. A table keyed by listener identity
// stores it once, so the first frame would reach it once instead of twice
// and either disposer would silence both.
const listener = (namespace: string): void => { seen.push(namespace) }
const disposeFirst = ctx.remote.$on('fixture/changed', listener)
ctx.remote.$on('fixture/changed', listener)
ctx.remote.$dispatch('fixture/changed', ['both'])
expect(seen).toEqual(['both', 'both'])
// The surviving registration keeps receiving after its twin retires.
disposeFirst()
ctx.remote.$dispatch('fixture/changed', ['survivor'])
expect(seen).toEqual(['both', 'both', 'survivor'])
// Disposing twice is inert: the record is already gone, so the second call
// must not splice the surviving twin out from under its own owner.
disposeFirst()
ctx.remote.$dispatch('fixture/changed', ['still here'])
expect(seen).toEqual(['both', 'both', 'survivor', 'still here'])
})
it('separates the consumer verb from the carrier handoff', () => {
expectTypeOf<ClientRemote>().toHaveProperty('$on')
// The carrier owning the frame sink calls this; a consumer subscribes instead.
expectTypeOf<ClientRemote>().toHaveProperty('$dispatch')
})
it('drops a forwarded event nobody subscribes to', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const seen: string[] = []
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
ctx.remote.$dispatch('fixture/idle', [1])
expect(seen).toEqual([])
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
README.md: 567ece0fb58d4b9c0b022dd2ce4d8ee87caccc57
README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a
README.md: cc903af7204ca715c6c7931cfe44823d4d5fc71e
README.zh.md: fe34b8774c9864cef442ff8a58f22f541d40768a

View File

@@ -6,15 +6,23 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway.
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
## Forwarded Host events
`src/remote-events.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`; the type-only `src/types.ts` derives its selection face. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it.
The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-agent-presets`, `dsh-commands`, `dsh-credentials`, `dsh-llm`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypeRTForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way.
## Build boundary
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/remote-events.ts` and `src/types.ts` are listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift.
That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit the same allowlist and type projection into their own programs and emit byte-identical `remote-events` and `types` outputs into `lib/types`; the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph records why the double listing is intentional.
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.

View File

@@ -6,15 +6,25 @@
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence并为 TypeRT `agent``session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。
## 转发的 Host 事件
`src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集;只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。
监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-agent-presets``dsh-commands``dsh-credentials``dsh-llm``dsh-settings`),本包两个 face 都把那些声明纳入编译面因此「原样转发」是构造性成立的不需要另立证明。Host face 还额外把名单断言给 `TypeRTForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。
## 构建边界
仓库中的普通包只属于一个 TypeScript faceHost 包登记在根 `tsconfig.host.json`Client 包登记在根 `tsconfig.client.json``api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json``tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json``tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/remote-events.ts``src/types.ts` **同时**列进两个 face 的 `files`因为转发事件名单是「消费端能收到什么」的唯一控制点Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明
这条例外不止是一行 `files`。根 `tsconfig.base.json``@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把同一份名单与类型投影收进各自的 program并向 `lib/types` 发射逐字相同的 `remote-events``types` 输出;`.tsbuildinfo` 仍各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此本段记录这次双列为何是有意的。
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle不得因一个包同时存在 `src/index.ts``src/client/index.ts` 就复制本包的拆分。

View File

@@ -26,6 +26,10 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -47,28 +51,41 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"dependencies": {
"@deepseek-ai/dsh-type-meta": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}

View File

@@ -8,6 +8,23 @@ import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
export type { ApiRemoteForwardedEvent } from '../types.ts'
// The owner packages' client-safe `./types` exports supply the `Events`
// signatures `$on` hands to a listener, so a consumer reads the very
// declaration the Host emits rather than a flattened restatement of it.
export type {} from '@deepseek-ai/dsh-commands/types'
export type {} from '@deepseek-ai/dsh-credentials/types'
export type {} from '@deepseek-ai/dsh-llm/types'
export type {} from '@deepseek-ai/dsh-agent-presets/types'
export type {} from '@deepseek-ai/dsh-settings/types'
/**
* The Gateway Client face's own declaration merges, type-only: `ctx.remote` and
* with it the `$on`/`$dispatch` surface. Erased at emit, so this facade still
* carries no runtime edge to the Gateway implementation.
*/
export type {} from '@deepseek-ai/dsh-api-gateway/client'
declare module '@deepseek-ai/cordis' {
interface Context {

View File

@@ -1,5 +1,18 @@
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta'
import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
// The owner packages' client-safe `./types` exports carry the cordis `Events`
// declarations for every allowlisted event. Pulling them into this face is what
// makes the shape assertion below judge real signatures rather than an empty
// event vocabulary.
import type {} from '@deepseek-ai/dsh-commands/types'
import type {} from '@deepseek-ai/dsh-credentials/types'
import type {} from '@deepseek-ai/dsh-llm/types'
import type {} from '@deepseek-ai/dsh-agent-presets/types'
import type {} from '@deepseek-ai/dsh-settings/types'
export {
ApiRemoteSessionNotFound,
ApiRemoteSubagentSessionOwnership,
@@ -13,6 +26,18 @@ export type {
ApiRemoteAgentResult,
ApiRemoteLookupError,
} from './agent-lookup.ts'
export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
export type { ApiRemoteForwardedEvent } from './types.ts'
// Shape gate over the allowlist, kept in the Host face because the Host's event
// vocabulary is the authoritative one. It pins three things at compile time:
// every entry NAMES a declared event (the predicate is keyed on `keyof
// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not
// `unknown`, which is how "must not depend on AgentScope" is stated statically),
// and every entry is ONE-WAY (a waterfall or bail shape returns something other
// than void and is excluded). Widening the array to an event that fails any of
// these fails here, not on the wire.
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[]
/** Host plugin body; the selected contributions mount only in Client environments. */
export function apply(): void {}

View File

@@ -0,0 +1,23 @@
/**
* The one home of this application's forwarded-Host-event allowlist. Both
* compiler faces list this file, so the Host forwarding loop and the consumer
* `ctx.remote.$on` key face read one declaration instead of two copies that
* could drift; `./types.ts` derives the type projection from it and stays
* type-only.
*/
/**
* Host events this application forwards to consumers verbatim: no projection,
* no redaction, no renaming. The wire name is the Host cordis event name and
* the payload is its argument list, so this array is simultaneously the whole
* control point over what a consumer can receive and the legal key set of
* `ctx.remote.$on`. Forwarding one more event is an entry here and nothing
* else.
*/
export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected',
'commands/change',
'credentials/updated',
'llm/adapters-updated',
'settings/document-updated',
] as const

View File

@@ -0,0 +1,19 @@
/**
* Type face of the forwarded-Host-event allowlist: the consumer key projection
* and the selection seat it fills. The allowlist VALUE lives in
* `./remote-events.ts`, keeping this module type-only per the package
* convention; both compiler faces list both files, so the Host forwarding loop
* and the consumer `ctx.remote.$on` key face read one declaration instead of
* two copies that could drift.
*
* @module @deepseek-ai/dsh-api-remotes/types
*/
import type { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
/** Type projection of the allowlist; the consumer and the Host read this one. */
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
declare module '@deepseek-ai/dsh-type-meta' {
interface TypeRTRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
}

View File

@@ -6,18 +6,38 @@
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/index.ts"
"src/client/index.ts",
"src/remote-events.ts",
"src/types.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../gateway"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../goal/goal"
},
{
"path": "../../feedback/message-feedback"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../llm/llm"
},
{
"path": "../../preset/agent-presets"
},
{
"path": "../../settings/settings"
},
{
"path": "../../typert/type-meta"
}

View File

@@ -8,7 +8,9 @@
"files": [
"src/agent-lookup.ts",
"src/index.ts",
"src/invariant.ts"
"src/invariant.ts",
"src/remote-events.ts",
"src/types.ts"
],
"references": [
{
@@ -20,9 +22,24 @@
{
"path": "../../core/session"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../llm/llm"
},
{
"path": "../../preset/agent-presets"
},
{
"path": "../../session/session-persistence"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f
README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa
README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119
README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path.
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
## Model Experience

View File

@@ -4,7 +4,7 @@
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIXWindows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`
## 模型体验

View File

@@ -68,8 +68,8 @@ export class LocalAttachmentStore extends AttachmentStore {
return saveImageFile(this.root, input, this.imageLimits)
}
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
return readImageFile(this.root, ref)
async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment> {
return readImageFile(this.root, ref, signal)
}
}

View File

@@ -197,22 +197,32 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
* Read and verify one content-addressed image.
* @param root - absolute `DSH_HOME/attachments/v1` root.
* @param ref - reference recorded in the session log.
* @param signal - optional cancellation for filesystem and verification work.
* @returns verified bytes and reference.
* @throws the signal reason when aborted, or an AttachmentError when verification fails.
*/
export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
export async function readImageFile(
root: string,
ref: ImageAttachmentRef,
signal?: AbortSignal,
): Promise<StoredImageAttachment> {
signal?.throwIfAborted()
const sha256 = ensureReference(ref)
let data: Uint8Array
try {
data = new Uint8Array(await readFile(objectPath(root, sha256)))
data = new Uint8Array(await readFile(objectPath(root, sha256), { signal }))
} catch (error) {
signal?.throwIfAborted()
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
}
signal?.throwIfAborted()
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
// The digest proves these are the exact bytes admission fully decoded, so
// the read path only re-derives the header fields (no raster decode, no
// per-request pixel amplification on history replay).
const metadata = await probeImage(data)
signal?.throwIfAborted()
if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
|| metadata.width !== ref.width || metadata.height !== ref.height) {
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')

View File

@@ -9,12 +9,23 @@ import sharp from 'sharp'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import { readImageFile, saveImageFile } from '../src/store.ts'
const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] }))
const fsControl = vi.hoisted(() => ({
readSignals: [] as AbortSignal[],
syncedDirectories: [] as string[],
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
readFile(...args: Parameters<typeof actual.readFile>): ReturnType<typeof actual.readFile> {
const options = args[1]
if (typeof options === 'object' && options !== null) {
const signal = (options as { signal?: AbortSignal }).signal
if (signal !== undefined) fsControl.readSignals.push(signal)
}
return actual.readFile(...args)
},
async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0]))
return actual.open(...args)
@@ -130,6 +141,20 @@ describe('local attachment store', () => {
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
})
it('forwards read cancellation to the filesystem and preserves its reason', async () => {
const storageRoot = await root()
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const controller = new AbortController()
fsControl.readSignals.length = 0
await expect(readImageFile(storageRoot, ref, controller.signal)).resolves.toEqual({ ref, data: PNG })
expect(fsControl.readSignals).toEqual([controller.signal])
const cancellation = new Error('attachment read cancelled')
controller.abort(cancellation)
await expect(readImageFile(storageRoot, ref, controller.signal)).rejects.toBe(cancellation)
})
it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
const storageRoot = await root()
await expect(saveImageFile(storageRoot, {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
README.md: 4f450316294e554396adb9a8454051a08d9befd3
README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890
README.md: baeeca0cf939f1a3d4608769b362d532507b90f5
README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
## Model Experience

View File

@@ -4,7 +4,7 @@
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
## 模型体验

View File

@@ -52,9 +52,11 @@ export abstract class AttachmentStore extends Service {
/**
* Read one image and verify that bytes still match the recorded reference.
* @param ref - durable reference from the session log.
* @param signal - optional cancellation for backend read and verification work.
* @returns the verified bytes and canonical reference.
* @throws the signal reason when aborted, or a storage error when verification fails.
*/
abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
}
export default AttachmentStore

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
README.md: 253d1c9efb518ca204956f062b2f529f4e83531b
README.zh.md: 5a0d943a08d1da6c73ad07f469c056180b476ff3
README.md: 386ffc00466108ab14352b6d39d3a20da3321e1c
README.zh.md: 39d37bededa72ec96911bb1ce055ed105fdd7082

View File

@@ -23,6 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
## Behavior
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).

View File

@@ -23,6 +23,7 @@
## 行为
- **每次调用都 spawn不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。
- **在受管进程组之上应用配置预算**`resolve()` 从配置补全 `workdir``timeoutMs``stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算stderr 和后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
- **适合模型的终端环境**`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。

View File

@@ -36,7 +36,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
}
}

View File

@@ -11,9 +11,10 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
@@ -71,6 +72,26 @@ function assertPositiveFinite(name: string, value: number): void {
}
}
/**
* Reject a resolved section this executor could not run with. The schema
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
* fit, so a stored value is refused where it is written instead of failing at
* the next command.
* @param config - the resolved section, schema-valid by construction.
* @throws Error naming the field that cannot be used.
*/
export function assertServiceableBashConfig(config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
assertPositiveFinite('graceMs', resolved.graceMs)
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
* process-group SIGTERM→SIGKILL escalation are the subprocess service's
@@ -90,21 +111,29 @@ export class LocalBashExecutor extends BashExecutor {
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
/** The currently authoritative config: the settings section, or the composition entry. */
private source: () => ResolvedConfig
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
get config(): ResolvedConfig {
return this.source()
}
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery fills these fields before construction; the type does not encode that step.
this.config = config as ResolvedConfig
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
const entry = config as ResolvedConfig
assertServiceableBashConfig(entry)
this.source = () => entry
installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, {
validate: assertServiceableBashConfig,
setSource: (current) => {
this.source = current as () => ResolvedConfig
},
// Every field is read through the getter at each command, so nothing
// derived from the source needs rebuilding when the document changes.
onChange: () => {},
})
}
/**

View File

@@ -0,0 +1,115 @@
/** The `bash` settings section layered over the executor's composition entry. */
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import { Settings } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends Settings {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
async function boot(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}): Promise<{
ctx: Context
settingsFiber: Fiber
executorFiber: Fiber
bash: LocalBashExecutor
}> {
const ctx = new Context()
await ctx.plugin(LocalSubprocessService)
const settingsFiber = ctx.plugin(MemorySettings)
await settingsFiber.await()
const executorFiber = ctx.plugin(LocalBashExecutor, { timeoutMs: 60_000, ...config })
await executorFiber.await()
return { ctx, settingsFiber, executorFiber, bash: ctx.bash as LocalBashExecutor }
}
describe('bash settings section', () => {
it('resolves the user layer over the composition entry', async () => {
const bench = await boot()
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.bash.config.timeoutMs).toBe(5_000)
await bench.ctx.fiber.dispose()
})
it('refuses a stored value the constructor would have rejected', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
.rejects.toThrow(/positive finite/)
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('refuses a grace period longer than a timer can carry', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { graceMs: Number.MAX_SAFE_INTEGER }))
.rejects.toThrow(/graceMs must be no greater than/)
await bench.ctx.fiber.dispose()
})
it('serves the stored section to every later read', async () => {
const bench = await boot()
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { maxOutputBytes: 1_024, cwd: '/tmp' })
const spec = bench.bash.resolve({ command: 'true' })
expect(spec.stdoutMaxBytes).toBe(1_024)
expect(spec.workdir).toBe('/tmp')
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the settings provider detaches', async () => {
const bench = await boot()
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.bash.config.timeoutMs).toBe(5_000)
await bench.settingsFiber.dispose()
expect(bench.bash.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('keeps the composition entry when no settings provider is mounted', async () => {
const ctx = new Context()
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 1_234 })
expect((ctx.bash as LocalBashExecutor).config.timeoutMs).toBe(1_234)
await ctx.fiber.dispose()
})
it('releases the namespace when the executor unloads', async () => {
const bench = await boot()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
await bench.executorFiber.dispose()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
await bench.ctx.fiber.dispose()
})
})

View File

@@ -29,6 +29,9 @@
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
README.md: a076c6ef3150de86c251f0501b18de01c4491c67
README.zh.md: 1be9f817a8379b2974fb4cfd63c197c320934f00
README.md: ef873dfb87cb330847274be59d5f2a0c3a5bc0b9
README.zh.md: 4047765f248a25ff700ba62bf87636c0e8bfe7ac

View File

@@ -27,6 +27,8 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
`BASH_SETTINGS_NAMESPACE` (`bash`) is exported here rather than by a provider because it names the capability, not an implementation. A host composes exactly one provider of `ctx.bash` — the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration — so every provider can register this one namespace with its own schema and composition entry without two of them ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both.
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.

View File

@@ -27,6 +27,8 @@
实现会继承 `BashExecutor` 并实现抽象方法。dispose资源释放必须终止每个运行中的进程并等待其退出。
`BASH_SETTINGS_NAMESPACE``bash`)由此处导出而非由某个提供方导出,因为它命名的是能力而不是实现。一个宿主只组装一个 `ctx.bash` 提供方——win32 层会把 POSIX 行换成 pwsh 行,同时挂载两者会因服务重复注册而在加载期失败——所以每个提供方都能用自己的 schema 与组装条目注册这同一个命名空间,两者永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析。
## 词汇
`BashExecRequest`command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。

View File

@@ -35,12 +35,14 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
}
}

View File

@@ -6,9 +6,21 @@
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
/**
* Settings namespace of this capability, owned here rather than by either
* executor family because it names the capability, not an implementation: a
* host composes exactly one provider of `ctx.bash` (the win32 layer swaps the
* POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate
* service registration), so the providers share one namespace without ever
* registering it twice, and a settings document carried between platforms
* keeps resolving on both.
*/
export const BASH_SETTINGS_NAMESPACE = settingsNamespace('bash')
export { DSH_ENV_PREFIX } from './types.ts'
export type {
BashExecRequest,

View File

@@ -20,6 +20,9 @@
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/pwsh-local/README.md
README.md: eb3365b009e3595230e5fb0f616079bd73c55840
README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b
README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e
README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22

View File

@@ -28,8 +28,9 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its `
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section.
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.

View File

@@ -28,8 +28,9 @@
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding``$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认pwsh 7 默认为 UTF-8不受影响。
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数在构造时执行一次
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止Windows 用 taskkillPOSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算stderr 与后台运行仍使用 `maxOutputBytes`
- **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal``killed` 状态)在那里仅限 POSIX超时/取消分类与平台无关。
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。

View File

@@ -36,7 +36,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^"
}
}

View File

@@ -13,12 +13,16 @@
* @module @deepseek-ai/dsh-pwsh-local
*/
/* jscpd:ignore-start -- this executor mirrors dsh-bash-local call-for-call by
design (see this package's README), so the two import the same seam surface */
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/* jscpd:ignore-end */
import { resolvePwshPath } from './resolve.ts'
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
@@ -96,6 +100,26 @@ function assertPositiveFinite(name: string, value: number): void {
}
}
/**
* Reject a resolved section this executor could not run with. The schema
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
* fit, so a stored value is refused where it is written instead of failing at
* the next command.
* @param config - the resolved section, schema-valid by construction.
* @throws Error naming the field that cannot be used.
*/
export function assertServiceablePwshConfig(config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
assertPositiveFinite('graceMs', resolved.graceMs)
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**
* Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
* files, and process-tree termination are the subprocess service's mechanics;
@@ -114,25 +138,47 @@ export class PwshLocalExecutor extends BashExecutor {
pwshPath: z.string(),
})
/** Validated config (schemastery applied the defaults before construction). */
readonly config: ResolvedConfig
/** The currently authoritative config: the settings section, or the composition entry. */
private source: () => ResolvedConfig
/** The pwsh executable resolved once at construction. */
readonly pwshPath: string
/** The declared executable the current {@link pwshPath} was resolved from. */
private declaredPwshPath: string | undefined
/** The pwsh executable resolved from the current config. */
private resolvedPwshPath: string
/** Validated config (schemastery applied the defaults before construction). */
get config(): ResolvedConfig {
return this.source()
}
/** The pwsh executable every command runs through. */
get pwshPath(): string {
return this.resolvedPwshPath
}
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery fills these fields before construction; the type does not encode that step.
this.config = config as ResolvedConfig
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
}
this.pwshPath = resolvePwshPath(this.config.pwshPath)
const entry = config as ResolvedConfig
assertServiceablePwshConfig(entry)
this.source = () => entry
this.declaredPwshPath = entry.pwshPath
this.resolvedPwshPath = resolvePwshPath(entry.pwshPath)
installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, PwshLocalExecutor.Config, entry, {
validate: assertServiceablePwshConfig,
setSource: (current) => {
this.source = current as () => ResolvedConfig
},
// Probing the filesystem is the one fact derived from the source: every
// other field is read through the getter at each command.
onChange: () => {
const declared = this.source().pwshPath
if (declared === this.declaredPwshPath) return
this.declaredPwshPath = declared
this.resolvedPwshPath = resolvePwshPath(declared)
},
})
}
/**

View File

@@ -31,10 +31,10 @@ const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInte
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
/** Filesystem path equality across macOS temp symlinks and Windows drive-letter casing. */
function samePath(actual: string, expected: string): boolean {
const norm = (value: string) => (
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : realpathSync.native(value)
)
return norm(actual) === norm(expected)
}

View File

@@ -0,0 +1,108 @@
/** The shared `bash` settings section as the pwsh executor family resolves it. */
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import { Settings } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends Settings {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
async function boot(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}): Promise<{
ctx: Context
settingsFiber: Fiber
executorFiber: Fiber
pwsh: PwshLocalExecutor
}> {
const ctx = new Context()
await ctx.plugin(LocalSubprocessService)
const settingsFiber = ctx.plugin(MemorySettings)
await settingsFiber.await()
const executorFiber = ctx.plugin(PwshLocalExecutor, { timeoutMs: 60_000, ...config })
await executorFiber.await()
return { ctx, settingsFiber, executorFiber, pwsh: ctx.bash as PwshLocalExecutor }
}
describe('pwsh executor over the bash settings section', () => {
it('resolves the user layer over the composition entry', async () => {
const bench = await boot()
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
await bench.ctx.fiber.dispose()
})
it('refuses a stored value the constructor would have rejected', async () => {
const bench = await boot()
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
.rejects.toThrow(/pwsh-local: timeoutMs must be a positive finite number/)
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
await bench.ctx.fiber.dispose()
})
it('re-resolves the executable when the stored path changes', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { pwshPath: '/opt/second/pwsh' })
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
await bench.ctx.fiber.dispose()
})
it('keeps the resolved executable when an unrelated field changes', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
const before = bench.pwsh.pwshPath
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
expect(bench.pwsh.pwshPath).toBe(before)
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the settings provider detaches', async () => {
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000, pwshPath: '/opt/second/pwsh' })
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
await bench.settingsFiber.dispose()
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
await bench.ctx.fiber.dispose()
})
it('releases the namespace when the executor unloads', async () => {
const bench = await boot()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
await bench.executorFiber.dispose()
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
await bench.ctx.fiber.dispose()
})
})

View File

@@ -29,6 +29,9 @@
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: 9639f1c0a2ffe91fd509a2ffdf04be5f0895b700
README.zh.md: e8bf0374aad2be2311b6e72e91e41f02f403b48a
README.md: 4c82aa749edbeada0a344b0b119d1644544d9732
README.zh.md: 38298e091af4aa1b09c31dfa8141ce68fa1d3f50

View File

@@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home:
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
Every profile boot keeps `cordis.patch.yml` live through `watchUserPatches` (a one-shot surface disposes the watcher through its bounded shutdown). The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
## Model Experience

View File

@@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录Harness home 由 [`
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
- **`cordis.patch.yml`**home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`
长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时最后一个可用树会继续运行HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher并等待进行中的刷新结束。
每次 profile 启动都由 `watchUserPatches` 持续应用 `cordis.patch.yml` 的变更(一次性 surface 经由有界关闭 dispose 监视器)。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时最后一个可用树会继续运行HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher并等待进行中的刷新结束。
## 模型体验

View File

@@ -110,21 +110,33 @@ function entryConfig(ctx: Context, id: string): unknown {
}
describe('Loader config interpolation', () => {
it("resolves Include's own !!js options", async () => {
it("keeps Include's config literal — a nested row's !!js belongs to that row's fiber", async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
writeFileSync(join(dir, 'reader.mjs'), [
'export const name = "reader"',
'export function apply(ctx, config) { ctx.provide("observedValue", config.value) }',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: reader\n name: ./reader.mjs\n')
const ctx = new Context()
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href)
ctx.provide('answer', 42)
try {
// The include is a tree carrier: its own config (path, patches) stays
// literal, and the expression nested inside the patched row's config
// resolves against the row's fiber, not the include's.
await ctx.loader.create({
name: 'cordis:include',
config: { path: { __jsExpr: "ctx.get('includePath')" } },
config: {
path: pathToFileURL(join(dir, 'cordis.yml')).href,
patches: [{ id: 'reader', name: './reader.mjs', config: { value: { __jsExpr: "ctx.get('answer')" } } }],
},
})
await ctx.loader.await()
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true)
const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
expect(reader?.options.config).toEqual({ value: { __jsExpr: "ctx.get('answer')" } })
expect(ctx.get('observedValue')).toBe(42)
} finally {
await ctx.fiber.dispose()
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md
README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96
README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114
README.md: 2e8e58b23785fa78bd2663a459817669309a81be
README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd

View File

@@ -51,8 +51,6 @@ Every row configured from those values uses ordinary service injection and direc
Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset.
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering.
### Shared immutable arguments
`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments.

View File

@@ -51,8 +51,6 @@ export function apply(ctx: Context): void {
Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`Loader 索取 `webserver` 的配置之前Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。
### 共享不可变参数
`get()` 不会消费或修改 argv。多个插件可以解析同一份快照并分别提供服务。启动器不会检查组合中的命令行所有者没有读取方的 profile 只会忽略自己的应用参数。

View File

@@ -17,8 +17,6 @@
import type { Command } from 'commander'
import type { Context } from '@deepseek-ai/cordis'
// Empty type import carries the Loader Context merge used by enableRow.
import type {} from '@deepseek-ai/cordis-plugin-loader'
/**
* The invocation's inner arguments: everything after the launcher's own flags,
@@ -133,28 +131,6 @@ export function parseCmdline<T>(
}
}
/**
* Turn on a row this composition ships disabled, because this invocation asked
* for it (`dsh web --dev` and its client-plugin reload chain).
*
* A row cannot be inserted from inside a mounting plugin — the Loader returns a
* prefixed id it then fails to resolve — so a conditional row ships disabled
* and a row mounted beside it enables it after startup resolves the invocation.
* The Loader keeps that activation in memory, separate from serialized options,
* so reapplying the composition cannot restore the invocation's row to disabled.
* @param ctx - plugin context whose Loader tree carries the row.
* @param id - the row id.
* @returns nothing once the row has started or is waiting for its dependencies.
* @throws when the Loader or named row is absent.
*/
export async function enableRow(ctx: Context, id: string): Promise<void> {
const loader = ctx.get('loader')
if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service')
const entry = [...loader.entries()].find(candidate => candidate.options.id === id)
if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
await entry.enableRuntime()
}
/**
* Whether a thrown value is commander's own control-flow error (help, version,
* a parse error, or `program.error`).

View File

@@ -14,9 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { afterEach, describe, expect, it } from 'vitest'
import {
enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan,
} from '../src/index.ts'
import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts'
/** Every value one boot of the fixture tree observed. */
interface Observed {
@@ -175,71 +173,6 @@ describe('parseCmdline', () => {
})
})
describe('enableRow', () => {
it('enables the named Loader row and fails loud when the Loader or row is absent', async () => {
const withoutLoader = new Context()
await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service')
const ctx = new Context()
let enabled = false
ctx.provide('loader', {
entries: () => [{
options: { id: 'client-hmr' },
enableRuntime: async () => { enabled = true },
}],
} as never)
await enableRow(ctx, 'client-hmr')
expect(enabled).toBe(true)
await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable')
})
it('keeps invocation-only activation through config reapplication', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-'))
const observed = { starts: 0, stops: 0 }
;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed
writeFileSync(join(dir, 'conditional.mjs'), `
export function apply(ctx) {
globalThis.__runtimeEnableObserved.starts += 1
ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 })
}
`)
writeFileSync(join(dir, 'cordis.yml'), [
'- id: conditional',
` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`,
' disabled: true',
'',
].join('\n'))
const ctx = new Context()
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(join(dir, 'cordis.yml')).href },
})
await ctx.loader.await()
const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional')
const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include')
expect(conditional).toBeDefined()
expect(include?.fiber).toBeDefined()
expect(conditional?.options.disabled).toBe(true)
expect(observed).toEqual({ starts: 0, stops: 0 })
await enableRow(ctx, 'conditional')
await ctx.loader.await()
expect(conditional?.disabled).toBe(false)
expect(conditional?.options.disabled).toBe(true)
expect(observed).toEqual({ starts: 1, stops: 0 })
await include!.fiber!.update(include!.options.config, true)
await ctx.loader.await()
expect(conditional?.disabled).toBe(false)
expect(conditional?.options.disabled).toBe(true)
expect(observed).toEqual({ starts: 1, stops: 0 })
disposers.push(async () => { await ctx.fiber.dispose() })
})
})
describe('provideCmdline', () => {
it('hands the app a snapshot the caller cannot mutate afterwards', () => {
const ctx = new Context()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
README.md: 31a4894dbb191d2244371ca7272339e96e253053
README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a
README.md: 3d9ca350f5f8891e60cfc57c9ca89ef57d9790d3
README.zh.md: 1dcba9635b37efebeb0cc1129cc67bc7c01d0d1d

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin.
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
## Model Experience
@@ -17,4 +17,4 @@ None; the runner adds nothing to the request prefix.
## Known Limitations and Deferred Work
- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the hook.
- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request.

View File

@@ -4,7 +4,7 @@
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR热模块替换、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent智能体将任务作为普通用户消息提交并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0否则为 1。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent智能体将任务作为普通用户消息提交并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.md)请求退出(最终 `turn/end` 完成 → 0否则为 1。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
## 模型体验
@@ -17,4 +17,4 @@ Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a
## 已知限制与延期工作
- **只提交一个任务**runner 没有用于交互式后续输入的 surface它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该钩子
- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求

View File

@@ -9,7 +9,8 @@
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
# One-shot runs never watch or reload their composition.
# The shared module-reload HMR row stays off; the launcher's watch-only
# fallback still keeps the user patch layers live until the run exits.
- id: hmr
disabled: true

View File

@@ -16,8 +16,10 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
// Empty type import carries the loader Context merge for the settlement await.
// Empty type imports carry the loader Context merge for the settlement await
// and the cmdline Context merge for the appExit host value.
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-cmdline'
/** Stable Cordis plugin name. */
export const name = 'headless-runner'
@@ -41,22 +43,18 @@ interface RunOutcome {
reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
}
/**
* Process-facing effects of one run, injectable for tests. The launcher owns
* bounded tree shutdown and wires `exit()` to it.
*/
export interface HeadlessIo {
/** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
interface HeadlessIo {
stdout: { write(chunk: string): unknown }
stderr: { write(chunk: string): unknown }
/** Request process exit with `code` after the tree disposes. */
exit(code: number): void
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Process-facing effects provided before the headless tree mounts. */
headlessIo?: HeadlessIo
}
/** The process streams the runner writes to; tests substitute captures. */
export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
stdout: process.stdout,
stderr: process.stderr,
}
/** Aggregate the last assistant text and turn outcome in one owned interval. */
@@ -137,13 +135,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
/**
* Mount the one-shot direct driver.
* @param ctx - plugin context carrying core services and the launcher-owned IO seam.
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
* @param config - validated task config.
*/
export function apply(ctx: Context, config: Config): void {
const io = ctx.headlessIo
if (io === undefined) {
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
// Read through the global service store, not the property proxy: appExit is
// an optional host value, never an injected dependency.
const exit = ctx.get('appExit')
if (exit === undefined) {
throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
}
const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
}

View File

@@ -1,6 +1,6 @@
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
@@ -8,7 +8,10 @@ import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { apply, Config, type HeadlessIo } from '../src/index.ts'
import { apply, Config, internals } from '../src/index.ts'
const originalInternals = { ...internals }
afterEach(() => { Object.assign(internals, originalInternals) })
interface Script {
before?(session: Session): void
@@ -93,13 +96,10 @@ async function bench(script: Script): Promise<{
let err = ''
const order: string[] = []
ctx.on('session/flush', () => { order.push('flush') })
internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
const exited = new Promise<number>((resolve) => {
const io: HeadlessIo = {
stdout: { write: (chunk: string) => { out += chunk; return true } },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: (code) => { order.push('exit'); resolve(code) },
}
ctx.provide('headlessIo', io)
ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
})
apply(ctx, { task: 'do the thing' })
return { code: await exited, out, err, order }
@@ -181,12 +181,10 @@ describe('headless runner', () => {
it('reports a direct Agent creation failure', async () => {
const ctx = new Context()
let err = ''
internals.stdout = { write: () => true }
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
const exited = new Promise<number>((resolve) => {
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: resolve,
} satisfies HeadlessIo)
ctx.provide('appExit', resolve)
})
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
@@ -200,12 +198,10 @@ describe('headless runner', () => {
it('stringifies a non-Error Agent creation failure', async () => {
const ctx = new Context()
let err = ''
internals.stdout = { write: () => true }
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
const exited = new Promise<number>((resolve) => {
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: resolve,
} satisfies HeadlessIo)
ctx.provide('appExit', resolve)
})
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
@@ -224,11 +220,9 @@ describe('headless runner', () => {
it('abandons a run when the tree is disposed during Loader settlement', async () => {
const ctx = new Context()
let exited = false
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: () => true },
exit: () => { exited = true },
} satisfies HeadlessIo)
internals.stdout = { write: () => true }
internals.stderr = { write: () => true }
ctx.provide('appExit', () => { exited = true })
const services = ctx.plugin((child: Context) => {
child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
child.provide('sessions', {} as never)
@@ -246,9 +240,9 @@ describe('headless runner', () => {
await ctx.fiber.dispose()
})
it('fails loud without the launcher-owned headlessIo seam', () => {
it('fails loud without the launcher-provided exit request', () => {
const ctx = new Context()
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
})
it('validates config: the task is required', () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd
README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68
README.md: 06856a47cd8ccc2c6ee5a53c40928b1bd2933cc7
README.zh.md: 8befc7c7404ea1b082842f122769967fff32df2f

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
## Model Experience
@@ -10,7 +10,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove
#### What the model sees
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order 98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered.
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order 98) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered.
#### Token effect
@@ -18,7 +18,7 @@ One source line and one prompt paragraph per session plus two managed-environmen
#### KV Cache effect
The prompt section sits near the system prompt's head and is stable for the life of the process (port and mode are boot facts), so it does not invalidate the cache across turns.
The prompt section sits near the system prompt's head and is stable for the life of the process (the port is a boot fact), so it does not invalidate the cache across turns.
## Known Limitations and Deferred Work

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona插入 Web 宿主行webserver、API 网关、workspace、投影缓存、存储浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL``DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host``--port``--dev`可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona插入 Web 宿主行webserver、API 网关、workspace、投影缓存、存储浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`[`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host``--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
## 模型体验
@@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
#### 模型看到的内容
`surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 98则向模型说明 GUI规范的本地 URL、「this page」指代什么、当前模式下 HMR热模块替换重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL``DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。
`surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 98则向模型说明 GUI规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher,以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和变量都不会注册。
#### Token 影响
@@ -18,7 +18,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
#### KV Cache 影响
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口是启动期事实),因此不会使跨轮次缓存失效。
## 已知限制与延期工作

View File

@@ -8,8 +8,8 @@
# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an
# ordinary Cordis service. Rows configured from flags inject that service, so
# Loader resolves their expressions only after it exists. The web runtime then
# provides bind-dependent `webRuntime` values to the trust fence and client
# roster. `dsh --profile web --help` provides neither service, so no server binds.
# provides bind-dependent `webRuntime` values to the trust fence.
# `dsh --profile web --help` provides neither service, so no server binds.
# ── surface-specific values the base deliberately omits ─────────────────────
@@ -105,39 +105,34 @@
# Web glue owned by this bundle: resolves the built frontend dist (an
# assembly fact of dsh-web-app, never user config), mounts the
# frontend-static fallback owner, registers the web-surface prompt
# section and bash runtime variables, and prints the URL line. The webStartup
# provider supplies invocation-only values; after the server binds, this row
# samples LAN trust once and provides `webRuntime`. A complete agent-preset
# persona suppresses the prompt section for that agent while retaining
# these host-owned shell variables.
# section and the bash runtime variable, and prints the URL line. The
# webStartup provider supplies invocation-only values; after the server
# binds, this row samples LAN trust once and provides `webRuntime`. A
# complete agent-preset persona suppresses the prompt section for that
# agent while retaining the host-owned shell variable.
- id: web-runtime
name: '@deepseek-ai/dsh-web-app'
inject: [webStartup]
config:
mode: !!js ctx.webStartup.mode
printUrl: true
surfaceContext: true
trustedHosts: !!js ctx.webStartup.trustedHosts
# The client-plugin reload chain: a dev-only row this bundle ships off,
# which the runtime row turns on before client discovery. It is a row rather
# than a child of web-runtime because its node half is a client-side package,
# which a host-side bundle cannot import.
# The client-plugin reload chain, always mounted: it is idle until a
# rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It
# is a row rather than a child of web-runtime because its node half is a
# client-side package, which a host-side bundle cannot import.
- id: client-hmr
name: '@deepseek-ai/dsh-client-hmr'
inject: [webStartup]
disabled: true
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
# Dual-face: this waits for the runtime row to decide whether HMR belongs
# in the first graph. The node half then scans this tree, composes
# window.__DSH_BOOT__, and serves /plugins/<id>/client.js; the browser half
# is the module table the shell kernel constructs before cordis exists
# (adopted as a plugin entry by the kernel, never fetched).
# Dual-face: the node half scans this tree, composes window.__DSH_BOOT__,
# and serves /plugins/<id>/client.js; the browser half is the module table
# the shell kernel constructs before cordis exists (adopted as a plugin
# entry by the kernel, never fetched).
- id: modules
name: '@deepseek-ai/dsh-client-modules'
inject: [webRuntime]
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client.
@@ -184,6 +179,11 @@
- id: ui-tool
name: '@deepseek-ai/dsh-client-ui-tool'
# Durable workflow lifecycle as an independent Chat node after the
# existing generic workflow tool row.
- id: ui-workflow-run
name: '@deepseek-ai/dsh-client-ui-workflow-run'
# Turn tail: the produced-files row under each closing assistant message.
# Remove this entry to turn the surface off; the tail hole renders empty.
- id: ui-deliverables
@@ -232,6 +232,11 @@
- id: ui-agent-preset
name: '@deepseek-ai/dsh-client-ui-agent-preset'
# Plugin configuration: the host-plane sections a user owns, as expandable
# cards. A namespace this deployment does not expose renders nothing.
- id: ui-plugin-config
name: '@deepseek-ai/dsh-client-ui-plugin-config'
# Plan control: the composer plan seat over the plan projection + /plan channel.
- id: ui-plan
name: '@deepseek-ai/dsh-client-ui-plan'

View File

@@ -45,12 +45,12 @@
},
"dependencies": {
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
@@ -63,6 +63,7 @@
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
"@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
@@ -73,6 +74,7 @@
"@deepseek-ai/dsh-client-ui-task": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
"@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",

View File

@@ -5,7 +5,7 @@
* the built frontend dist (workspace knowledge of this bundle, never user
* config), mounts the `frontend-static` fallback owner over it, registers the
* harness-source and web-surface prompt sections, the bash-visible web runtime
* variables, and the URL line. App command-line values arrive through the
* variable, and the URL line. App command-line values arrive through the
* `webStartup` service expressions in the bundle patch.
* @module @deepseek-ai/dsh-web-app
*/
@@ -16,7 +16,6 @@ import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import { enableRow } from '@deepseek-ai/dsh-cmdline'
import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -28,7 +27,6 @@ export const name = 'web-app'
/** This dsh installation's root, from either this package's source or built entry. */
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
const HMR_ROW_ID = 'client-hmr'
/** Runtime service that releases Web rows after bind-dependent values resolve. */
const WEB_RUNTIME_SERVICE = 'webRuntime'
@@ -36,19 +34,14 @@ const WEB_RUNTIME_SERVICE = 'webRuntime'
/** Services required before the web runtime can mount. */
export const inject = ['httpServer']
/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
export type WebMode = 'production' | 'development'
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
export interface Config {
/** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
mode: WebMode
/** Print the URL line on activation; a non-interactive layer can turn it off. */
printUrl: boolean
/**
* Register the model-visible surface context (the `app:web-surface` prompt
* section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
* non-interactive layer can turn it off when its user is not in the GUI, so the
* section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
* layer can turn it off when its user is not in the GUI, so the
* orientation text would be false.
*/
surfaceContext: boolean
@@ -57,7 +50,6 @@ export interface Config {
}
export const Config: z<Config> = z.object({
mode: z.union([z.const('production'), z.const('development')]).default('production'),
printUrl: z.boolean().default(true),
surfaceContext: z.boolean().default(true),
trustedHosts: z.array(String).default([]),
@@ -73,8 +65,6 @@ export interface WebRuntimeValues {
/** Environment variable naming the canonical local URL of this Web GUI. */
const DSH_WEB_URL = 'DSH_WEB_URL' as const
/** Environment variable naming the Web runtime mode. */
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
@@ -102,13 +92,10 @@ export function resolveLanTrust(bindHost: string, extra: readonly string[]): Web
}
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
const updateContract = mode === 'development'
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
function webSurfacePrompt(webUrl: string): string {
const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while '
+ '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. '
+ 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. '
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
+ 'The browser provides no implicit DOM, route, or screenshot context. '
@@ -140,20 +127,14 @@ function resolveDistIndex(): string {
export const internals: { resolveDistIndex: () => string } = { resolveDistIndex }
/**
* Mount the Web runtime: dist serving, surface prompt, bash runtime
* variables, and the URL line.
* Mount the Web runtime: dist serving, surface prompt, the bash runtime
* variable, and the URL line.
* @param ctx - plugin context carrying the httpServer service.
* @param config - validated {@link Config}.
* @returns nothing once the invocation's client roster and runtime contributions are registered.
*/
export async function apply(ctx: Context, config: Config): Promise<void> {
// Client discovery must start after the optional HMR row has a pending
// fiber. Otherwise its first browser graph omits the reload receiver, which
// cannot use that receiver to discover itself later.
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
export function apply(ctx: Context, config: Config): void {
const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts)
// Release dependent rows only after the optional row has a pending fiber and
// bind-dependent trust has been sampled once.
// Release dependent rows only after bind-dependent trust has been sampled once.
ctx.provide(WEB_RUNTIME_SERVICE, runtime)
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
if (config.surfaceContext) {
@@ -162,7 +143,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
text: () => webSurfacePrompt(localWebUrl(promptCtx)),
})
})
ctx.inject(['bashEnv'], (runtimeCtx) => {
@@ -170,9 +151,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
name: 'web-runtime',
variables: {
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
},
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }),
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx) }),
})
})
}

View File

@@ -1,6 +1,6 @@
/**
* The web app's command-line provider: it parses the `dsh --profile web` flag
* family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help`
* family (`--host`, `--port`, `--trusted-host`) and its `--help`
* text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
* Ordinary rows inject that service before reading it from lazy config.
* @module @deepseek-ai/dsh-web-app/startup
@@ -25,8 +25,6 @@ export interface WebStartupValues {
host?: string
/** `--port`, absent when the invocation did not name one. */
port?: number
/** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */
mode: 'production' | 'development'
/** Explicit `--trusted-host` authorities, in argument order. */
trustedHosts: string[]
}
@@ -35,7 +33,6 @@ export interface WebStartupValues {
interface WebOptions {
host?: string
port?: string
dev?: boolean
trustedHost?: string[]
}
@@ -50,14 +47,12 @@ function webCommand(): Command {
.helpOption('-h, --help', 'show this help')
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.addHelpText('after', `
Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --port 8080 serve on another port
dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN
dsh --profile web --dev mount the client-plugin HMR receiver
`)
}
@@ -74,7 +69,6 @@ function planWebStartup(program: Command): WebStartupValues {
return {
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
mode: options.dev === true ? 'development' : 'production',
trustedHosts: options.trustedHost ?? [],
}
}

View File

@@ -57,7 +57,6 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
' config:',
" host: !!js ctx.webStartup.host ?? '127.0.0.1'",
' port: !!js ctx.webStartup.port ?? 3080',
' mode: !!js ctx.webStartup.mode',
' trustedHosts: !!js ctx.webStartup.trustedHosts',
'- id: provider',
` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`,
@@ -91,14 +90,12 @@ describe('web command-line provider', () => {
const { values, observed } = await bootProvider([
'--host', '0.0.0.0',
'--port', '8080',
'--dev',
'--trusted-host', 'lab.internal', 'lab-2.internal',
'--trusted-host', '10.0.0.9',
])
expect(values).toEqual({
host: '0.0.0.0',
port: 8080,
mode: 'development',
trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'],
})
expect(observed.readerConfig).toEqual(values)
@@ -107,11 +104,10 @@ describe('web command-line provider', () => {
it('leaves deployment values to each consumer when flags omit them', async () => {
const { values, observed } = await bootProvider([])
expect(values).toEqual({ mode: 'production', trustedHosts: [] })
expect(values).toEqual({ trustedHosts: [] })
expect(observed.readerConfig).toEqual({
host: '127.0.0.1',
port: 3080,
mode: 'production',
trustedHosts: [],
})
})

View File

@@ -58,17 +58,9 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
return { server, seat: () => fallback }
}
/** Install the optional HMR row the runtime sequences before client discovery. */
function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] {
const updates: string[] = []
ctx.provide('loader', {
entries: () => [{
options: { id: 'client-hmr' },
enableRuntime: async () => { updates.push('client-hmr') },
}],
await: settle,
} as never)
return updates
/** A fake Loader whose settlement the test controls (the URL line waits on it). */
function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
ctx.provide('loader', { await: settle } as never)
}
interface BashContribution {
@@ -90,15 +82,14 @@ describe('web-app runtime glue', () => {
return () => {}
},
} as never)
const enabledRows = provideHmrRow(ctx)
provideLoader(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
await ctx.plugin(SystemPrompt, { persona: '' })
// Settle the injected registrations.
await new Promise(resolve => setTimeout(resolve, 0))
expect(seat()).toBeDefined() // frontend-static claimed the fallback
expect(enabledRows).toEqual(['client-hmr'])
expect(ctx.get('webRuntime')).toEqual({
lanAddresses: ['192.168.1.5'],
trustedHosts: ['192.168.1.5', 'lab.internal'],
@@ -108,24 +99,26 @@ describe('web-app runtime glue', () => {
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
expect(section?.text).toContain('http://127.0.0.1:4567')
expect(section?.text).toContain('--dev')
// The single update contract: the receiver is always on; no-refresh
// reloads additionally need the rebuild watcher.
expect(section?.text).toContain('pnpm run dev:web')
const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' })
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
await ctx.fiber.dispose()
})
it('stays quiet in production mode with printUrl off and reports the production update contract', async () => {
it('stays quiet with printUrl off', async () => {
stageDist()
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
.toContain('without `--dev`')
.toContain('rebuilding the affected Web artifacts')
await ctx.fiber.dispose()
})
@@ -140,7 +133,7 @@ describe('web-app runtime glue', () => {
return () => {}
},
} as never)
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
const assembly = await ctx.systemPrompt.assemble()
@@ -155,7 +148,7 @@ describe('web-app runtime glue', () => {
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
await ctx.fiber.dispose()
@@ -169,9 +162,9 @@ describe('web-app runtime glue', () => {
settled.provide('httpServer', fakeHttpServer().server)
let release: () => void
const settlement = new Promise<void>((resolve) => { release = resolve })
provideHmrRow(settled, () => settlement)
provideLoader(settled, () => settlement)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
release!()
@@ -184,8 +177,8 @@ describe('web-app runtime glue', () => {
log.mockClear()
const failed = new Context()
failed.provide('httpServer', fakeHttpServer().server)
provideHmrRow(failed, async () => { throw new Error('boot failed') })
await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
provideLoader(failed, async () => { throw new Error('boot failed') })
apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
await failed.fiber.dispose()
@@ -200,8 +193,8 @@ describe('web-app runtime glue', () => {
await child
let releaseTorn: () => void
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
provideHmrRow(torn, () => tornSettlement)
await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
provideLoader(torn, () => tornSettlement)
apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
await child.dispose() // the httpServer service goes away
releaseTorn!()
await new Promise(resolve => setTimeout(resolve, 0))
@@ -217,7 +210,7 @@ describe('web-app runtime glue', () => {
const { server } = fakeHttpServer()
Object.defineProperty(server, 'port', { get: () => undefined })
ctx.provide('httpServer', server)
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67
README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c
README.md: 75abe408952ed66dcc237ce489e417f61159bcc3
README.zh.md: 5432efcb0a5ebc410093da4c3ec6c2e07c4520ca

View File

@@ -18,11 +18,13 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. |
| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. |
| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. |
| [`ui-attachment/`](ui-attachment/README.md) | Provides attachment display atoms: draft-image rail, message gallery, and lightbox. |
| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. |
| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. |
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |
@@ -33,6 +35,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. |
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |

View File

@@ -18,11 +18,13 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 |
| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 |
| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 |
| [`ui-attachment/`](ui-attachment/README.md) | 提供附件展示原子组件:草稿图片栏、消息画廊与灯箱。 |
| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 |
| [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
| [`ui-workflow-run/`](ui-workflow-run/README.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 |
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent智能体活动的其他视图。 |
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |
@@ -33,6 +35,7 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 |
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 |
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 |
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |

View File

@@ -2835,8 +2835,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
return Promise.resolve({ accepted: true })
},
// Satisfies the ApiProxy contract type only: the browser export button
// fetches GET /api/session.export directly (window.fetch), so this stub is
// never reached through the fixture's dispatch.
// hands GET /api/session.export to the native download manager, so this
// stub is never reached through the fixture's dispatch.
downloads: {
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
},

View File

@@ -152,14 +152,14 @@ describe('connection client apply', () => {
sockets[1]!.receive(JSON.stringify({
type: 'server-request',
rpcId: 'host-browser',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
method: 'host/remote-event',
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
}))
expect(await muxFrame).toMatchObject({
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
})
expect(await hostFrame).toMatchObject({
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } },
})
expect(errors).toHaveBeenCalledTimes(2)
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })

View File

@@ -93,7 +93,7 @@ describe('WebSocket downlinks', () => {
},
async function * (signal) {
try {
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } }
await untilAbort(signal)
} finally {
hostAborted = true
@@ -116,8 +116,8 @@ describe('WebSocket downlinks', () => {
expect(await hostFrame).toEqual({
type: 'server-request',
rpcId: 'host-1',
method: 'host/commands-changed',
payload: { type: 'host/commands-changed' },
method: 'host/remote-event',
payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
})
const muxClosed = once(mux, 'close')

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2
README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef
README.md: c355595dd53ddcb74be629a6d5e730c6c5fcebbf
README.zh.md: 6ed4d0e79cb755f84784823749994b448ff209b8

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动
为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲
浏览器侧订阅系统 SSEServer-Sent Events通道`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate``prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR热模块替换无需 builder→host 通道。

View File

@@ -4,7 +4,9 @@
* mounts deliver no inotify events), reports content changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/).
* Dev-only row: prod compositions never mount this plugin.
* The web bundle mounts this row unconditionally: without a rebuild
* watcher rewriting client bundles, the poll observes no changes and the
* chain stays idle.
*/
import { statSync } from 'node:fs'
import type { ServerResponse } from 'node:http'

View File

@@ -33,7 +33,9 @@
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime"
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
"platform": "web",
"immediately": true
@@ -41,21 +43,25 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"dependencies": {

View File

@@ -7,7 +7,7 @@
import { useState } from 'react'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from './settings-contract.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { createLanguageRowStore } from './settings-store.ts'
import css from './LanguageRow.module.css'

View File

@@ -13,9 +13,11 @@ import type { Context } from '@deepseek-ai/cordis'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
bindSettingsScope, type ClientContext, type SettingsScope,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.settingsScope Context merge and the settings slot types.
// Cross-plugin collaboration goes through the service, never a value import
// (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import {
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
} from '../locale-settings.ts'
@@ -29,7 +31,6 @@ import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
export type { CommonKey } from '../locales/index.ts'
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
@@ -343,7 +344,7 @@ function detectBrowserLocale(): LocaleId | undefined {
}
/** Required services: slot registration plus the settings transport. */
export const inject = ['slots', 'connection']
export const inject = ['slots', 'connection', 'remote', 'settingsScope']
/**
* Client plugin body: provide the locale service with base dictionaries and
@@ -352,7 +353,7 @@ export const inject = ['slots', 'connection']
* @param ctx - client cordis context.
*/
export function apply(ctx: ClientContext): void {
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
const host = ctx.settingsScope.bind<LocaleSettings>({ namespace: LOCALE_SETTINGS_NAMESPACE })
const locale = new LocaleService(ctx, host)
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })

View File

@@ -1,26 +0,0 @@
/**
* The `settings.general.item` slot type — one preference row inside the
* settings General section, contributed by the feature plugin that owns the
* preference (locale → Language, ui-theme → Appearance). Options: `id` (row
* key), `order` (row position). Rows draw their own internals (row layout,
* separators via CSS); the section column only stacks them.
*
* TYPE HOME RATIONALE: the slot is declared at runtime by
* ui-settings-general's General entry, but its type lives here — this
* package is the common dependency of every item registrant (any settings
* row carries copy, so every registrant already depends on locale), whereas
* the declarer's own contract is unreachable for locale/ui-theme without a
* reference cycle.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One preference row inside the settings General section (see module JSDoc). */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */
children?: never
}

View File

@@ -4,6 +4,8 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import {
apply, inject, SETTINGS_NS,
} from '@deepseek-ai/dsh-client-locale/client'
@@ -43,6 +45,9 @@ async function bench() {
}
})
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
// The settings transport and the forwarded-event port the plugin injects.
new TestRemote(ctx)
await ctx.plugin(SettingsScopeService).await()
return {
ctx, slots: ctx.get('slots') as SlotsService, describe, mutate,
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
@@ -79,7 +84,7 @@ describe('locale apply', () => {
})
it('declares the slot service', () => {
expect(inject).toEqual(['slots', 'connection'])
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
})
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
@@ -134,10 +139,10 @@ describe('locale apply', () => {
const locale = b.ctx.get('locale') as LocaleService
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
b.setHostPreference(undefined)
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
b.setHostPreference('en')
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
expect(b.describe).toHaveBeenCalledTimes(3)
})

View File

@@ -6,6 +6,7 @@ import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepsee
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
@@ -20,10 +21,13 @@ describe('invariant companion', () => {
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
// The feature registers its own Language settings row, hence the slots edge.
expect(inject).toEqual(['slots', 'connection'])
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
const ctx = new Context()
new SlotsService(ctx)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
// The settings row's transport and the forwarded-event port.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject, apply: clientApply }).await()
const locale = ctx.get('locale')
expect(locale).toBeInstanceOf(LocaleService)

View File

@@ -25,6 +25,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../ui-settings"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 7c835deb58db149710495f97a2553c3de58d99da
README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09
README.md: f4823f58ec79df0cbccfff0a08d9bb59b9a3ac8d
README.zh.md: ce8117fc4c95071a6db8592302030a8a63b5478b

View File

@@ -2,9 +2,12 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, the composition `base` and raw `user` layers, revision, writability, host/memory mode), serializes `set` and `unset` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. A field is overridden when it is PRESENT in `user` — an override equal to the composition default is still an override, which comparing values could not see — and `unset` is how a form clears one back to `base`. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
## Slot declaration injection
`ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws.
@@ -33,6 +36,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.

View File

@@ -2,9 +2,12 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``session/preset-changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
对于每条可到达本地根 Agent 或可继续子 Agent 的提示词,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次 Session 或 subagent 提示词 RPC。该值既不缓存也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、组装 `base` 层与原始 `user` 层、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set``unset` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。字段是否被覆盖取决于它是否**出现**在 `user` 中——与组装默认值相同的覆盖仍然是覆盖,比较值是看不出来的——而 `unset` 就是表单把某个字段清回 `base` 的方式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照状态、分节值、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
## Slot 声明注入
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose资源释放回调 effect重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
@@ -33,6 +36,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering中途引导不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果claim 竞态则会返回 `queue-item-not-found`

View File

@@ -33,7 +33,8 @@
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-typert-registry"
"@deepseek-ai/dsh-typert-registry",
"@deepseek-ai/dsh-api-gateway"
],
"platform": "web",
"immediately": true
@@ -44,7 +45,6 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
@@ -59,19 +59,20 @@
"zustand": "~4.4.7"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@types/react": "~18.3.1"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,81 @@
/**
* The settings-namespace scope contract. The type lives here, in the common
* dependency of every feature that owns a preference, while the implementation
* and its Host transport live with the Settings surface
* (`dsh-client-ui-settings`): a feature service accepts a scope through
* `attachSettings` without depending on the surface that binds it, which would
* otherwise close a reference cycle.
*/
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/**
* Composition layer the Host resolved {@link value} over, when the owning
* plugin declared one. What a field reverts to once cleared.
*/
base: unknown
/**
* Raw user layer as stored, when one exists. A field's PRESENCE here is what
* marks it overridden — an override whose value equals the composition
* default is still an override, and comparing values could not see it.
*/
user: unknown
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
/**
* Queue one field clear, so the field re-inherits the composition layer.
* Shares {@link set}'s ordering, revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @returns settlement after the clear and any latest-write recovery read.
*/
unset(field: string): Promise<void>
}

View File

@@ -1,6 +1,10 @@
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
// than api-remotes': that face imports a Host-tsdown-generated artifact, and this
// project sits in the Host build graph.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
@@ -42,9 +46,12 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
export { resolveWorkspacePath } from './workspaces/path.ts'
// Contract only: the scope implementation and its Host transport belong to
// dsh-client-ui-settings (see that package's settings-scope.ts).
export type {
SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec,
} from './contract/settings-scope.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { AgentContext, ISessions } from './contract/sessions.ts'
@@ -150,47 +157,6 @@ declare module '@deepseek-ai/cordis' {
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
/**
* The host command registry changed (host/commands-changed passthrough).
* Pure invalidation signal: subscribers refetch `command.list` in the
* background rather than diffing.
* @mode emit
*/
'commands/changed'(): void
/**
* One settings namespace's resolved value changed on the host
* (host/settings-changed passthrough). Subscribers refetch
* `settings.describe`; the frame carries no values.
* @mode emit
* @param ns - the namespace whose resolved value changed.
*/
'settings/changed'(ns: string): void
/**
* One credential reference's state changed on the host
* (host/credentials-changed passthrough). The ref is an
* environment-variable NAME — never a value.
* @mode emit
* @param ref - the reference whose configured state changed.
*/
'credentials/changed'(ref: string): void
/**
* The host provider topology changed (host/models-changed passthrough).
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
* @mode emit
*/
'models/changed'(): void
/**
* One session's agent preset changed (host/session-preset-changed
* passthrough), so everything its composition decides — the command
* catalog, the skill catalog — is stale for that session and no other.
* Every connected client observes it, not only the one that issued the
* switch. Subscribers refetch their own session-keyed caches; the frame
* carries no catalog.
* @mode emit
* @param sessionId - the session whose composition changed.
* @param agentPreset - the preset it now runs.
*/
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
@@ -213,7 +179,7 @@ declare module '@deepseek-ai/cordis' {
}
/** Required services: the wire handle and Client TypeRT registry. */
export const inject = ['connection', 'typert']
export const inject = ['connection', 'typert', 'remote']
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
@@ -241,17 +207,12 @@ export function apply(ctx: Context): void {
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches, the settings
// and model services) subscribe on ctx.
// Forwarded-event bridge: the session layer ignores registry frames (no
// session routing). This plugin owns the frame sink, so it hands the
// decoded frame straight to the Remote service, which fans it out to
// `ctx.remote.$on` subscribers; no consumer reads a frame.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/session-preset-changed') {
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
}
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args)
},
onConnected: () => {
sessions.handleConnected()

View File

@@ -330,8 +330,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
* - `engaging`: a first prompt was attempted, but no accepted turn or other
* authoritative activity signal has arrived — the UI keeps the composer
* visible through admission and error frames.
* - `active`: the session is non-blank beyond its pending first prompt, is
* running, or owns a pending interaction — the ordinary conversation view.
* - `active`: the session is non-blank beyond its pending first prompt,
* contains visible non-command Chat content, is running, or owns a pending
* interaction — the ordinary conversation view.
*
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; returning to the hero would discard the error context).

View File

@@ -800,14 +800,6 @@ export class SessionManager {
}
return
}
case 'host/session-preset-changed': {
// Every connected client observes the switch here; only the tab that
// issued it also gets the RPC echo. The merge keeps the row's own
// updatedAt and lowers `blank` only, so re-applying the switching
// tab's own frame is a no-op.
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
return
}
case 'host/session-removed': {
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)

View File

@@ -23,6 +23,7 @@ import { PendingWait } from './pending.ts'
import { Notifier } from './notifier.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { resolvedClientTimeZone } from '../time-zone.ts'
import { SessionQueueMirror } from './queue-mirror.ts'
/** Messages requested per history page. */
@@ -194,7 +195,12 @@ export class Session implements SessionFace {
let result: RpcResult<{ accepted: true }>
try {
if (this.address === undefined) {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
result = (await this.api.sessions.prompt({
sessionId: this.sessionId,
mode,
content,
clientTimeZone: resolvedClientTimeZone(),
})).result
} else if (this.address.mode === 'one-shot') {
result = {
ok: false,
@@ -220,6 +226,7 @@ export class Session implements SessionFace {
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
clientTimeZone: resolvedClientTimeZone(),
})).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
@@ -741,7 +748,8 @@ export class Session implements SessionFace {
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
(!this.blankBit && !this.firstPromptPendingTurn)
hasVisibleConversationContent(chat)
|| (!this.blankBit && !this.firstPromptPendingTurn)
|| this.running
|| this.pendingCache.value.length > 0,
this.promptAttempted,
@@ -774,13 +782,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput {
return { event: entry.event, view: entry.view }
}
/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */
function hasVisibleConversationContent(chat: ChatSnapshot): boolean {
return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command')
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). A failed first prompt
* stays engaging until an authoritative accepted-turn, running, or pending
* signal arrives (retry semantics — see ComposerPhase).
* @param hasContent - authoritative non-blank activity beyond a pending first
* prompt, a running turn, or a pending interaction.
* prompt, visible non-command Chat content, a running turn, or a pending interaction.
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

Some files were not shown because too many files have changed in this diff Show More