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