Merge remote-tracking branch 'origin/master' into feat/plugin-owned-settings-surface

# Conflicts:
#	packages/client/ui-input-trigger/README.i18n.yaml
#	packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx
#	packages/client/ui-settings-plugins/src/client/index.ts
#	packages/client/ui-settings-plugins/src/client/tab-store.ts
#	packages/client/ui-settings-plugins/tests/apply.client.spec.ts
#	packages/client/ui-settings-plugins/tests/section.client.spec.tsx
#	packages/client/ui-settings-plugins/tests/stores.client.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/tests/api-proxy-config.spec.ts
This commit is contained in:
Yichen Jiang
2026-08-14 15:46:01 +08:00
3766 changed files with 58438 additions and 99720 deletions

View File

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

View File

@@ -0,0 +1,68 @@
# @deepseek-ai/dsh-cordis-client-runner
English | [中文](README.zh.md)
Browser half of dynamic dual-half plugin packages. The host-side runner holds every definition's code in process memory and asks the open pages, over a `cordis/request-run` event, whether to run one; this package answers that request, turns the definition into a live browser plugin, and turns a `dynamicCordisRunner/retract` event back into a clean page.
## What it does
1. **Event subscription** — the four announcements are forwarded host cordis events, so this package consumes `cordis/request-run`, `cordis/request-run-resolved`, and `dynamicCordisRunner/retract` through `ctx.remote.$on`, whose key set IS the api-remotes allowlist.
2. **Closure evaluation** — the browser half's source runs as an async function body whose parameters are its symbol surface (`React`, `console`, `styles`, `host`, plus teaching traps shadowing `setTimeout`/`fetch`/`require`). No JSX, no TypeScript, no module imports.
3. **Guard facade** — `apply` receives a whitelisting proxy over the real fiber ctx: lifecycle verbs plus the services the returned plugin declared in its own `inject` (so the object form `{ inject: ['slots'], apply(ctx) {} }` is what reaches a service; a plain function has no declaration site and reaches none). The `slots` seat assigns the shadowing priority (registering IS shadowing, newest run wins); the `theme` seat pins the override layer's source to the package id and hangs its disposer on the fiber.
4. **Loader entries** — the guarded plugin is seated in the module table and mounted through `loader.create`, so a dynamic package rides the same activation gating, fiber-effect cleanup, and status projection as a static one. Unload is entry removal plus factory invalidation plus style removal.
5. **Run orchestration** — a `cordis/request-run` event asks this page whether to run a definition. Whoever answers drives the run in order: the host half first, then the source fetch, then the browser half, then one resolution carrying what happened. A user pressing "run" is itself the authorization and orchestrates the same way with nothing to answer — and for a host-only definition the run ends at the host half, because there is no second half to fetch or load here.
6. **Package-internal RPC** — a package's `host.call` routes to its own host half through the `dynamicCordisRunner` Remote namespace (`invoke`), and each routing failure code becomes its own teaching error. Both directions carry JSON only: an omitted argument travels as `null` (so `host.call('listServices')` is legal and the handler receives `null`), and a payload the generated codec refuses — a function, `undefined`, a class instance — becomes a teaching error naming the call and the contract instead of the codec's bare field name.
7. **Render-failure reflow** — the slot registry's supervision seam (`slots.onEntryError`) fires for every entry-boundary crash on the page; the ones belonging to a package this runner seated go to two outlets from that one observation: upstream to the authoring session (`reportRenderFailure`, for the model) and onto this package's own `renderFailures` face field (for the panel row). Ownership is keyed on component identity, recorded when the guard's `register` proxy seats it, because the registry stores the component verbatim — so no parallel ledger of entries has to be kept in step. This is post-settle diagnosis only: it carries no settle authority, never touches a run resolution, and a failed report is swallowed rather than turning one crash into two.
## Lifecycle
Loads converge by `(id, rev)` against live state: loading a revision this page already runs answers from live state without reloading (so a replayed run does not look unanswered), a newer revision replaces it, and the same revision after a retract loads afresh. Operations serialize per definition.
Nothing loads at activation, and nothing is restored after a refresh — a page runs a dynamic package only when someone answers a run request or asks for it here.
## What a run surface reads and calls
`ctx.dynamicCordisRunner` is the whole face:
- `activeRuns` — each definition's single in-flight activity: `awaiting-approval` (the request id to answer plus the ask's session, package name, and purpose) or `orchestrating` (the session the run is being carried out for). Both arms name the session because grouping belongs to the run, not to its phase; the waiting arm carries the ask's own text because `cordis_define` broadcasts nothing, so a request can name a definition the last registry read does not cover and then this entry is the only source that row has. A surface renders from it and keeps no copy, which is what makes the affordance survive a remount.
- `renderFailures` — this page's last render crash per definition (slot, teaching message, and whether the crash retired the entry from its cell), on the same notification channel as the live set. Page-local and current by construction: it clears when the package stops, is retracted, or loads again, so a row can render it directly. The host keeps its own last-across-pages copy for the model — the two have different owners and lifetimes, and a surface must not read the host's back in place of this one.
- `lastRunError` — why this page's own attempt failed, per definition. It outlives the activity, because the host disposes only the half a failed request started: a page can be looking at a definition the host reports as running while having nothing loaded itself.
- `approve(requestId)` / `decline(requestId)` / `startUserRun({ agentId, id, hasClientHalf })` — the two entries. All three are idempotent (per request id, and per definition for the user's own run), so a double press cannot start two runs. `hasClientHalf` is required: a host-only definition has no source to fetch, so the caller states the shape from the registry row it is acting on rather than the orchestrator learning it from a failed fetch. An answerable request always has a browser half, because the host runs a host-only definition itself instead of asking a page.
- `subscribe()` / `getSnapshot()` / `isLoaded(id)` — what this page has loaded. `isLoaded` is page-local truth, never the host's "it is running".
## Model Experience
### Run resolution, when a model asked for the run
#### What the model sees
This package contributes no tool, prompt, or context of its own; the first thing it authors that reaches a model is the resolution it sends back for a `cordis/request-run` round trip, which the host turns into the blocked `cordis_run` result. A success carries the loaded revision and, for a browser half parked on services this page does not have, their names. A failure carries one reason — `rejected` when the user refused, `host-half-failed`, or `client-half-failed` — and, for the browser half, this package's own text: the failing stage (`evaluate`, `module-import`, or `activate`) followed by the closure's, guard's, or fiber's message. The guard's teaching errors (an undeclared service, a shadowed browser global, a plugin that returned no `apply`) reach the model through exactly that field. A crash that happens later, while React renders the loaded half, travels the separate post-settle path below.
#### Token effect
Conditional and bounded: at most one resolution per run request, spent inside the `cordis_run` tool result the host already emits. The text is data-dependent (a definition's own error message) and this package retains nothing across requests — a page's later load failures are page-local diagnostics with no model-visible carrier.
#### KV Cache effect
Append-only. A resolution reaches the model only as the tool result for the request that was already in flight, extending the history tail; nothing this package authors rewrites or reorders earlier request tokens, so an otherwise reusable prefix stays reusable. Repeated runs of the same definition each produce their own result rather than replacing an earlier one.
### Render failure, after the run settled
#### What the model sees
A browser half that loads cleanly can still crash when React renders it, and that crash lands after the run was answered — so the model would otherwise be told "ok" and never learn. Every entry-boundary crash of a package this page seated is sent to the host (`reportRenderFailure`) naming the slot, whether the crash retired the entry from its cell (`abdicated`: the package's UI is gone, not merely broken), and a message written for the author: the crash text, plus the redirect for a withheld browser global the text names but does not teach — `window.setInterval` around the closure trap crashes as `is not a function`, which explains nothing on its own. The host keeps the last one per package and shows it through `cordis_inspect`; nothing here reaches a run resolution. The same observation also lands on `renderFailures` for the page's own surface — one observer, two outlets, because "the last crash across pages, for the model" and "what this page is showing now" are different facts with different lifetimes.
#### Token effect
Conditional and bounded by the host's retention, not by this page: one report per crash, and the host keeps only the latest per package, so a repeatedly crashing entry costs the model one paragraph rather than a growing list. The report never enters a tool result of its own — the model pays for it only when it asks.
#### KV Cache effect
None of its own. Reports travel over RPC and are stored, not appended to the conversation; the model reads them through an inspection it chose to make, which extends the tail like any other tool result.
## Known Limitations and Deferred Work
- **A refused resolution is not retried.** The acknowledgement of `resolveRequestRun` is not read, so when the host declines a stale success (`accepted: false`, because the definition's revision moved on while this page was loading) the page keeps what it loaded and does not orchestrate again. The request stays answerable — another page's answer or the caller's cancellation settles it — and the stop that bumped the revision retracts the stale load. Retrying was evaluated and deferred: the window is one revision bump inside a single round trip.
- The plugin declares `remote.dynamic`, so it stays parked until the host-side namespace exists rather than loading packages whose host half it could never reach.
- Slot admission (allow/deny lists per deployment) has no carrier: the dispatched row declares services, not target slots.
- Guard whitelists are hand-mirrored twins of the host-side sandbox facade; sharing one specification is deferred.

View File

@@ -0,0 +1,68 @@
# @deepseek-ai/dsh-cordis-client-runner
[English](README.md) | 中文
动态双半插件包的浏览器半。host 侧 runner 把每个定义的代码留在进程内存里,并经一条 `cordis/request-run` 事件向打开的页面发问「要不要运行它」;本包回答这个请求、把定义变成活的浏览器插件,并把 `dynamicCordisRunner/retract` 事件变回干净的页面。
## 它做什么
1. **事件订阅** —— 四条公告是转发的 host cordis 事件,所以本包经 `ctx.remote.$on` 消费 `cordis/request-run`、`cordis/request-run-resolved` 与 `dynamicCordisRunner/retract`,而 `$on` 的键面就是 api-remotes 的白名单。
2. **闭包求值** —— 浏览器半的源码作为一个 async 函数体运行,其参数即符号面(`React`、`console`、`styles`、`host`,外加遮蔽 `setTimeout`/`fetch`/`require` 的教学陷阱)。无 JSX、无 TypeScript、不能 import 模块。
3. **guard 门面** —— `apply` 收到的是真 fiber ctx 之上的白名单代理:生命周期动词,加上**返回的 plugin 自己在 `inject` 里声明**的服务(所以要用对象形态 `{ inject: ['slots'], apply(ctx) {} }` 才拿得到服务;裸函数没有声明位,拿不到任何服务)。`slots` 座位分配遮蔽 priority(注册即遮蔽,最新一次运行者胜出);`theme` 座位把覆盖层的 source 钉成包 id,并把它的 disposer 挂到 fiber 上。
4. **loader entry** —— 加了 guard 的插件被塞进模块表,再经 `loader.create` 挂载,于是动态包与静态包共享同一套激活门控、fiber effect 清理与状态投影。卸载 = 移除 entry + 失效 factory + 撤下样式。
5. **run 编排** —— 一条 `cordis/request-run` 事件问这一页要不要运行某个定义。回答的那一方按顺序把 run 跑完:先 host 半、再取源码、再浏览器半,最后一次回答带上结果。用户按下「运行」本身就是授权,同样走这条编排,只是没有要回答的对象;而纯 host 定义的 run 到 host 半就结束了 —— 这里没有第二半可取、也没有第二半可装。
6. **包内 RPC** —— 包内的 `host.call` 经 `dynamicCordisRunner` Remote namespace(`invoke`)转给它自己的 host 半,三种路由失败码各自变成对应的教学错误。两个方向都只驮 JSON:省略入参会以 `null` 过线(所以 `host.call('listServices')` 合法,handler 收到 `null`),而生成的 codec 拒收的载荷(函数、`undefined`、类实例)会变成一条点明「哪次调用 + 约定是什么」的教学错误,而不是 codec 那个光秃秃的字段名。
7. **渲染期失败回流** —— 槽位注册表的 supervision 接缝(`slots.onEntryError`)对页面上每一次 entry 边界崩溃都会通知;凡属于本 runner 落座过的包,那**一次**观察会分两个出口:一路上行给撰写它的会话(`reportRenderFailure`,给模型看),一路发布到本包 face 上的 `renderFailures`(给面板那一行看)。归属以 component 身份为键,在 guard 的 `register` 代理落座时记下 —— 注册表原样保存 component,所以不需要再维护一份与之同步的 entry 台账。这条通道纯属事后诊断:不驮任何 settle 权威、绝不触碰 run 的最终回答,而且报告本身失败时只吞不抛 —— 不让一次崩溃变成两次。
## 生命周期
装载按 `(id, rev)` 对 live 态收敛:装载这一页已在运行的那个 revision 会**直接从 live 态回答**而不重装(所以被重播的 run 不会看起来没人回答),更新的 revision 顶替旧的,同一 revision 在 retract 之后再装则重新装载。同一定义的操作串行执行。
激活时什么都不装,刷新之后也不恢复 —— 一页只在有人回答了一次 run 请求、或有人在这一页主动要求时,才运行动态包。
## run 界面读什么、调什么
`ctx.dynamicCordisRunner` 就是全部的面:
- `activeRuns` —— 每个定义唯一的在途活动:`awaiting-approval`(要回答的 requestId,加上这次询问的会话、包名与用途)或 `orchestrating`(这次 run 是为哪个会话在跑)。两条臂都带会话,因为归组属于这次 run 而不属于它的阶段;待确认那条还带着询问自己的文字,因为 `cordis_define` 什么都不播 —— 一个请求可以点名上一次注册表读取没覆盖到的定义,那时这条活动就是那一行唯一的来源。界面从它渲染、自己不留副本 —— 这正是控件能活过 remount 的原因。
- `renderFailures` —— **本页**最后一次渲染崩溃,按定义索引(槽位、教学 message、以及这次崩溃是否已把 entry 从格位上摘掉),与 live 集合共用同一条通知通道。它按构造就是「本页当前」:包 stop、被 retract、或重新装载成功时即清空,所以界面可以直接照着渲染。host 那边另存一份「跨页面最后一次」给模型 —— 两份的归属与寿命本来就不同,界面**不要**改成回读 host 那份。
- `lastRunError` —— 本页自己那次尝试为何失败,按定义索引。它比活动活得更久:host 只拆失败请求自己启动的那半,所以一个页面可能看着 host 报告为「在跑」的定义,而自己什么都没装上。
- `approve(requestId)` / `decline(requestId)` / `startUserRun({ agentId, id, hasClientHalf })` —— 两条入口。三者都幂等(按 requestId,用户自发的 run 按定义 id),所以连点两次不会起两次 run。`hasClientHalf` 是必填:纯 host 定义没有源码可取,所以由调用方从它正在操作的注册表行里把这个事实说出来,而不是让编排器从一次失败的取码里反推。可回答的请求必然带浏览器半 —— 纯 host 定义是 host 自己起的,它不会去问页面。
- `subscribe()` / `getSnapshot()` / `isLoaded(id)` —— 这一页装了什么。`isLoaded` 是页面本地的事实,永远不等于 host 说的「在跑」。
## 模型体验
### 由模型发起那次 run 的最终回答
#### 模型看到什么
本包自己不贡献任何工具、提示词或上下文;它为一次 `cordis/request-run` 往返发回的回答,是它撰写并到达模型的第一样内容 —— host 把它变成那个被阻塞的 `cordis_run` 的结果。成功时带上已装载的 revision,以及(当浏览器半挂在这一页没有的服务上时)那些服务的名字。失败时带一个 reason:用户拒绝的 `rejected`、`host-half-failed`、或 `client-half-failed`;后者还带上本包自己的文本 —— 出错阶段(`evaluate` / `module-import` / `activate`)加上闭包、guard 或 fiber 的消息。guard 的教学错误(未声明的服务、被遮蔽的浏览器全局、返回值里没有 `apply`)正是经这个字段到达模型的。而装载之后、React 渲染时才发生的崩溃,走下面那条独立的事后通道。
#### token 影响
有条件且有界:每次 run 请求最多一个回答,花在 host 本来就会发出的那个 `cordis_run` 结果里。文本随数据而定(某个定义自己的错误消息),本包跨请求不留存任何东西 —— 一页后续的装载失败是页面本地诊断,在模型侧没有任何承载物。
#### KV cache 影响
只追加。回答只作为「本来就在途的那次请求」的工具结果到达模型、延长历史尾部;本包撰写的内容不会重写或重排更早的请求 token,因此原本可复用的前缀仍然可复用。同一定义的多次运行各自产出各自的结果,而不是替换更早那一个。
### run 落定之后的渲染期失败
#### 模型看到什么
一个装载得干干净净的浏览器半,仍可能在 React 渲染时崩溃,而那次崩溃发生在 run 已经被回答之后 —— 否则模型只会被告知「ok」,永远学不到。凡是本页落座过的包,其 entry 边界的每一次崩溃都会发回 host(`reportRenderFailure`):点名槽位、说明这次崩溃是否已把 entry 从格位上摘掉(`abdicated`:包的 UI 是没了、而不只是坏了),以及一条写给作者的 message —— 崩溃文本,外加「文本里点到了某个被摘掉的浏览器全局、但文本自己没教」时补上的那句教学:绕过闭包陷阱的 `window.setInterval` 只会崩成 `is not a function`,它自己什么都解释不了。host 每包只留最后一条,经 `cordis_inspect` 透给模型;这条通道上的任何东西都不会进入 run 的最终回答。同一次观察还会落到 `renderFailures` 上给本页界面用 —— 一个观察者、两个出口,因为「跨页面最后一次崩溃(给模型)」与「这一页此刻正在显示什么」是两件寿命不同的事实。
#### token 影响
有条件,且其上界由 host 的留存策略决定、不由这一页决定:每次崩溃一条报告,而 host 每包只留最新一条 —— 所以一个反复崩溃的 entry 对模型的代价是一段话,而不是一张越来越长的清单。报告本身不会自带任何工具结果:模型只在主动去问的时候才为它付费。
#### KV cache 影响
自身没有。报告经 RPC 送出并被存起来,而不是追加进对话;模型是通过自己发起的一次查看读到它的,那次查看与任何工具结果一样只延长尾部。
## 已知限制与欠账
- **被拒绝的回答不会重试。** `resolveRequestRun` 的 ack 不读,所以当 host 拒绝一个陈旧的成功答复(`accepted: false` —— 这一页装载期间定义的 revision 被顶掉了),这一页会保留已装的东西、也不再重新编排。那次请求仍可作答(别的页面作答或调用方取消都能收尾),而顶掉 revision 的那次 stop 会 retract 掉这一页的陈旧装载。重试评估过、延后:竞态窗口只是一次往返内的一次 revision 递增。
- 插件声明了 `remote.dynamic`,因此在 host 侧 namespace 存在之前一直挂起,而不是装载一些永远够不到自己 host 半的包。
- 槽位准入(按部署的允许/拒绝清单)没有载体:下发行声明的是服务,不是目标槽位。
- guard 白名单是 host 侧沙箱门面的手抄孪生;抽取共享规格留待后续。

View File

@@ -0,0 +1,79 @@
{
"name": "@deepseek-ai/dsh-cordis-client-runner",
"description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries",
"version": "0.1.0-rc.6",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/extensions/cordis-client-runner"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-modules",
"@deepseek-ai/dsh-client-ui-theme"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

View File

@@ -0,0 +1,971 @@
/**
* Generated by scripts/gen-cordis-api.ts — do not edit by hand; run
* `pnpm run gen-cordis-api` to regenerate (freshness-gated by
* `pnpm run verify-cordis-api` in doc-sync).
*
* The machine-readable cordis API catalog `cordis_inspect` serves to the
* model: harness services (summary + structured public method contracts),
* harness events (mode + structured listener contracts), and the inherited `ctx` API. Produced by
* the same AST walk as docs/cordis-catalog, so this data and the rendered
* docs cannot diverge.
*
* @module @deepseek-ai/dsh-cordis-client-runner/client/api-catalog
*/
/* jscpd:ignore-start */
/** One named parameter in a Service method or Event listener. */
export interface ApiParameter {
/** Parameter name from the exact signature. */
name: string
/** Source-owned parameter contract. */
description: string
}
/** One public service member and its source-owned contract. */
export interface ServiceApiMethod {
/** Public method signature with its body stripped. */
signature: string
/** Method purpose and behavior. */
description: string
/** Named parameters in signature order. */
parameters: readonly ApiParameter[]
/** Non-void result contract when documented. */
returns?: string
/** Documented failure conditions. */
throws?: readonly string[]
}
/** One harness `ctx.<key>` service and its public methods. */
export interface ServiceApiEntry {
/** The `ctx.<key>` name, e.g. `tools`. */
key: string
/** First sentence of the service class JSDoc. */
summary: string
/** Complete service description. */
description: string
/** Public methods, bodies stripped, in source order. */
methods: readonly ServiceApiMethod[]
}
/** One harness event: its dispatch mode, exact signature, and listener contract. */
export interface EventApiEntry {
/** The scoped event name, e.g. `agent/status`. */
name: string
/** The dispatch mode from the declaration's `@mode` tag. */
mode: string
/** The exact listener signature, whitespace-normalized. */
signature: string
/** First sentence of the event JSDoc. */
summary: string
/** Complete event description. */
description: string
/** Named listener parameters in signature order. */
parameters: readonly ApiParameter[]
}
/** One inherited (cordis core + loader/hmr/timer) `ctx` member group with its summary. */
export interface InheritedApiEntry {
/** The `ctx` member name(s), e.g. `ctx.on / ctx.once`. */
name: string
/** One-line summary of what the member does. */
summary: string
}
/** One named type declaration referenced by a Service or Event signature. */
export interface TypeApiEntry {
/** The exported type/interface name, e.g. `ShellRunResult`. */
name: string
/** The full declaration text, comments stripped. */
declaration: string
}
/** Every harness `ctx.<key>` service, sorted by key. */
export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'layout',
summary: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply.',
description: 'The outward layout face (`ctx.layout`): the panel transitions other plugins may trigger — and exactly what a test fake must supply. The attachPanels wiring hook stays on the concrete class (root-entry assembly only).',
methods: [
{
signature: 'toggleSidebar(): void',
description: 'Toggle the sidebar panel (closed ⟷ contract default width).',
parameters: [],
},
{
signature: 'openDetails(): void',
description: 'Open the details panel (no-op when already open).',
parameters: [],
},
{
signature: 'closeDetails(): void',
description: 'Close the details panel.',
parameters: [],
},
],
},
{
key: 'locale',
summary: 'Dictionary registry plus locale preference.',
description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s zh fallback -> the shared common namespace (active, then zh) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).',
methods: [
{
signature: 'getLocale(): LocaleSnapshot',
description: 'Read the current immutable locale snapshot.',
parameters: [],
returns: 'the current snapshot (stable reference until the next change).',
},
{
signature: 'getSnapshot(): LocaleSnapshot',
description: 'LocaleFace getSnapshot: the current snapshot (carries `revision`; stable reference between changes, uSES-safe).',
parameters: [],
returns: 'the current snapshot.',
},
{
signature: 'subscribe(fn: () => void): () => void',
description: 'LocaleFace subscribe: notified on every snapshot change (locale switch or dictionary registration — registrations bump the revision so already rendered outlets pick up late-arriving dictionaries).',
parameters: [{ name: 'fn', description: 'change callback.' }],
returns: 'unsubscribe.',
},
{
signature: 'setLocale(id: string): void',
description: 'Switch the active locale — the only user preference write entry.',
parameters: [{ name: 'id', description: 'a registered locale id; unknown ids throw.' }],
},
{
signature: 'register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void',
description: 'Register a declared namespace\'s dictionaries, all locales in one call — the typed form: each dictionary is checked against the namespace\'s LocaleNamespaceMap key union (a missing or extra key is a compile error), and every shipped locale is required (bilingual balance enforced at registration). Duplicate (ns, locale) throws (single occupant; a namespace\'s texts have one owner). Registration bumps the revision so mounted outlets pick up late-arriving dictionaries.',
parameters: [{ name: 'ns', description: 'a namespace merged into LocaleNamespaceMap.' }, { name: 'dicts', description: 'complete dictionaries keyed by locale id.' }],
returns: 'disposer removing every locale registered by this call (idempotent).',
},
{
signature: 'register(ns: string, locale: string, dict: LocaleDict): () => void',
description: 'Single-locale untyped form for namespaces outside the merge table (dynamic composition, tests).',
parameters: [{ name: 'ns', description: 'namespace.' }, { name: 'locale', description: 'locale tag.' }, { name: 'dict', description: 'dictionary.' }],
returns: 'disposer (idempotent).',
},
{
signature: 'bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>',
description: 'Bind a declared namespace to a translate function typed to its dictionary key union (plus the shared common vocabulary) — the same key domain the framework-injected `t` seat carries. The returned reference is stable per namespace (repeat binds return the same function), so it can ride inject surfaces without breaking memoization.',
parameters: [{ name: 'ns', description: 'a namespace merged into LocaleNamespaceMap.' }],
returns: 'the typed translate function (reads the active locale at call time).',
},
{
signature: 'bind(ns: string): Translate',
description: 'Untyped form for namespaces outside the merge table (dynamic composition, tests).',
parameters: [{ name: 'ns', description: 'namespace.' }],
returns: 'the translate function.',
},
],
},
{
key: 'sessions',
summary: 'The sessions-service face injected as `ctx.sessions`.',
description: 'The sessions-service face injected as `ctx.sessions`.',
methods: [
{
signature: 'open(id: SessionId): void',
description: 'Select a session as current.',
parameters: [{ name: 'id', description: 'session id (must exist in the list; unknown ids fail loud).' }],
},
{
signature: 'openSubagent(address: SubagentAddress): void',
description: 'Open a healthy catalog child through its exact direct-parent address.',
parameters: [{ name: 'address', description: 'catalog-derived parent and child ids.' }],
},
{
signature: 'setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void',
description: 'Mark whether a catalog menu is consuming live membership updates.',
parameters: [{ name: 'parentSessionId', description: 'catalog owner.' }, { name: 'open', description: 'current menu state.' }],
},
{
signature: 'refreshSubagents(parentSessionId: SessionId): Promise<void>',
description: 'Refresh one direct-child catalog.',
parameters: [{ name: 'parentSessionId', description: 'catalog owner.' }],
returns: 'completion of the current or newly started refresh.',
},
{
signature: 'search( query: string, signal: AbortSignal, ): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>',
description: 'Search the Host\'s visible message-content index. Results stay request-local; the list snapshot remains the metadata authority.',
parameters: [{ name: 'query', description: 'non-blank literal phrase.' }, { name: 'signal', description: 'cancellation for a superseded search.' }],
returns: 'bounded results, or a business/transport error.',
},
{
signature: 'fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>',
description: 'Fork a session from a completed-turn prefix of the source; on resolution the child is in the list store and `open()` can target it.',
parameters: [{ name: 'opts', description: 'source session id, the optional event seq anchoring the cut (the boundary is the first turn/end at or after it; an in-log anchor in an open turn is unavailable rather than clipped backward), and whether to increment an inherited durable title before resolving.' }],
returns: 'the child session id.',
throws: ['when the fork fails, or when a requested child-title rename fails after creation.'],
},
{
signature: 'scope(id: SessionId): AgentContext | undefined',
description: 'Resolve an Agent-scoped context view (use-and-discard).',
parameters: [{ name: 'id', description: 'session id.' }],
returns: 'scoped ctx, or undefined for a session neither listed nor already scoped.',
},
{
signature: 'binding(id: SessionId): SessionBinding | undefined',
description: 'Resolve the stable session binding (scope-addressed assembly feed).',
parameters: [{ name: 'id', description: 'session id.' }],
returns: 'binding, or undefined for a session neither listed nor already scoped.',
},
],
},
{
key: 'slots',
summary: 'cordis Service layer of the slot system; see the module doc for the split with SlotCore.',
description: 'cordis Service layer of the slot system; see the module doc for the split with SlotCore.',
methods: [
{
signature: 'declare readonly register: SlotCore[\'register\']',
description: 'The single registration API. The typed face IS the core\'s register (both overloads reused verbatim — one authority, no structural copy; see SlotCore.register for children declaration, store seat, inject face, load-time validation, and the unload cascade). This layer adds: disposal through the caller\'s ctx.effect (fiber unload = cascade), exclusive-factory minting (`store: createXxxStore` becomes a per-entry handle), the registrant diagnostics stamp, and store-instance lifecycle on the entry axis.\n\nDeclared here, implemented by prototype assignment below the class: it MUST stay a prototype method (never an instance arrow) — the cordis service proxy binds `this.ctx` to the CALLER\'s context at call time, which is what routes the effect (and the unload cascade) into the caller\'s fiber. An arrow property would freeze `this` to the service\'s own root ctx and silently break per-plugin disposal.',
parameters: [],
},
{
signature: 'inject(key: keyof SlotMap & string, callback: () => SlotInjectionEffect): () => void',
description: 'Install an effect for each declaration lifetime of a slot. The callback runs synchronously when the declaration already exists; otherwise it runs inside the declaring `register()` call after the declaration is committed. Collapse disposes the effect and a later declaration runs it again. Callback effects are synchronous disposers; iterable effects install transactionally and dispose in reverse order. The controller belongs to the caller\'s fiber, so plugin unload cancels a pending wait and removes any active contribution.',
parameters: [{ name: 'key', description: 'declared SlotMap key to depend on.' }, { name: 'callback', description: 'creates one disposer or an iterable of disposers.' }],
returns: 'idempotent disposer for the wait and active effect.',
throws: ['callback setup failures synchronously when the slot is already declared.'],
},
],
},
{
key: 'theme',
summary: 'Theme registry and preference owner.',
description: 'Theme registry and preference owner. `light`/`dark` are built in (the base stylesheets carry both palettes); third-party themes register alias-layer overrides. Reads go through getTheme; preference writes only through setTheme; continuous sync only through the `theme/change` event. overrideTokens stacks partial token layers over the active theme without touching the registry. The service holds the `prefers-color-scheme` media query (environment sensing, not presentation) and re-emits when the OS scheme flips while the preference is `system`.',
methods: [
{
signature: 'getTheme(): ThemeSnapshot',
description: 'Read the current immutable theme snapshot.',
parameters: [],
returns: 'the current snapshot (stable reference until the next change).',
},
{
signature: 'setTheme(id: string): void',
description: 'Switch the theme preference — the only user preference write entry. Built-in preferences are written through the settings scope and every accepted value emits `theme/change`.',
parameters: [{ name: 'id', description: 'a registered theme id or `system`; unknown ids throw.' }],
},
{
signature: 'register(definition: ThemeDefinition): () => void',
description: 'Register a theme. Duplicate id throws (single occupant per id; the built-in pair counts; `system` is a preference, not a registrable id).',
parameters: [{ name: 'definition', description: 'theme id, colorScheme, and alias-token overrides.' }],
returns: 'disposer. Disposing the theme backing the active preference resets the preference to the default so the UI never keeps tokens of an unregistered theme.',
},
{
signature: 'overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void',
description: 'Stack a token override layer on top of the active theme — the token-level analogue of slot shading: the base theme stays untouched, layers compose in seq order with later layers winning per-token, and removing a layer restores whatever it covered. Calling again with the same source replaces that source\'s whole layer and restacks it on top (effect re-registration semantics). Emits `theme/change` with the recomposed snapshot.',
parameters: [{ name: 'source', description: 'layer identity; one layer per source (dynamic packages pass their package id — the façade pins it, so it also names the layer\'s origin for inspection).' }, { name: 'tokens', description: 'token-name → `{ light, dark }` value pairs. Validated at runtime (model-authored callers reach this boundary with untyped JS); a bare string value throws a teaching error.' }],
returns: 'disposer removing exactly the layer this call created; a no-op once the source has re-overridden (the newer layer is not torn down).',
},
],
},
{
key: 'timer',
summary: 'Disposable timer helpers mixed into Cordis contexts.',
description: 'Disposable timer helpers mixed into Cordis contexts.',
methods: [
{
signature: 'timeout(callback: () => void, delay: number): () => void',
description: 'Run a callback once and return its disposer.',
parameters: [],
},
{
signature: 'timeout(delay: number): Promise<void>',
description: 'Resolve after a delay; disposal rejects the pending promise.',
parameters: [],
},
{
signature: 'interval(callback: () => void, delay: number): () => void',
description: 'Run a callback repeatedly and return its disposer.',
parameters: [],
},
{
signature: 'interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>',
description: 'Return an async iterator of timer ticks.',
parameters: [],
},
{
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
description: 'Return a throttled function whose timer is disposed with the current fiber.',
parameters: [],
},
{
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
description: 'Return a debounced function whose timer is disposed with the current fiber.',
parameters: [],
},
],
},
{
key: 'workspaces',
summary: 'The workspaces-service face injected as `ctx.workspaces`.',
description: 'The workspaces-service face injected as `ctx.workspaces`.',
methods: [
{
signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>',
description: 'Connect a Workspace to its reusable or freshly created blank session.',
parameters: [{ name: 'workspaceId', description: 'target workspace.' }],
returns: 'the connected session id.',
},
{
signature: 'startSession(workspaceId?: WorkspaceId): void',
description: 'The New Session flow: connect the explicit, current-Session, or recent Workspace and open the resulting session; failures surface on the session list state.',
parameters: [{ name: 'workspaceId', description: 'explicit target; omitted inherits the current Session\'s Workspace before falling back to the recency projection.' }],
},
{
signature: 'create(input: { path: string }): Promise<WorkspaceView>',
description: 'Register an existing path as a Workspace.',
parameters: [{ name: 'input', description: 'the Host create payload.' }],
returns: 'the created or idempotently resolved Workspace.',
},
{
signature: 'pickDirectory(): Promise<string | null>',
description: 'Open the Host\'s native directory picker.',
parameters: [],
returns: 'the selected path, or null when the user cancelled.',
},
{
signature: 'listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>',
description: 'List one directory level through the Host\'s `browse` capability.',
parameters: [{ name: 'path', description: 'absolute directory to list; absent lists the Host home directory.' }, { name: 'signal', description: 'aborts the wire request (and the Host\'s scan) when the caller supersedes it.' }],
returns: 'the level\'s listing with breadcrumb ancestry.',
},
{
signature: 'createDirectory(path: string, name: string): Promise<string>',
description: 'Create one child directory through the Host\'s `browse` capability.',
parameters: [{ name: 'path', description: 'absolute existing parent directory.' }, { name: 'name', description: 'single non-blank path segment.' }],
returns: 'the created directory\'s absolute path.',
},
{
signature: 'openPath(path: string): Promise<void>',
description: 'Open a filesystem path with the Host operating system\'s default application.',
parameters: [{ name: 'path', description: 'absolute or host-resolvable path.' }],
},
{
signature: 'rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>',
description: 'Rename a Workspace.',
parameters: [{ name: 'workspaceId', description: 'target workspace.' }, { name: 'title', description: 'the new display title.' }],
returns: 'the updated Workspace view.',
},
{
signature: 'delete(workspaceId: WorkspaceId): Promise<void>',
description: 'Delete a Workspace (its sessions fall back to the unaccounted group).',
parameters: [{ name: 'workspaceId', description: 'target workspace.' }],
},
{
signature: 'insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>',
description: 'Move an accounted session within/into a Workspace\'s ordered list.',
parameters: [{ name: 'workspaceId', description: 'target workspace.' }, { name: 'sessionId', description: 'accounted session to move.' }, { name: 'beforeSessionId', description: 'accounted anchor to insert before; omitted appends.' }],
returns: 'the updated Workspace view.',
},
{
signature: 'archiveSession(sessionId: SessionId): Promise<void>',
description: 'Archive a session into the registry-global set (hidden from grouping surfaces; session log and accounting slot remain). Archiving the current session clears the selection into the New Session view state.',
parameters: [{ name: 'sessionId', description: 'session to archive.' }],
},
],
},
]
/** Every harness event, sorted by name. */
export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'connection/reset',
mode: 'emit',
signature: '\'connection/reset\'(): void',
summary: 'A connection generation was (re-)established.',
description: 'A connection generation was (re-)established. Wire-derived caches must treat their state as stale and repull (commands directory; the queue mirrors reset themselves through the session resync path).',
parameters: [],
},
{
name: 'locale/change',
mode: 'emit',
signature: '\'locale/change\'(snapshot: LocaleSnapshot): void',
summary: 'The active locale switched.',
description: 'The active locale switched. Dictionary registrations do NOT emit this event (listeners may re-register slots in response, and boot registers one namespace per package); continuous render refresh rides the LocaleFace revision instead.',
parameters: [{ name: 'snapshot', description: 'Current immutable locale snapshot.' }],
},
{
name: 'slots/changed',
mode: 'emit',
signature: '\'slots/changed\'(key: string): void',
summary: 'A slot\'s definition or registration set changed.',
description: 'A slot\'s definition or registration set changed.',
parameters: [{ name: 'key', description: 'the mutated SlotMap key.' }],
},
{
name: 'theme/change',
mode: 'emit',
signature: '\'theme/change\'(snapshot: ThemeSnapshot): void',
summary: 'Theme state changed (preference switched, registry updated, or the OS color scheme changed while the preference is `system`).',
description: 'Theme state changed (preference switched, registry updated, or the OS color scheme changed while the preference is `system`).',
parameters: [{ name: 'snapshot', description: 'Current immutable theme snapshot.' }],
},
]
/** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'ActionsDecl',
declaration: 'export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>;',
},
{
name: 'AgentContext',
declaration: 'export type AgentContext = Omit<Context, \'remote\'> & {\n readonly remote: TypertClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};',
},
{
name: 'AssistantBlock',
declaration: 'export type AssistantBlock = {\n kind: \'text\';\n text: string;\n} | {\n kind: \'reasoning\';\n text: string;\n} | {\n kind: \'image\';\n attachment: ImageAttachmentRef;\n} | {\n kind: \'tool-call\';\n callId: string;\n name: string;\n argsRaw: string;\n} | {\n kind: \'other\';\n block: unknown;\n};',
},
{
name: 'AssistantMessageNode',
declaration: 'export interface AssistantMessageNode {\n kind: \'assistant\';\n seq: number;\n messageId?: MessageId;\n time: number;\n turn: number;\n step: number;\n blocks: readonly AssistantBlock[];\n usage?: unknown;\n provenance?: AssistantProvenanceView;\n requestConfig?: AssistantRequestConfig;\n timing?: AssistantTiming;\n interrupted?: true;\n}',
},
{
name: 'AssistantProvenanceView',
declaration: 'export interface AssistantProvenanceView {\n provider: string;\n model: string;\n}',
},
{
name: 'AssistantRequestConfig',
declaration: 'export interface AssistantRequestConfig {\n provider: string;\n model: string;\n purpose?: string;\n thinking?: string;\n reasoningEffort?: string;\n temperature?: number;\n maxTokens?: number;\n stop?: readonly string[];\n}',
},
{
name: 'AssistantTiming',
declaration: 'export interface AssistantTiming {\n stepStartTime: number | null;\n firstTokenTime: number | null;\n completedTime: number;\n}',
},
{
name: 'BakedActions',
declaration: 'export type BakedActions<T, A extends ActionsDecl<T>> = {\n [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};',
},
{
name: 'BoundActions',
declaration: 'export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never;',
},
{
name: 'ChainKeysOf',
declaration: 'export type ChainKeysOf<S extends keyof SlotMap & string> = S extends unknown ? (SlotMap[S][\'kind\'] extends \'chain\' ? S : never) : never;',
},
{
name: 'ChainRenderOpts',
declaration: 'export interface ChainRenderOpts {\n fallback?: ReactNode;\n overlay?: boolean;\n}',
},
{
name: 'ChatConversationViewNode',
declaration: 'export interface ChatConversationViewNode extends ConversationViewNode {\n readonly target: \'chat\';\n readonly anchorSeq: number;\n readonly location: ConversationLocation;\n readonly visibility: \'visible\' | \'hidden\';\n}',
},
{
name: 'ChatLocationNodeIndex',
declaration: 'export interface ChatLocationNodeIndex {\n getTurn(turn: number): readonly string[];\n getStep(turn: number, step: number): readonly string[];\n}',
},
{
name: 'ChatNodeStore',
declaration: 'export interface ChatNodeStore {\n get(key: string): ChatConversationViewNode | undefined;\n values(): readonly ChatConversationViewNode[];\n}',
},
{
name: 'ChatSnapshot',
declaration: 'export interface ChatSnapshot {\n readonly order: readonly string[];\n readonly nodes: ChatNodeStore;\n readonly locations: ChatLocationNodeIndex;\n readonly timeline: ConversationTimelineSnapshot;\n readonly legacy: LegacyConversationSlice;\n}',
},
{
name: 'ChildrenDecl',
declaration: 'export type ChildrenDecl = {\n [P in keyof SlotMap & string]?: SlotSpec<SlotMap[P]>;\n};',
},
{
name: 'CommandNode',
declaration: 'export interface CommandNode {\n kind: \'command\';\n seq: number;\n time: number;\n commandId: CommandId;\n name: string | null;\n args: string | null;\n outcome: {\n kind: \'success\' | \'error\';\n text?: string;\n sourceEventSeq?: number;\n } | null;\n}',
},
{
name: 'CommonKeyOf',
declaration: 'export type CommonKeyOf = LocaleNamespaceMap extends {\n common: infer C;\n} ? C & string : never;',
},
{
name: 'CompactionSummaryNode',
declaration: 'export interface CompactionSummaryNode {\n kind: \'compaction\';\n seq: number;\n time: number;\n summary: string | null;\n summaryEventSeq: number | null;\n shadowedItemCount: number | null;\n shadowedTokenCount: number | null;\n}',
},
{
name: 'ComposedProps',
declaration: 'export type ComposedProps<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K>, S extends keyof SlotMap & string, H, I extends object, M = never, N = undefined> = PropsRuntime<K, EntryKey> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>;',
},
{
name: 'ComposerPhase',
declaration: 'export type ComposerPhase = \'blank\' | \'engaging\' | \'active\';',
},
{
name: 'ContextMessageNode',
declaration: 'export interface ContextMessageNode {\n kind: \'context\';\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n provenance: ContextProvenanceView;\n form: KnownContextForm | null;\n}',
},
{
name: 'ContextProvenanceView',
declaration: 'export interface ContextProvenanceView {\n role: ContextRole;\n label: string | null;\n}',
},
{
name: 'ContextRole',
declaration: 'export type ContextRole = \'inject\' | \'recall\';',
},
{
name: 'ConversationLocation',
declaration: 'export type ConversationLocation = {\n readonly kind: \'session\';\n} | {\n readonly kind: \'turn\';\n readonly turn: TurnLocation;\n} | {\n readonly kind: \'step\';\n readonly turn: TurnLocation;\n readonly step: StepLocation;\n} | {\n readonly kind: \'unresolved\';\n};',
},
{
name: 'ConversationLocationDataStore',
declaration: 'export interface ConversationLocationDataStore<DataMap extends object> {\n get<Key extends keyof DataMap & string>(key: Key): Readonly<DataMap[Key]> | undefined;\n}',
},
{
name: 'ConversationNode',
declaration: 'export type ConversationNode = UserMessageNode | AssistantMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | TurnErrorNode | TurnMaxTokensNode | ToolResultNode | CommandNode | CompactionSummaryNode | UnknownSurfaceNode;',
},
{
name: 'ConversationSnapshot',
declaration: 'export interface ConversationSnapshot {\n sessionId: SessionId;\n views: ConversationViewSnapshotStore;\n chat: ChatSnapshot;\n nodes: readonly ConversationNode[];\n turnTimings: ReadonlyMap<number, {\n readonly startTime: number;\n readonly endTime?: number;\n }>;\n turnEnds: ReadonlyMap<number, number>;\n partial: PartialAssistant | null;\n runningCalls: readonly RunningToolCall[];\n pending: readonly PendingInteraction[];\n queue: readonly QueuedMessage[];\n running: boolean;\n subagent: {\n address: SubagentAddress;\n parentAvailable: boolean;\n } | null;\n composerPhase: ComposerPhase;\n removed: boolean;\n openState: OpenState;\n openError: RpcError | null;\n hasMore: boolean;\n loadingOlder: boolean;\n promptError: PromptError | null;\n blank: boolean;\n lastAgentError: string | null;\n}',
},
{
name: 'ConversationStepDataMap',
declaration: 'export interface ConversationStepDataMap {\n}',
},
{
name: 'ConversationTimelineSnapshot',
declaration: 'export interface ConversationTimelineSnapshot {\n readonly turnOrder: readonly number[];\n readonly turns: ReadonlyMap<number, TurnLocation>;\n}',
},
{
name: 'ConversationTurnDataMap',
declaration: 'export interface ConversationTurnDataMap {\n}',
},
{
name: 'ConversationViewNode',
declaration: 'export interface ConversationViewNode {\n readonly key: string;\n readonly kind: string;\n readonly id: string;\n readonly target: string;\n readonly data: unknown;\n}',
},
{
name: 'ConversationViewSnapshotMap',
declaration: 'export interface ConversationViewSnapshotMap {\n}',
},
{
name: 'ConversationViewSnapshotStore',
declaration: 'export interface ConversationViewSnapshotStore {\n get<Target extends Extract<keyof ConversationViewSnapshotMap, string>>(target: Target): ConversationViewSnapshotMap[Target] | undefined;\n}',
},
{
name: 'EntryKeyOf',
declaration: 'export type EntryKeyOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? keyof P & string : string;',
},
{
name: 'GlobalStandardProps',
declaration: 'export interface GlobalStandardProps {\n}',
},
{
name: 'HandleOf',
declaration: 'export type HandleOf<H> = H extends () => infer R ? R : H;',
},
{
name: 'HooksSources',
declaration: 'export type HooksSources = Record<string, HostObservable<unknown>>;',
},
{
name: 'HostObservable',
declaration: 'export interface HostObservable<T> {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}',
},
{
name: 'InjectFace',
declaration: 'export type InjectFace<I extends object> = I extends {\n hooks: infer HS extends HooksSources;\n} ? Omit<I, \'hooks\'> & PropsHooks<HS> : I;',
},
{
name: 'InjectParams',
declaration: 'export type InjectParams<K extends keyof SlotMap & string, H> = ScopeOf<K> extends \'session\' ? ([\n H\n] extends [\n StoreDecl\n] ? [\n sessionId: SessionIdOf,\n actions: BoundActions<HandleOf<H>>\n] : [\n sessionId: SessionIdOf\n]) : ScopeOf<K> extends \'session-maybe\' ? ([\n H\n] extends [\n StoreDecl\n] ? [\n sessionId: SessionIdOf | undefined,\n actions: BoundActions<HandleOf<H>> | undefined\n] : [\n sessionId: SessionIdOf | undefined\n]) : ([\n H\n] extends [\n StoreDecl\n] ? [\n actions: BoundActions<HandleOf<H>>\n] : [\n]);',
},
{
name: 'ISession',
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\'): Promise<RpcResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<RpcResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{\n accepted: true;\n }>>;\n cancel(): Promise<RpcResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<RpcResult<{\n title: string;\n seq: number;\n }>>;\n loadOlder(): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
},
{
name: 'KeyPropsOf',
declaration: 'export type KeyPropsOf<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K>> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? EntryKey extends keyof P ? P[EntryKey] extends object ? P[EntryKey] : never : never : object;',
},
{
name: 'KnownContextForm',
declaration: 'export type KnownContextForm = typeof KNOWN_FORMS[number];',
},
{
name: 'LegacyConversationSlice',
declaration: 'export interface LegacyConversationSlice {\n readonly nodes: readonly ConversationNode[];\n readonly turnTimings: ReadonlyMap<number, {\n readonly startTime: number;\n readonly endTime?: number;\n }>;\n readonly turnEnds: ReadonlyMap<number, number>;\n readonly partial: PartialAssistant | null;\n readonly runningCalls: readonly RunningToolCall[];\n}',
},
{
name: 'LocaleDefinition',
declaration: 'export interface LocaleDefinition {\n id: LocaleId;\n label: string;\n}',
},
{
name: 'LocaleDict',
declaration: 'export type LocaleDict = Record<string, string>;',
},
{
name: 'LocaleDictOf',
declaration: 'export type LocaleDictOf<N extends keyof LocaleNamespaceMap & string> = Record<LocaleNamespaceMap[N] & string, string>;',
},
{
name: 'LocaleId',
declaration: 'export type LocaleId = typeof LOCALE_IDS[number];',
},
{
name: 'LocaleKeysOf',
declaration: 'export type LocaleKeysOf<N extends keyof LocaleNamespaceMap & string> = (LocaleNamespaceMap[N] & string) | CommonKeyOf;',
},
{
name: 'LocaleNamespaceMap',
declaration: 'export interface LocaleNamespaceMap {\n}',
},
{
name: 'LocaleSnapshot',
declaration: 'export interface LocaleSnapshot {\n active: LocaleId;\n locales: readonly LocaleDefinition[];\n revision: number;\n}',
},
{
name: 'MatchedShare',
declaration: 'export type MatchedShare<E extends SlotEntryDef, M> = E[\'kind\'] extends \'chain\' ? {\n matched: M;\n} : object;',
},
{
name: 'ModelRetryNode',
declaration: 'export type ModelRetryNode = LlmRetryEventData & {\n kind: \'model-retry\';\n seq: number;\n time: number;\n retryState: \'scheduled\' | \'started\' | \'cancelled\';\n};',
},
{
name: 'ObservableSnapshot',
declaration: 'export interface ObservableSnapshot<T> {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}',
},
{
name: 'OpenState',
declaration: 'export type OpenState = \'cold\' | \'loading\' | \'open\' | \'error\';',
},
{
name: 'OwnerOf',
declaration: 'export type OwnerOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n owner: infer O extends object;\n} ? O : object;',
},
{
name: 'PartialAssistant',
declaration: 'export interface PartialAssistant {\n turn: number;\n step: number;\n blocks: readonly AssistantBlock[];\n}',
},
{
name: 'PendingInteraction',
declaration: 'export type PendingInteraction = {\n [K in PendingKind]: PendingWait<K>;\n}[PendingKind];',
},
{
name: 'PendingKind',
declaration: 'export type PendingKind = keyof PendingPayloads;',
},
{
name: 'PendingPayloads',
declaration: 'export interface PendingPayloads {\n approval: Omit<Extract<MuxFrame, {\n type: \'approval/requested\';\n }>, \'type\' | \'sessionId\'>;\n question: Omit<Extract<MuxFrame, {\n type: \'question/requested\';\n }>, \'type\' | \'sessionId\'>;\n}',
},
{
name: 'PendingWait',
declaration: 'export class PendingWait<K extends PendingKind = PendingKind> {\n readonly kind: K;\n readonly key: string;\n readonly sessionId: SessionId;\n readonly payload: PendingPayloads[K];\n constructor(kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K], respond: (message: ClientResponse) => Promise<RpcReceipt>);\n respond(result: ClientResponse[\'result\']): Promise<RpcReceipt>;\n markSettled(): void;\n}',
},
{
name: 'ProjectionsFace',
declaration: 'export interface ProjectionsFace {\n faceOf(key: string): ObservableSnapshot<unknown>;\n}',
},
{
name: 'PromptError',
declaration: 'export interface PromptError {\n op: \'send\' | \'stop\';\n error: RpcError;\n}',
},
{
name: 'PropsHooks',
declaration: 'export type PropsHooks<HS extends HooksSources> = {\n [N in keyof HS & string as `use${Capitalize<N>}`]: SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>;\n};',
},
{
name: 'PropsLocale',
declaration: 'export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string ? {\n t: TranslateNS<N>;\n} : object;',
},
{
name: 'PropsRenderSlots',
declaration: 'export type PropsRenderSlots<S extends keyof SlotMap & string> = {\n renderSlot: RenderSlotFn<Exclude<S, ChainKeysOf<S>>>;\n readonly __renders?: ((key: S) => void) | undefined;\n} & ([\n ChainKeysOf<S>\n] extends [\n never\n] ? object : {\n renderSlotChain: <K extends ChainKeysOf<S>>(key: K, owner: OwnerOf<K>, opts?: ChainRenderOpts) => ReactNode;\n}) & (\'session\' extends ScopeOf<S> ? {\n SessionProvider: SessionProviderComponent;\n} : object);',
},
{
name: 'PropsRuntime',
declaration: 'export type PropsRuntime<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>> = OwnerOf<K> & KeyPropsOf<K, EntryKey> & SlotInjectFace<SlotInjectOf<K>> & (ScopeOf<K> extends \'session\' ? SessionStandardProps : ScopeOf<K> extends \'session-maybe\' ? SessionMaybeStandardProps : object) & GlobalStandardProps;',
},
{
name: 'PropsSlotHooks',
declaration: 'export type PropsSlotHooks<HS extends object> = {\n [N in keyof HS & string as `use${Capitalize<N>}`]: BoundHookOf<HS[N]>;\n};',
},
{
name: 'PropsStore',
declaration: 'export type PropsStore<H> = H extends StoreHandle<infer T, infer A> ? {\n useStore: SnapshotSelectorHook<T>;\n actions: BakedActions<T, A>;\n} : object;',
},
{
name: 'QueueAction',
declaration: 'export type QueueAction = Parameters<SessionFace[\'updateQueue\']>[1];',
},
{
name: 'RunningToolCall',
declaration: 'export interface RunningToolCall {\n callId: string;\n name: string;\n argsRaw: string;\n turn: number;\n step: number;\n time: number;\n callView: ToolCallView | null;\n subCalls: readonly ToolCallBlock[];\n}',
},
{
name: 'ScopeOf',
declaration: 'export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K][\'scope\'];',
},
{
name: 'SessionAreaProps',
declaration: 'export interface SessionAreaProps {\n empty?: (() => ReactNode) | undefined;\n children: (sessionId: SessionIdOf) => ReactNode;\n}',
},
{
name: 'SessionBinding',
declaration: 'export interface SessionBinding {\n readonly sessionId: SessionId;\n readonly session: SessionFace;\n readonly ctx: AgentContext;\n}',
},
{
name: 'SessionFace',
declaration: 'export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>;',
},
{
name: 'SessionIdOf',
declaration: 'export type SessionIdOf = SessionStandardProps extends {\n sessionId: infer S;\n} ? S : string;',
},
{
name: 'SessionMaybeStandardProps',
declaration: 'export interface SessionMaybeStandardProps {\n}',
},
{
name: 'SessionProviderComponent',
declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;',
},
{
name: 'SessionSearchResultItem',
declaration: 'export interface SessionSearchResultItem {\n sessionId: SessionId;\n snippet: string;\n}',
},
{
name: 'SessionStandardProps',
declaration: 'export interface SessionStandardProps {\n}',
},
{
name: 'SlotComponent',
declaration: 'export type SlotComponent<P> = (props: P) => ReactNode;',
},
{
name: 'SlotCore',
declaration: 'export class SlotCore {\n constructor();\n register<K extends keyof SlotMap & string, const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>, const D extends ChildrenDecl = Record<never, never>, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent<never> = SlotComponent<never>>(options: BaseOptions<K, EntryKey, D, H, M, N> & {\n inject?: undefined;\n }, component: C & SlotComponent<ComposedProps<K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>> & RendersCheck<C, D>): () => void;\n register<K extends keyof SlotMap & string, I extends object, const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>, const D extends ChildrenDecl = Record<never, never>, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent<never> = SlotComponent<never>>(options: BaseOptions<K, EntryKey, D, H, M, N> & {\n inject: (...args: InjectParams<K, H>) => I;\n }, component: C & SlotComponent<ComposedProps<K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>> & RendersCheck<C, D>): () => void;\n register(options: ErasedOptions, component: unknown): () => void;\n isLive(entry: StoredEntry): boolean;\n entries(key: string): readonly StoredEntry[];\n entriesOfSlot(key /* …truncated — full shape in source */',
},
{
name: 'SlotEntryDef',
declaration: 'export interface SlotEntryDef {\n kind: SlotKind;\n scope: SlotScope;\n owner?: object;\n keyProps?: Record<string, object>;\n hookContext?: unknown;\n inject?: object;\n}',
},
{
name: 'SlotInjectFace',
declaration: 'export type SlotInjectFace<I extends object> = I extends {\n hooks: infer HS extends object;\n} ? Omit<I, \'hooks\'> & PropsSlotHooks<HS> : I;',
},
{
name: 'SlotInjectOf',
declaration: 'export type SlotInjectOf<K extends keyof SlotMap & string> = SlotMap[K] extends {\n inject: infer Injected extends object;\n} ? Injected : object;',
},
{
name: 'SlotKind',
declaration: 'export type SlotKind = \'single\' | \'list\' | \'keyed\' | \'chain\';',
},
{
name: 'SlotLabel',
declaration: 'export type SlotLabel = string | (() => string);',
},
{
name: 'SlotMap',
declaration: 'export interface SlotMap {\n}',
},
{
name: 'SlotScope',
declaration: 'export type SlotScope = \'root\' | \'session-maybe\' | \'session\';',
},
{
name: 'SlotSpec',
declaration: 'export type SlotSpec<E extends SlotEntryDef> = {\n kind: E[\'kind\'];\n scope: E[\'scope\'];\n} & (\'inject\' extends keyof E ? E extends {\n inject: infer Injected extends object;\n} ? {\n inject: Injected;\n} : {\n inject?: object;\n} : {\n inject?: never;\n});',
},
{
name: 'SnapshotSelectorHook',
declaration: 'export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S;',
},
{
name: 'SteeringMessageNode',
declaration: 'export interface SteeringMessageNode {\n kind: \'steering\';\n messageId: MessageId;\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n}',
},
{
name: 'StepLocation',
declaration: 'export interface StepLocation {\n readonly turn: number;\n readonly step: number;\n readonly start: SessionEvent<\'step/start\'> | undefined;\n readonly end: SessionEvent<\'step/end\'> | undefined;\n readonly status: \'open\' | \'closed\' | \'unknown\';\n readonly data: ConversationLocationDataStore<ConversationStepDataMap>;\n}',
},
{
name: 'StoreDecl',
declaration: 'export type StoreDecl = StoreHandle<any, any> | StoreFactory;',
},
{
name: 'StoredEntry',
declaration: 'export interface StoredEntry {\n component: unknown;\n options: {\n key?: string;\n id?: string;\n order?: number;\n label?: SlotLabel;\n priority?: number;\n };\n select?: ((owner: never) => unknown) | undefined;\n inject?: ((...args: never[]) => Record<string, unknown>) | undefined;\n children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined;\n store?: StoreDecl | undefined;\n locale?: string | undefined;\n registrant?: string | undefined;\n}',
},
{
name: 'StoreFactory',
declaration: 'export type StoreFactory = () => StoreHandle<any, any>;',
},
{
name: 'StoreHandle',
declaration: 'export interface StoreHandle<T, A extends ActionsDecl<T>> {\n readonly spec: StoreSpec<T, A>;\n create(scopeKey?: string): StoreInstance<T, A>;\n}',
},
{
name: 'StoreInstance',
declaration: 'export interface StoreInstance<T, A extends ActionsDecl<T>> {\n readonly actions: BakedActions<T, A>;\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n clearPersisted(): void;\n}',
},
{
name: 'StoreSpec',
declaration: 'export interface StoreSpec<T, A extends ActionsDecl<T>> {\n init: () => T;\n persist?: string;\n actions: A;\n}',
},
{
name: 'ThemeDefinition',
declaration: 'export interface ThemeDefinition {\n id: string;\n colorScheme: \'light\' | \'dark\';\n tokens: ThemeTokens;\n}',
},
{
name: 'ThemePreference',
declaration: 'export type ThemePreference = typeof THEME_PREFERENCES[number];',
},
{
name: 'ThemeSnapshot',
declaration: 'export interface ThemeSnapshot {\n preference: ThemePreference;\n active: ThemeDefinition;\n themes: readonly ThemeDefinition[];\n revision: number;\n}',
},
{
name: 'ThemeTokenModes',
declaration: 'export interface ThemeTokenModes {\n light: string;\n dark: string;\n}',
},
{
name: 'ThemeTokenOverrides',
declaration: 'export type ThemeTokenOverrides = Record<string, ThemeTokenModes>;',
},
{
name: 'ThemeTokens',
declaration: 'export type ThemeTokens = Record<string, string>;',
},
{
name: 'ToolCallBlock',
declaration: 'export type ToolCallBlock = RunningToolCall | ToolResultNode;',
},
{
name: 'ToolResultNode',
declaration: 'export interface ToolResultNode {\n kind: \'tool-result\';\n seq: number;\n time: number;\n callId: string;\n call: {\n name: string;\n argsRaw: string;\n } | null;\n callTime: number | null;\n content: readonly ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n callView: ToolCallView | null;\n resultView: ToolResultView | null;\n subCalls: readonly ToolCallBlock[];\n}',
},
{
name: 'Translate',
declaration: 'export type Translate<K extends string = string> = (key: K, params?: Record<string, unknown>) => string;',
},
{
name: 'TranslateNS',
declaration: 'export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>;',
},
{
name: 'TurnErrorNode',
declaration: 'export interface TurnErrorNode {\n kind: \'turn-error\';\n seq: number;\n time: number;\n turn: number;\n step: number;\n message: string;\n code?: string;\n}',
},
{
name: 'TurnLocation',
declaration: 'export interface TurnLocation {\n readonly turn: number;\n readonly start: SessionEvent<\'turn/start\'> | undefined;\n readonly end: SessionEvent<\'turn/end\'> | undefined;\n readonly status: \'open\' | \'closed\' | \'unknown\';\n readonly steps: readonly StepLocation[];\n readonly data: ConversationLocationDataStore<ConversationTurnDataMap>;\n}',
},
{
name: 'TurnMaxTokensNode',
declaration: 'export interface TurnMaxTokensNode {\n kind: \'turn-max-tokens\';\n seq: number;\n time: number;\n turn: number;\n step: number;\n}',
},
{
name: 'UnknownSurfaceNode',
declaration: 'export interface UnknownSurfaceNode {\n kind: \'unknown\';\n seq: number;\n time: number;\n type: string;\n data: unknown;\n}',
},
{
name: 'UserMessageNode',
declaration: 'export interface UserMessageNode {\n kind: \'user\';\n seq: number;\n time: number;\n content: readonly ContentBlock[];\n source: unknown;\n}',
},
]
/** The inherited `ctx` API (cordis core + loader/hmr/timer), in curated order. */
export const INHERITED_CTX_API: readonly InheritedApiEntry[] = [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / short-circuit chain).' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.' },
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).' },
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.' },
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the four supported helpers are mixed onto ctx directly (declared via Pick).' },
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).' },
{ name: 'ctx.hmr', summary: 'The hot-module-reload watcher (present under the hmr plugin).' },
]
function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] {
const included = new Set<string>()
let frontier = [...seeds]
while (frontier.length > 0) {
const next: string[] = []
for (const entry of TYPE_API) {
if (included.has(entry.name)) continue
const pattern = new RegExp(`\b${entry.name}\b`)
if (!frontier.some(text => pattern.test(text))) continue
included.add(entry.name)
next.push(entry.declaration)
}
frontier = next
}
return TYPE_API.filter(entry => included.has(entry.name))
}
function contextProperty(key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key) ? `ctx.${key}` : `ctx[${JSON.stringify(key)}]`
}
/**
* Project the Service Catalog as a compact directory or one exact coding contract.
* @param key - exact Service key; omit it to list all Services and method signatures.
* @param services - platform-specific visible Service entries.
* @returns compact navigation data or one detailed Service with its referenced type closure.
*/
export function queryServiceApi(key?: string, services: readonly ServiceApiEntry[] = SERVICE_API): object {
if (key === undefined) {
return {
mode: 'catalog',
services: services.map(service => ({
key: service.key,
description: service.summary,
methods: service.methods.map(method => ({ signature: method.signature })),
})),
}
}
const service = services.find(candidate => candidate.key === key)
if (service === undefined) throw new Error(`no catalogued Service named "${key}"`)
return {
mode: 'service',
service: {
key: service.key,
description: service.description,
access: {
optional: { expression: `ctx.get(${JSON.stringify(service.key)})`, requiresUndefinedCheck: true },
hardDependency: { inject: [service.key], expression: contextProperty(service.key) },
},
methods: service.methods,
},
referencedTypes: referencedTypeClosure(service.methods.map(method => method.signature)),
}
}
/**
* Project the Event Catalog as a compact directory or one exact listener contract.
* @param name - exact Event name; omit it to list all Events and listener signatures.
* @param events - platform-specific visible Event entries.
* @returns compact navigation data or one detailed Event with its referenced type closure.
*/
export function queryEventApi(name?: string, events: readonly EventApiEntry[] = EVENT_API): object {
if (name === undefined) {
return {
mode: 'catalog',
events: events.map(event => ({
name: event.name,
description: event.summary,
mode: event.mode,
signature: event.signature,
})),
}
}
const event = events.find(candidate => candidate.name === name)
if (event === undefined) throw new Error(`no catalogued Event named "${name}"`)
return {
mode: 'event',
event: {
name: event.name,
description: event.description,
mode: event.mode,
signature: event.signature,
parameters: event.parameters,
},
referencedTypes: referencedTypeClosure([event.signature]),
}
}
/* jscpd:ignore-end */

View File

@@ -0,0 +1,222 @@
/**
* Browser-half closure evaluation: the package source runs as the body of an
* async function whose parameters ARE the symbol surface. Shadowing parameters
* (setTimeout/fetch/require/…) turn the ambient browser globals into teaching
* redirects without touching the page. The host syntax-prechecked the source at
* define time; SyntaxError handling here is the engine-divergence fallback and
* reaches the model through the load report.
*/
import * as React from 'react'
import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
/** A mountable plugin as the closure must return it (FUNCTION or OBJECT form). */
export interface DynamicCordisEvaluatedPlugin {
/** Optional plugin name; the runner overwrites it with the module id. */
name?: string
/** Services the browser half declares; the runner overwrites it from the dispatched row. */
inject?: string[]
/** Plugin body receiving the guard facade. */
apply: (ctx: unknown, config?: unknown) => unknown
}
/** What the evaluator needs from the runner to build one package's closure. */
export interface DynamicCordisClosureEnv {
/** Route `host.call` to this package's host half over the wire. */
invoke(method: string, args: unknown): Promise<unknown>
/** Mirror one runtime error text into the load report (console.error copies). */
noteError(message: string): void
}
const TIMER_REDIRECT
= 'browser timer globals are unavailable in dynamic packages. Declare inject: [\'timer\'] on the returned plugin, '
+ 'query Client Service.listService for the exact API, and close over that plugin ctx. In React, create timers '
+ 'from an event handler or React.useEffect and return callback-form disposers from the effect cleanup.'
/**
* Where each withheld browser global sends the author instead. One home for two
* consumers: the closure traps below throw these, and a render crash whose
* message names one of them gets the same redirect appended — a package that
* reached the global some other way (`window.setInterval`) crashes with the
* engine's own bare text, and the author needs the redirect either way.
*/
export const DYNAMIC_CLIENT_REDIRECTS: Readonly<Record<string, string>> = {
setTimeout: TIMER_REDIRECT,
setInterval: TIMER_REDIRECT,
clearTimeout: TIMER_REDIRECT,
clearInterval: TIMER_REDIRECT,
fetch:
'network belongs to the HOST half: register a handler there with harness.handle(method, fn) and call it here via host.call(method, args).',
require:
'modules cannot be imported here. React arrives as the `React` closure symbol; everything else goes through ctx services or host.call.',
}
/** Callable teaching traps shadowing the ambient globals the closure must not reach. */
function closureTraps(): Record<string, () => never> {
const traps: Record<string, () => never> = {}
for (const [name, redirect] of Object.entries(DYNAMIC_CLIENT_REDIRECTS)) {
traps[name] = (): never => {
throw new Error(`${name} is not available in a dynamic client half — ${redirect}`)
}
}
return traps
}
/** The `harness` seat exists only host-side; any touch teaches the split. */
function harnessTrap(): unknown {
return new Proxy({}, {
get(_target, prop) {
throw new Error(
`harness.${String(prop)} belongs to the HOST half (\`code\`): register handlers there with harness.handle(method, fn); `
+ 'the browser half calls them via host.call(method, args).',
)
},
})
}
/** Per-package style-tag bookkeeping behind the `styles.insert` symbol. */
export class DynamicCordisStyles {
private readonly tags = new Set<HTMLStyleElement>()
/** @param pluginId - owning Plugin ID, stamped as `data-dyn` on every tag. */
constructor(private readonly pluginId: CordisDynamicPluginId) {}
/**
* Inject one stylesheet, removed automatically on package unload.
* @param css - raw CSS text.
* @returns disposer removing this one tag early.
*/
insert(css: string): () => void {
if (typeof css !== 'string') throw new Error('styles.insert(css) needs a CSS string')
const tag = document.createElement('style')
tag.dataset.dyn = this.pluginId
tag.textContent = css
document.head.append(tag)
this.tags.add(tag)
return () => {
this.tags.delete(tag)
tag.remove()
}
}
/** Live tag count (load-report contribution summary). */
get count(): number {
return this.tags.size
}
/** Remove every tag this package still owns (unload path). */
dispose(): void {
for (const tag of this.tags) tag.remove()
this.tags.clear()
}
}
/** Stringify one console argument for the error mirror. */
function errorText(arg: unknown): string {
if (arg instanceof Error) return arg.message
if (typeof arg === 'string') return arg
if (arg === undefined) return 'undefined'
try {
return JSON.stringify(arg)
} catch {
// A circular or otherwise non-serializable console argument: the mirror
// carries the message, and nothing else here can fail.
return '[unserializable console argument]'
}
}
/** Tagged write-through console; error lines additionally copy into the load report. */
function taggedConsole(pluginId: CordisDynamicPluginId, noteError: (message: string) => void): Console {
const tag = `[cordis:${pluginId}]`
const forward = (level: 'log' | 'info' | 'warn' | 'error' | 'debug') => (...args: unknown[]): void => {
console[level](tag, ...args)
if (level !== 'error') return
noteError(args.map(errorText).join(' ').slice(0, 500))
}
return {
...console,
log: forward('log'),
info: forward('info'),
warn: forward('warn'),
error: forward('error'),
debug: forward('debug'),
}
}
/**
* Narrow a closure return value to a mountable plugin (host guard mirror).
* @param value - whatever the closure returned.
* @returns whether the value is mountable.
*/
export function isDynamicCordisPlugin(value: unknown): value is DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown) {
if (typeof value === 'function') return true
return typeof value === 'object' && value !== null
&& typeof (value as { apply?: unknown }).apply === 'function'
}
/**
* Evaluate one package's browser half and return the (un-guarded) plugin.
* @param pluginId - stable Plugin ID (console tag and style ownership).
* @param clientCode - the browser half's source: an async function body returning a plugin.
* @param env - runner wiring for `host.call` and error mirroring.
* @param styles - the package's style bookkeeping (owned by the caller so unload can dispose it).
* @returns the plugin the closure returned.
* @throws teaching errors for syntax failures and non-plugin returns.
*/
export async function evaluateClientHalf(
pluginId: CordisDynamicPluginId,
clientCode: string,
env: DynamicCordisClosureEnv,
styles: DynamicCordisStyles,
): Promise<DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)> {
const traps = closureTraps()
const parameters = ['React', 'console', 'styles', 'host', 'harness', ...Object.keys(traps), 'process', 'Buffer']
let closure: (...args: unknown[]) => Promise<unknown>
try {
// The wrapper mirrors the host precheck exactly, so line offsets match.
// Evaluating a definition's browser half IS this package's product: the
// source arrived from a host process that accepted and prechecked it.
// oxlint-disable-next-line typescript/no-implied-eval -- see above
const factory = new Function(...parameters, `return (async () => {\n${clientCode}\n})()`)
closure = factory as (...args: unknown[]) => Promise<unknown>
} catch (error) {
if (!(error instanceof SyntaxError)) throw error
// Engine-divergence fallback: the host precheck already carried the
// line/caret teaching; browsers give only the message.
throw new Error(
`client half failed to parse in this browser: ${error.message}\n`
+ 'The browser half is plain JavaScript (no JSX, no TypeScript); build elements with React.createElement.',
)
}
const host = {
/**
* Call a host-half handler of THIS package (harness.handle pairing). A call
* with nothing to pass omits the argument: it arrives at the handler as
* `null`, because the wire carries JSON and `undefined` is not JSON —
* requiring `host.call('m', {})` would be a ritual, and defaulting to `{}`
* would invent an empty argument the caller never wrote.
*/
call: (method: string, args: unknown = null): Promise<unknown> => env.invoke(method, args),
}
const returned = await closure(
React,
taggedConsole(pluginId, (message) => { env.noteError(message) }),
styles,
host,
harnessTrap(),
...Object.values(traps),
undefined, // process: undefined keeps `typeof process` probes safe
undefined, // Buffer
)
if (!isDynamicCordisPlugin(returned)) {
if (returned === undefined) {
throw new Error(
'client half returned `undefined` — did you forget `return`?\n'
+ ' ✓ return (ctx) => { … }\n'
+ ' ✓ return { name: \'…\', inject: [\'slots\'], apply(ctx) { … } }',
)
}
throw new Error('client half must `return` a plugin: a function, or an object with an `apply(ctx)` method')
}
return returned
}

View File

@@ -0,0 +1,239 @@
/**
* The browser twin of the tool-cordis context facade: a whitelist of
* lifecycle-safe verbs plus optional `ctx.get()` lookup and declared-service
* property access, with
* framework internals withheld and Context-valued returns denied. Two seats
* carry extra machinery: `slots`, where the register proxy assigns the
* shadowing priority and ledgers the registration — invoking the service with
* the traced receiver so the effect lands on the CALLING plugin's fiber
* (SlotRegistry.register must stay a prototype method for exactly that
* reason) — and `theme`, whose override source is pinned to the package id.
*
* This is API discipline, not a security boundary: a dynamic package's code is
* as trusted as the host process that accepted its definition.
*/
import { Context } from '@deepseek-ai/cordis'
import type { DynamicCordisPackage } from '@deepseek-ai/dsh-api-remotes/client'
import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
/** Facade verbs beyond declared services (host CTX_VERBS twin). */
const CTX_VERBS = new Set([
'effect', 'on', 'once', 'provide', 'timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce',
])
const TIMER_VERBS = new Set(['timeout', 'interval', 'setTimeout', 'setInterval', 'throttle', 'debounce'])
/** One package's slot-registration ledger row (contribution projection source). */
export interface DynamicCordisSlotLedgerRow {
/** Target slot name. */
slot: string
/** The assigned shadowing priority (globally unique — how winners are matched back to packages). */
priority: number | undefined
}
/** What the facade needs beyond the real ctx to govern one package. */
export interface DynamicCordisGuardEnv {
/** The dispatched Package row. */
pkg: DynamicCordisPackage
/** Ledger sink: every slot registration this package makes. */
ledger: DynamicCordisSlotLedgerRow[]
/**
* Ownership index sink: the component object seated in a slot, so a later
* render crash reported against the stored entry can be attributed back to
* this package. Identity is the key — the registry stores the component
* verbatim — which is why nothing else has to be remembered about the entry.
* @param component - whatever the package passed as its component.
*/
claim(component: unknown): void
/** Allocate one page-local shadowing rank; later registrations sort first. */
allocatePriority(): number
/** Report one post-activation guard rejection to the owning Agent. */
reportFailure(error: Error): void
}
/** Reject any service return that is a cordis Context (host guard twin). */
function denyContext(value: unknown, service: string, env: DynamicCordisGuardEnv): unknown {
if (value instanceof Context) {
return rejectGuard(env,
`service "${service}" returned a cordis Context, which the dynamic facade does not expose. `
+ 'Operate through your own plugin ctx and the services you declared — never another context.',
)
}
return value
}
/**
* Forward service methods with the traced service as receiver — `this.ctx`
* inside prototype methods (slots.register) must stay the CALLER's ctx so
* effects land on the calling plugin's fiber — while denying Context returns.
*/
function guardedService(service: object, name: string, env: DynamicCordisGuardEnv): unknown {
return new Proxy(service, {
get(target, prop) {
const value = Reflect.get(target, prop, target) as unknown
if (typeof value !== 'function') return denyContext(value, name, env)
return (...args: unknown[]): unknown => {
const result = Reflect.apply(value, target, args) as unknown
if (result instanceof Promise) return result.then(resolved => denyContext(resolved, name, env))
return denyContext(result, name, env)
}
},
})
}
/** Erased register options as this facade reads and rewrites them. */
interface ErasedSlotOptions {
name?: string
priority?: number
[key: string]: unknown
}
/**
* The slots seat: automatic shadowing priority and ledger recording around the
* traced service's own register.
*/
function guardedSlots(slots: SlotRegistry, env: DynamicCordisGuardEnv): unknown {
return new Proxy(slots, {
get(target, prop) {
const value = Reflect.get(target, prop, target) as unknown
if (prop !== 'register') {
if (typeof value !== 'function') return denyContext(value, 'slots', env)
return (...args: unknown[]): unknown => denyContext(Reflect.apply(value, target, args), 'slots', env)
}
return (rawOptions: unknown, component: unknown): unknown => {
if (typeof rawOptions !== 'object' || rawOptions === null) {
return rejectGuard(env, 'slots.register(options, component) needs an options object with a `name`')
}
const options = { ...rawOptions as ErasedSlotOptions }
const slot = options.name
if (typeof slot !== 'string' || slot.length === 0) {
return rejectGuard(env, 'slots.register options need a string `name` (the target slot key)')
}
if (slot === 'tool.view.cordis') {
if (options.key !== 'self') {
return rejectGuard(env, 'tool.view.cordis only accepts key "self"; the runtime binds it to this Package')
}
options.key = `${env.pkg.pluginId}.${env.pkg.packageId}`
}
// Shadowing kinds get a page-local rank. Later registrations sort first;
// chain slots keep their own election (select order) untouched.
const spec = (slots.spec as (key: string) => { kind?: string } | undefined)(slot)
let priority = options.priority
if (spec === undefined || spec.kind !== 'chain') {
priority = env.allocatePriority()
options.priority = priority
}
const register = Reflect.get(target, 'register', target) as unknown as (opts: object, comp: unknown) => () => void
const dispose = register.call(target, options, component)
env.ledger.push({ slot, priority })
// After the registry accepted it: a rejected registration seats no entry,
// so claiming one would index a component no crash can ever name.
env.claim(component)
return dispose
}
},
})
}
/**
* The theme seat: `overrideTokens`' source is FORCED to the package id — a
* dynamic package can never impersonate (or evict) another source's layer, and
* its own layers converge under one identity unload can reason about. The
* layer's disposer is additionally hung on the calling fiber, because the
* documented contract is "unload restores" and model code cannot be trusted to
* keep the returned handle (slots parity — register hangs its own cleanup).
* Everything else forwards through the generic guard.
*/
function guardedTheme(theme: ThemeRuntime, env: DynamicCordisGuardEnv, ctx: Context): unknown {
return new Proxy(theme, {
get(target, prop) {
if (prop !== 'overrideTokens') {
const value = Reflect.get(target, prop, target) as unknown
if (typeof value !== 'function') return denyContext(value, 'theme', env)
return (...args: unknown[]): unknown => {
const result = Reflect.apply(value, target, args) as unknown
if (result instanceof Promise) return result.then(resolved => denyContext(resolved, 'theme', env))
return denyContext(result, 'theme', env)
}
}
return (source: unknown, tokens: unknown): unknown => {
// Two-argument shape preserved so the facade matches the documented
// service signature; the source VALUE is replaced, never trusted.
if (tokens === undefined && typeof source === 'object' && source !== null) {
return rejectGuard(env,
'theme.overrideTokens(source, tokens) takes two arguments; source is replaced with your package id, '
+ 'so pass any string first and the token map second: overrideTokens(\'mine\', { \'--dsw-alias-…\': { light: \'…\', dark: \'…\' } })',
)
}
const method = Reflect.get(target, 'overrideTokens', target)
const dispose = Reflect.apply(method, target, [`${env.pkg.pluginId}.${env.pkg.packageId}`, tokens]) as () => void
// Fiber-owned lifetime; the returned handle stays valid for early
// removal (the service disposer is idempotent per layer identity).
ctx.effect(() => dispose, 'cordis-client-runner: dynamic theme override layer')
return dispose
}
},
})
}
/**
* Build the facade one dynamic plugin's `apply` receives (host sandboxContext
* twin, browser seats). `ctx.get(name)` performs optional lookup; direct
* `ctx.serviceName` access is gated by the fiber's `inject` declaration.
* @param ctx - the plugin's real fiber ctx (loader-created).
* @param env - package row + ledger sink.
* @returns the whitelisting proxy standing in for ctx.
*/
export function dynamicCordisContext(ctx: Context, env: DynamicCordisGuardEnv): Context {
const declared = new Set(Object.keys(ctx.fiber.inject))
const denyRead = (prop: string): never => {
if (ctx.get(prop) !== undefined) {
return rejectGuard(env,
`service "${prop}" is not declared by your plugin. Declare it on the plugin you return: `
+ `{ inject: ['${prop}', …], apply(ctx) { … } } — a plain \`function\` has no declaration site, `
+ 'so use the object form. The runtime then parks the package if the provider unloads.',
)
}
return rejectGuard(env,
`dynamic ctx does not expose "${prop}". Available: ctx.on / ctx.provide / timer helpers after injecting timer, and any service your `
+ 'returned plugin declared in inject (slots and theme are the usual UI seats). Framework internals are withheld '
+ 'by design.',
)
}
const readService = (name: string, requireDeclaration: boolean): unknown => {
if (requireDeclaration && !declared.has(name)) return denyRead(name)
const service = denyContext(ctx.get(name), name, env)
if (service === null || (typeof service !== 'object' && typeof service !== 'function')) return service
if (name === 'slots') return guardedSlots(service as SlotRegistry, env)
if (name === 'theme') return guardedTheme(service as ThemeRuntime, env, ctx)
return guardedService(service, name, env)
}
return new Proxy({}, {
get(_target, prop) {
if (prop === 'get') return (name: string): unknown => readService(name, false)
if (typeof prop !== 'string') return undefined
// Lazy verb forwarder (host twin): resolve ctx[verb] only when called.
if (CTX_VERBS.has(prop)) {
return (...args: unknown[]): unknown => {
if (TIMER_VERBS.has(prop) && !declared.has('timer')) return denyRead('timer')
const method = ctx[prop as keyof Context]
return Reflect.apply(method as (...a: unknown[]) => unknown, ctx, args)
}
}
return readService(prop, true)
},
set(_target, prop) {
return rejectGuard(env, `dynamic ctx is read-only; cannot assign "${String(prop)}"`)
},
has: (_target, prop) => prop === 'get'
|| (typeof prop === 'string'
&& ((CTX_VERBS.has(prop) && (!TIMER_VERBS.has(prop) || declared.has('timer'))) || declared.has(prop))),
}) as unknown as Context
}
function rejectGuard(env: DynamicCordisGuardEnv, message: string): never {
const error = new Error(message)
env.reportFailure(error)
throw error
}

View File

@@ -0,0 +1,308 @@
/**
* Dynamic-package runner, browser half: the load engine that turns one browser
* half's source into a live cordis plugin (closure → guard → module table →
* loader entry, ./runtime.ts), plus the retract announcement that unloads it.
*
* Nothing loads on activation: this page holds no dynamic package until a
* dispatch arrives, and a dispatch only follows a model `cordis_run` or a user
* pressing a card's start control. A refresh therefore starts clean by design —
* host process memory still holds the definition, the page simply does not run
* it until asked again.
*/
import type { Context } from '@deepseek-ai/cordis'
import type {
ApprovalRequestId, CordisDynamicPluginId, DynamicCordisInvokeResult, JsonValue,
DynamicCordisInventoryRow,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
// The Client Remote assembly is the one place the two planes meet: it mounts the
// `dynamicCordisRunner` namespace and re-exports its payload vocabulary, so this
// package names what it sends without importing a Host package.
import type { DynamicCordisLivePackage } from './runtime.ts'
import { DynamicCordisPackageRunner } from './runtime.ts'
import { CordisRunOrchestrator } from './orchestrator.ts'
import { ClientCordisInspectRegistry, provideClientCordisInspect } from './inspect-registry.ts'
import { clientInspectProviders } from './providers.ts'
import { provideClientTimer } from './timer.ts'
import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest } from './orchestrator.ts'
import type { CordisObservable, DynamicCordisRenderFailure } from './runtime.ts'
export { CordisRunOrchestrator } from './orchestrator.ts'
export { ClientCordisInspectRegistry } from './inspect-registry.ts'
export type {
ClientCordisInspectHost, ClientCordisInspectProviderRegistration, ClientCordisInspectQueryContext,
} from './inspect-registry.ts'
export type {
CordisRunActivity, CordisRunFailure, CordisRunHostSeam,
CordisRunOrchestratorEnv, CordisRunRequest, CordisUserRunRequest,
} from './orchestrator.ts'
export { DynamicCordisPackageRunner } from './runtime.ts'
export type {
CordisObservable, DynamicCordisClientHalf, DynamicCordisLivePackage, DynamicCordisLoadErrorCause,
DynamicCordisLoadResult, DynamicCordisRenderFailure, DynamicCordisRunnerEnv,
} from './runtime.ts'
export { DynamicCordisStyles, evaluateClientHalf, isDynamicCordisPlugin } from './evaluator.ts'
export type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from './evaluator.ts'
export { dynamicCordisContext } from './guard.ts'
export type { DynamicCordisGuardEnv, DynamicCordisSlotLedgerRow } from './guard.ts'
export { ClientTimerService } from './timer.ts'
// Re-exported so consumers of the service face and the two events can name
// their subjects without reaching into the wire contract themselves.
export type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
DynamicCordisPackage,
} from '@deepseek-ai/dsh-api-remotes/client'
/**
* What a run surface reads and calls. The activity map is the single home of
* "a run is in flight", so an affordance never keeps its own copy — that is what
* makes it survive a remount.
*/
export interface CordisRunnerFace {
/** Each definition's in-flight run activity. */
readonly activeRuns: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>>
/** The last failure of this page's own run attempt, per definition. */
readonly lastRunError: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunFailure>>
/**
* This page's last render crash per definition: a browser half that loaded
* cleanly and then broke while React rendered it. Page-local and current by
* construction — cleared when the package stops, is retracted, or loads again —
* which is what makes it safe for a row to render directly. The host keeps its
* own last-across-pages copy for the model; the two have different owners and
* lifetimes and neither is derived from the other.
*/
readonly renderFailures: CordisObservable<ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure>>
/**
* Restore pending approvals after a page reconnect or missed event.
* @param rows - current dynamic Plugin inventory.
*/
reconcileApprovals(rows: readonly DynamicCordisInventoryRow[]): void
/**
* Answer one run request with "run it" and drive both halves.
* @param requestId - the request being answered; unknown or settled ids are a no-op.
* @param approveFutureVersions - whether this decision covers later Packages of the same Plugin.
* @returns after the orchestration settled.
*/
approve(requestId: ApprovalRequestId, approveFutureVersions: boolean): Promise<void>
/**
* Answer one run request with "do not run it".
* @param requestId - the request being answered; unknown or settled ids are a no-op.
* @returns after the refusal reached the host.
*/
decline(requestId: ApprovalRequestId): Promise<void>
/**
* Run a definition here at the user's own request (the gesture authorizes it).
* A definition with a browser half also loads onto this page; a host-only one
* only comes up in the host process.
* @param request - the definition to run, its session, and whether it has a browser half.
* @returns after the orchestration settled.
*/
startUserRun(request: CordisUserRunRequest): Promise<void>
/**
* Observe what this page has loaded.
* @param fn - notified after every converged load or unload.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Read what this page currently has loaded.
* @returns immutable rows for live Client halves.
*/
getSnapshot(): readonly DynamicCordisLivePackage[]
/**
* Whether this page loaded a definition's browser half — page-local truth,
* never the host's "it is running".
* @param pluginId - stable Plugin identity.
* @returns true while a load is live here.
*/
isLoaded(pluginId: CordisDynamicPluginId): boolean
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Run orchestration and page-local load state: what run surfaces read and call. */
dynamicCordisRunner: CordisRunnerFace
}
}
/** Teaching text for a routing failure the infrastructure itself reports. */
function invokeFailure(pluginId: CordisDynamicPluginId, method: string, result: Extract<DynamicCordisInvokeResult, { ok: false }>): string {
const where = `host.call("${method}") on ${pluginId}`
if (result.code === 'plugin-not-running') {
return `${where} found no active Host half — the Plugin is stopped or was removed.`
}
if (result.code === 'stale-run') {
return `${where} belongs to an activation that has already been replaced.`
}
if (result.code === 'method-not-found') {
return `${where} is not registered: the host half must declare it with harness.handle("${method}", fn).`
}
return `${where} failed inside the host handler: ${result.message}`
}
/** Preserve a Host handler's stack while adding the Client call site diagnosis. */
function invokeError(
pluginId: CordisDynamicPluginId,
method: string,
result: Extract<DynamicCordisInvokeResult, { ok: false }>,
): Error {
const error = new Error(invokeFailure(pluginId, method, result))
if (result.stack !== undefined) error.stack = `${error.stack ?? error.message}\nHost stack:\n${result.stack}`
return error
}
/**
* Teaching text for a `host.call` the wire itself refused: the generated codec
* rejected the argument before sending, or the result on the way back, or the
* transport broke. The infrastructure's message names the field it refused but
* not the call it belonged to, and the model authored both halves — so this adds
* the call and the contract it has to satisfy.
*/
function wireFailure(id: CordisDynamicPluginId, method: string, error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return `host.call("${method}") on ${id} did not complete: ${message}\n`
+ 'Both directions carry JSON only: pass plain JSON data as the argument — or omit it, and the handler receives '
+ `null — and answer from harness.handle("${method}", fn) with JSON (\`return null\` when there is nothing to report).`
}
/** Stable Cordis plugin name. */
export const name = 'cordis-client-runner'
/**
* Required services: the loader/module chain for entries, the slot registry for
* contributions, and the `dynamicCordisRunner` Remote namespace. Declaring the
* namespace parks this plugin until the host side exists, so a page never loads
* a browser half whose host half it could not reach.
*/
export const inject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCordisRunner']
/**
* Client plugin body: build the runner and subscribe the dispatch family.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
provideClientTimer(ctx)
const inspect = new ClientCordisInspectRegistry({
sync: async (providers) => {
const answered = await ctx.remote.dynamicCordisRunner.syncInspectManifest(providers)
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
},
resolve: async (agentId, requestId, resolution) => {
const answered = await ctx.remote.dynamicCordisRunner.resolveInspectQuery(agentId, requestId, resolution)
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
},
})
provideClientCordisInspect(ctx, inspect)
for (const provider of clientInspectProviders(ctx)) {
ctx.effect(() => inspect.register(provider), `cordis-client-runner: inspect ${provider.manifest.id}`)
}
ctx.on('connection/reset', () => { inspect.publish() })
const runner = new DynamicCordisPackageRunner({
ctx,
loader: ctx.loader,
modules: ctx.get('modules') as ClientModuleSystem,
slots: ctx.get('slots') as SlotRegistry,
invoke: async (pluginId, pluginRunId, method, args) => {
// Model-authored arguments reach this boundary untyped; the namespace's
// generated codec is what validates them as JSON, and its rejection is a
// bare field name — this is the only place that still knows which call it
// belonged to, so the teaching has to be added here.
const answered = await ctx.remote.dynamicCordisRunner.invoke(pluginId, pluginRunId, method, args as JsonValue)
.catch((error: unknown) => { throw new Error(wireFailure(pluginId, method, error)) })
// Two failure layers, and they teach different things: the carrier's error
// branch means the call never reached the host half, while the namespace's
// own `ok: false` is that half answering with a refusal.
if (!answered.ok) throw new Error(wireFailure(pluginId, method, `${answered.error.code}: ${answered.error.message}`))
const result = answered.value
if (result.ok) return result.value
throw invokeError(pluginId, method, result)
},
// Post-settle diagnosis, deliberately fire-and-forget: the run this package
// belongs to was answered before it ever rendered, so nothing waits on this
// and a failed report must not turn one crash into two.
reportRenderFailure: (agentId, pluginId, pluginRunId, failure) => {
void ctx.remote.dynamicCordisRunner.reportRenderFailure(agentId, pluginId, pluginRunId, failure).then((result) => {
if (!result.ok) {
console.error(`[cordis-client-runner] reporting a render failure of ${pluginId} failed:`, result.error)
}
}, (error: unknown) => {
console.error(`[cordis-client-runner] reporting a render failure of ${pluginId} failed:`, error)
})
},
reportGuardFailure: (agentId, pluginId, pluginRunId, failure) => {
void ctx.remote.dynamicCordisRunner.reportClientGuardFailure(agentId, pluginId, pluginRunId, failure).then((result) => {
if (!result.ok) {
console.error(`[cordis-client-runner] reporting a guard failure of ${pluginId} failed:`, result.error)
}
}, (error: unknown) => {
console.error(`[cordis-client-runner] reporting a guard failure of ${pluginId} failed:`, error)
})
},
})
const orchestrator = new CordisRunOrchestrator({
runner,
host: {
// The seam names business payloads only, so a carrier failure is folded
// here into whatever each verb already does with one: the short-circuit
// message for a start, a throw where the caller has a catch of its own.
runHostHalf: async (agentId, pluginId, packageId, mode, requestId, approveFutureVersions) => {
const answered = await ctx.remote.dynamicCordisRunner.runHostHalf(
agentId, pluginId, packageId, mode, requestId, approveFutureVersions,
)
return answered.ok ? answered.value : { ok: false, message: `${answered.error.code}: ${answered.error.message}` }
},
getClientCode: async (agentId, pluginId, pluginRunId) => {
const answered = await ctx.remote.dynamicCordisRunner.getClientCode(agentId, pluginId, pluginRunId)
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
return answered.value
},
resolveRequestRun: async (requestId, resolution) => {
const answered = await ctx.remote.dynamicCordisRunner.resolveRequestRun(requestId, resolution)
// Thrown rather than returned: `answer` logs and drops a failed answer,
// and the host settles the request on its own either way.
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
return answered.value
},
settleUserRun: async (agentId, pluginId, resolution) => {
const answered = await ctx.remote.dynamicCordisRunner.settleUserRun(agentId, pluginId, resolution)
if (!answered.ok) throw new Error(`${answered.error.code}: ${answered.error.message}`)
return answered.value
},
},
})
const face: CordisRunnerFace = {
activeRuns: orchestrator.activeRuns,
lastRunError: orchestrator.lastRunError,
renderFailures: runner.renderFailures,
reconcileApprovals: (rows) => { orchestrator.reconcileApprovals(rows) },
approve: (requestId, approveFutureVersions) => orchestrator.approve(requestId, approveFutureVersions),
decline: requestId => orchestrator.decline(requestId),
startUserRun: request => orchestrator.startUserRun(request),
subscribe: fn => runner.subscribe(fn),
getSnapshot: () => runner.getSnapshot(),
isLoaded: id => runner.isLoaded(id),
}
ctx.provide('dynamicCordisRunner', face)
ctx.effect(() => () => { void runner.dispose() }, 'cordis-client-runner: dynamic package runner')
// Forwarded Host events: `$on` hands the listener the Host's own argument list,
// so these read the request itself rather than a transport envelope.
ctx.remote.$on('cordis/request-run', (request) => {
orchestrator.open(request)
})
ctx.remote.$on('cordis/request-run-resolved', (resolved) => { orchestrator.close(resolved.requestId) })
ctx.remote.$on('cordis/dynamic-retract', (retracted) => {
runner.retract(retracted.pluginId, retracted.pluginRunId)
})
ctx.remote.$on('cordis/inspect-query', (request) => {
void inspect.query(request).catch((error: unknown) => {
console.error(`[cordis-client-runner] inspect query ${request.provider}.${request.method} failed:`, error)
})
})
ctx.remote.$on('cordis/inspect-query-resolved', (resolved) => { inspect.close(resolved.requestId) })
}

View File

@@ -0,0 +1,150 @@
/** Browser registry for read-only Cordis capability providers. */
import type { Context } from '@deepseek-ai/cordis'
import type {
CordisInspectProviderManifest, CordisInspectQueryRequest, CordisInspectQueryResolution,
CordisInspectRequestId, JsonValue,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** Context supplied to a Client inspect provider query. */
export interface ClientCordisInspectQueryContext {
/** Cancellation broadcast by the Host. */
signal: AbortSignal
/** Session whose model requested the query. */
sessionId: SessionId
}
/** Client provider registration retained beside its serializable manifest. */
export interface ClientCordisInspectProviderRegistration {
/** Provider and explicit query directory. */
manifest: CordisInspectProviderManifest
/** Execute one declared read-only method. */
query(method: string, input: JsonValue | undefined, context: ClientCordisInspectQueryContext): Promise<JsonValue>
}
/** Remote operations needed by the Client registry. */
export interface ClientCordisInspectHost {
/** Replace the Host's mirrored Client manifest. */
sync(providers: readonly CordisInspectProviderManifest[]): Promise<void>
/** Submit one query result; the first accepted page wins. */
resolve(
sessionId: SessionId,
requestId: CordisInspectRequestId,
resolution: CordisInspectQueryResolution,
): Promise<void>
}
/** Client provider registry, manifest publisher, and live query dispatcher. */
export class ClientCordisInspectRegistry {
private readonly providers = new Map<string, ClientCordisInspectProviderRegistration>()
private readonly active = new Map<CordisInspectRequestId, AbortController>()
private publishQueued = false
private syncChain = Promise.resolve()
/** @param host - folded manifest and query result transport. */
constructor(private readonly host: ClientCordisInspectHost) {}
/**
* Register one Client provider and publish a new complete manifest.
* @param registration - provider manifest and local handler.
* @returns idempotent disposer.
*/
register(registration: ClientCordisInspectProviderRegistration): () => void {
const { manifest } = registration
if (manifest.id.trim() === '') throw new Error('Client Cordis inspect provider id must not be empty')
if (this.providers.has(manifest.id)) throw new Error(`Client Cordis inspect provider "${manifest.id}" is already registered`)
const names = new Set<string>()
for (const method of manifest.methods) {
if (names.has(method.name)) throw new Error(`Client Cordis inspect provider "${manifest.id}" repeats method "${method.name}"`)
names.add(method.name)
}
this.providers.set(manifest.id, registration)
this.publish()
let disposed = false
return () => {
if (disposed) return
disposed = true
if (this.providers.get(manifest.id) === registration) {
this.providers.delete(manifest.id)
this.publish()
}
}
}
/** Publish the current complete manifest, including after reconnect. */
publish(): void {
if (this.publishQueued) return
this.publishQueued = true
queueMicrotask(() => {
this.publishQueued = false
const manifests = [...this.providers.values()].map(provider => provider.manifest)
this.syncChain = this.syncChain.then(async () => {
await this.host.sync(manifests)
}).catch((error: unknown) => {
console.error('[cordis-client-runner] syncing inspect providers failed:', error)
})
})
}
/**
* Execute and answer one Host-broadcast query.
* @param request - exact provider query and Session correlation received from Host.
* @returns after the first local result has been sent back to Host.
*/
async query(request: CordisInspectQueryRequest): Promise<void> {
if (this.active.has(request.requestId)) return
const controller = new AbortController()
this.active.set(request.requestId, controller)
let resolution: CordisInspectQueryResolution
try {
const provider = this.providers.get(request.provider)
if (provider === undefined) {
resolution = { ok: false, reason: 'provider-missing', message: `Client inspect provider "${request.provider}" is unavailable` }
} else if (!provider.manifest.methods.some(method => method.name === request.method)) {
resolution = { ok: false, reason: 'method-missing', message: `Client inspect provider "${request.provider}" has no method "${request.method}"` }
} else {
const data = await provider.query(request.method, request.input, {
signal: controller.signal,
sessionId: request.agentId,
})
resolution = controller.signal.aborted
? { ok: false, reason: 'cancelled', message: 'Client inspect query was cancelled' }
: { ok: true, data }
}
} catch (error) {
resolution = controller.signal.aborted
? { ok: false, reason: 'cancelled', message: 'Client inspect query was cancelled' }
: { ok: false, reason: 'provider-error', message: error instanceof Error ? error.message : String(error) }
} finally {
this.active.delete(request.requestId)
}
if (controller.signal.aborted) return
await this.host.resolve(request.agentId, request.requestId, resolution)
}
/**
* Cancel local work after another page answered or the Tool call ended.
* @param requestId - query correlation that is no longer answerable.
*/
close(requestId: CordisInspectRequestId): void {
this.active.get(requestId)?.abort()
this.active.delete(requestId)
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Browser registry for pre-definition Cordis capability discovery. */
cordisInspect: ClientCordisInspectRegistry
}
}
/**
* Provide the registry as a normal Client service.
* @param ctx - Client Cordis context receiving the service.
* @param registry - page-local inspect registry to publish.
*/
export function provideClientCordisInspect(ctx: Context, registry: ClientCordisInspectRegistry): void {
ctx.provide('cordisInspect', registry)
}

View File

@@ -0,0 +1,458 @@
/**
* Page-side run orchestration for model approvals and direct panel gestures.
* Host activation always precedes Client loading. The same Plugin-keyed state
* drives every surface, so remounting a panel never loses an open approval or
* an in-flight transition.
*/
import type {
ApprovalRequestId,
CordisDynamicPackageId,
CordisDynamicPluginId,
CordisDynamicPluginRunId,
CordisDynamicRunMode,
DynamicCordisClientSource,
DynamicCordisHostHalfResult,
DynamicCordisInventoryRow,
DynamicCordisResolveAck,
DynamicCordisRunResolution,
DynamicCordisRunResponse,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { errorDetails } from './runtime.ts'
import type { CordisErrorDetails, CordisObservable, DynamicCordisPackageRunner } from './runtime.ts'
/** One Plugin's in-flight approval or activation. */
export type CordisRunActivity =
| {
phase: 'awaiting-approval'
requestId: ApprovalRequestId
agentId: SessionId
packageId: CordisDynamicPackageId
mode: CordisDynamicRunMode
name: string
purpose: string
}
| {
phase: 'orchestrating'
agentId: SessionId
packageId: CordisDynamicPackageId
mode: CordisDynamicRunMode
}
/** Why this page's latest activation attempt failed. */
export interface CordisRunFailure {
/** Package the attempt targeted. */
packageId: CordisDynamicPackageId
/** Which half or settlement stage failed. */
reason: 'host-half-failed' | 'client-half-failed'
/** Actionable failure text. */
message: string
/** Original failure stack when available. */
stack?: string
}
/** Host operations consumed by the orchestrator after transport folding. */
export interface CordisRunHostSeam {
/** Start a new Host activation or attach this page to an existing one. */
runHostHalf(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
packageId: CordisDynamicPackageId,
mode: CordisDynamicRunMode,
requestId: ApprovalRequestId | null,
approveFutureVersions: boolean,
): Promise<DynamicCordisHostHalfResult>
/** Fetch Client source for one exact active run. */
getClientCode(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
): Promise<DynamicCordisClientSource>
/** Settle a model-driven approval. */
resolveRequestRun(
requestId: ApprovalRequestId,
resolution: DynamicCordisRunResolution,
): Promise<DynamicCordisResolveAck>
/** Settle a direct panel activation after this page handles its Client half. */
settleUserRun(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
resolution: DynamicCordisRunResolution,
): Promise<DynamicCordisRunResponse>
}
/** Dependencies of one page's orchestrator. */
export interface CordisRunOrchestratorEnv {
/** Page-local Client loader. */
runner: DynamicCordisPackageRunner
/** Folded Host RPC operations. */
host: CordisRunHostSeam
}
/** Forwarded approval request fields used by this page. */
export interface CordisRunRequest {
requestId: ApprovalRequestId
agentId: SessionId
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
mode: CordisDynamicRunMode
name: string
purpose: string
requiresApproval: boolean
}
/** Direct panel activation request. */
export interface CordisUserRunRequest {
agentId: SessionId
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
mode: CordisDynamicRunMode
/** Host-only Packages finish without a Client load or settlement call. */
hasClientHalf: boolean
}
interface RunPlan extends CordisUserRunRequest {
requestId?: ApprovalRequestId
approveFutureVersions?: boolean
}
/** Drives Host → Client activation and publishes Plugin-keyed activity. */
export class CordisRunOrchestrator {
private readonly requests = new Map<ApprovalRequestId, CordisRunRequest>()
private readonly activity = new Map<CordisDynamicPluginId, CordisRunActivity>()
private readonly failures = new Map<CordisDynamicPluginId, CordisRunFailure>()
private readonly inFlight = new Map<CordisDynamicPluginId, Promise<void>>()
private readonly listeners = new Set<() => void>()
private activityCache: ReadonlyMap<CordisDynamicPluginId, CordisRunActivity> | undefined
private failureCache: ReadonlyMap<CordisDynamicPluginId, CordisRunFailure> | undefined
/** @param env - Client loader and folded Host operations. */
constructor(private readonly env: CordisRunOrchestratorEnv) {}
/** Open approvals and current activation attempts, keyed by stable Plugin ID. */
readonly activeRuns: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunActivity>> = {
getSnapshot: () => this.activityCache ??= new Map(this.activity),
subscribe: fn => this.observe(fn),
}
/** Latest page-side activation failure for each Plugin. */
readonly lastRunError: CordisObservable<ReadonlyMap<CordisDynamicPluginId, CordisRunFailure>> = {
getSnapshot: () => this.failureCache ??= new Map(this.failures),
subscribe: fn => this.observe(fn),
}
/**
* Register a Client activation request, starting it immediately when the Plugin is already authorized.
* @param request - forwarded approval and activation metadata.
*/
open(request: CordisRunRequest): void {
this.requests.set(request.requestId, request)
if (!request.requiresApproval) {
void this.orchestrate({
agentId: request.agentId,
pluginId: request.pluginId,
packageId: request.packageId,
mode: request.mode,
requestId: request.requestId,
hasClientHalf: true,
}).catch((error: unknown) => {
console.error(`[cordis-client-runner] automatic activation ${request.requestId} failed:`, error)
})
return
}
if (this.activity.get(request.pluginId)?.phase !== 'orchestrating') {
this.activity.set(request.pluginId, {
phase: 'awaiting-approval',
requestId: request.requestId,
agentId: request.agentId,
packageId: request.packageId,
mode: request.mode,
name: request.name,
purpose: request.purpose,
})
}
this.commit()
}
/**
* Rebuild pending approvals and automatic Client activations from an authoritative Host inventory read.
* @param rows - complete process-wide Plugin inventory.
*/
reconcileApprovals(rows: readonly DynamicCordisInventoryRow[]): void {
const expected = new Map<ApprovalRequestId, CordisRunRequest>()
for (const row of rows) {
const attempt = row.latestRun
if (attempt?.approvalRequestId === undefined
|| (attempt.status !== 'awaiting-approval'
&& attempt.status !== 'starting-host'
&& attempt.status !== 'client-pending')) continue
const pkg = row.packages.find(candidate => candidate.packageId === attempt.packageId)
if (pkg === undefined) continue
expected.set(attempt.approvalRequestId, {
requestId: attempt.approvalRequestId,
agentId: row.agentId,
pluginId: row.pluginId,
packageId: attempt.packageId,
mode: attempt.mode,
name: pkg.name,
purpose: pkg.purpose,
requiresApproval: attempt.requiresApproval ?? attempt.status === 'awaiting-approval',
})
}
let changed = false
for (const [requestId, request] of [...this.requests]) {
if (expected.has(requestId)) continue
this.requests.delete(requestId)
const current = this.activity.get(request.pluginId)
if (current?.phase === 'awaiting-approval' && current.requestId === requestId) {
this.activity.delete(request.pluginId)
}
changed = true
}
for (const [requestId, request] of expected) {
const previous = this.requests.get(requestId)
const current = this.activity.get(request.pluginId)
if (!request.requiresApproval && current?.phase === 'orchestrating') continue
if (request.requiresApproval
&& sameRequest(previous, request)
&& current?.phase === 'awaiting-approval'
&& current.requestId === requestId) continue
if (!request.requiresApproval) {
this.open(request)
changed = true
continue
}
this.requests.set(requestId, request)
if (current?.phase !== 'orchestrating') {
this.activity.set(request.pluginId, {
phase: 'awaiting-approval',
requestId,
agentId: request.agentId,
packageId: request.packageId,
mode: request.mode,
name: request.name,
purpose: request.purpose,
})
}
changed = true
}
if (changed) this.commit()
}
/**
* Close an approval settled by another page or by cancellation.
* @param requestId - approval request that can no longer be answered here.
*/
close(requestId: ApprovalRequestId): void {
const request = this.requests.get(requestId)
if (request === undefined) return
this.requests.delete(requestId)
const current = this.activity.get(request.pluginId)
if (current?.phase === 'awaiting-approval' && current.requestId === requestId) {
this.activity.delete(request.pluginId)
}
this.commit()
}
/**
* Approve and execute one still-open model request.
* @param requestId - approval request to execute.
* @param approveFutureVersions - whether this approval covers later Packages for the same Plugin.
*/
approve(requestId: ApprovalRequestId, approveFutureVersions: boolean): Promise<void> {
const request = this.requests.get(requestId)
if (request === undefined || !request.requiresApproval) return Promise.resolve()
return this.orchestrate({
agentId: request.agentId,
pluginId: request.pluginId,
packageId: request.packageId,
mode: request.mode,
requestId,
approveFutureVersions,
hasClientHalf: true,
})
}
/**
* Reject one still-open model request without executing either half.
* @param requestId - approval request to reject.
*/
async decline(requestId: ApprovalRequestId): Promise<void> {
const request = this.requests.get(requestId)
if (request === undefined || !request.requiresApproval) return
const current = this.activity.get(request.pluginId)
if (current?.phase !== 'awaiting-approval' || current.requestId !== requestId) return
this.requests.delete(requestId)
this.activity.delete(request.pluginId)
this.commit()
await this.answer(requestId, { ok: false, reason: 'rejected' })
}
/**
* Execute a direct panel run; the user gesture itself authorizes it.
* @param request - exact Package activation selected by the user.
*/
startUserRun(request: CordisUserRunRequest): Promise<void> {
return this.orchestrate(request)
}
private observe(fn: () => void): () => void {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
private commit(): void {
this.activityCache = undefined
this.failureCache = undefined
for (const fn of [...this.listeners]) fn()
}
private orchestrate(plan: RunPlan): Promise<void> {
const running = this.inFlight.get(plan.pluginId)
if (running !== undefined) return running
this.activity.set(plan.pluginId, {
phase: 'orchestrating',
agentId: plan.agentId,
packageId: plan.packageId,
mode: plan.mode,
})
this.failures.delete(plan.pluginId)
if (plan.requestId !== undefined) this.requests.delete(plan.requestId)
this.commit()
const attempt = this.drive(plan).finally(() => {
this.inFlight.delete(plan.pluginId)
this.activity.delete(plan.pluginId)
this.commit()
})
this.inFlight.set(plan.pluginId, attempt)
return attempt
}
private async drive(plan: RunPlan): Promise<void> {
const started = await this.startHost(plan)
if (!started.ok) {
this.fail(plan, 'host-half-failed', started)
if (plan.requestId !== undefined) {
await this.answer(plan.requestId, { ...started, reason: 'host-half-failed' })
}
return
}
if (!plan.hasClientHalf) return
let source: DynamicCordisClientSource
try {
source = await this.env.host.getClientCode(plan.agentId, plan.pluginId, started.pluginRunId)
} catch (error) {
await this.finishClientFailure(plan, started.pluginRunId, started.startedHere, errorDetails(error), error)
return
}
const loaded = await this.env.runner.load({
pluginId: source.pluginId,
packageId: source.packageId,
pluginRunId: source.pluginRunId,
agentId: plan.agentId,
name: source.name,
code: source.code,
}).catch((error: unknown) => ({ ok: false, cause: 'evaluate', ...errorDetails(error), error }) as const)
if (!loaded.ok) {
await this.finishClientFailure(
plan,
started.pluginRunId,
started.startedHere,
{
message: `${loaded.cause}: ${loaded.message}`,
...loaded.stack === undefined ? {} : { stack: loaded.stack },
},
loaded.error,
)
return
}
const resolution: DynamicCordisRunResolution = {
ok: true,
pluginRunId: loaded.pluginRunId,
...loaded.waitingFor === undefined ? {} : { waitingFor: loaded.waitingFor },
}
if (plan.requestId !== undefined) {
await this.answer(plan.requestId, resolution)
return
}
await this.settleDirect(plan, resolution)
}
private async startHost(plan: RunPlan): Promise<DynamicCordisHostHalfResult> {
try {
return await this.env.host.runHostHalf(
plan.agentId,
plan.pluginId,
plan.packageId,
plan.mode,
plan.requestId ?? null,
plan.approveFutureVersions ?? false,
)
} catch (error) {
return { ok: false, ...errorDetails(error) }
}
}
private async finishClientFailure(
plan: RunPlan,
pluginRunId: CordisDynamicPluginRunId,
startedHere: boolean,
failure: CordisErrorDetails,
originalError?: unknown,
): Promise<void> {
console.error(
`[cordis-client-runner] Client activation ${plan.pluginId}/${plan.packageId} (${pluginRunId}) failed:`,
originalError ?? failure,
)
this.fail(plan, 'client-half-failed', failure)
const resolution: DynamicCordisRunResolution = {
ok: false,
reason: 'client-half-failed',
pluginRunId,
startedHere,
...failure,
}
if (plan.requestId !== undefined) await this.answer(plan.requestId, resolution)
else await this.settleDirect(plan, resolution)
}
private async settleDirect(plan: RunPlan, resolution: DynamicCordisRunResolution): Promise<void> {
try {
const response = await this.env.host.settleUserRun(plan.agentId, plan.pluginId, resolution)
if (!response.ok) this.fail(plan, 'client-half-failed', response)
} catch (error) {
this.fail(plan, 'client-half-failed', errorDetails(error))
}
}
private async answer(requestId: ApprovalRequestId, resolution: DynamicCordisRunResolution): Promise<void> {
try {
await this.env.host.resolveRequestRun(requestId, resolution)
} catch (error) {
console.error(`[cordis-client-runner] answering run request ${requestId} failed:`, error)
}
}
private fail(
plan: Pick<RunPlan, 'pluginId' | 'packageId'>,
reason: CordisRunFailure['reason'],
failure: CordisErrorDetails,
): void {
this.failures.set(plan.pluginId, { packageId: plan.packageId, reason, ...failure })
this.commit()
}
}
function sameRequest(left: CordisRunRequest | undefined, right: CordisRunRequest): boolean {
return left?.requestId === right.requestId
&& left.agentId === right.agentId
&& left.pluginId === right.pluginId
&& left.packageId === right.packageId
&& left.mode === right.mode
&& left.name === right.name
&& left.purpose === right.purpose
&& left.requiresApproval === right.requiresApproval
}

View File

@@ -0,0 +1,245 @@
/** Built-in Client inspect providers over live Client-owned services. */
import type { Context } from '@deepseek-ai/cordis'
import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client'
import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
import { queryEventApi, queryServiceApi } from './api-catalog.ts'
import type { ClientCordisInspectProviderRegistration } from './inspect-registry.ts'
import { CLIENT_SLOT_API } from './slot-catalog.ts'
import type { ClientSlotEntry } from './slot-catalog.ts'
/* jscpd:ignore-start */
const EMPTY_INPUT = { type: 'object', properties: {}, additionalProperties: false } as const
const ANY_OUTPUT = { description: 'JSON data owned by this inspect provider.' } as const
const SERVICE_INPUT = exactInput('service', 'Exact Service key. Omit it for the compact Service and method-signature directory.')
const EVENT_INPUT = exactInput('event', 'Exact Event name. Omit it for the compact Event and listener-signature directory.')
const SERVICE_OUTPUT = {
description: 'Compact Service directory, or one exact Service contract with only its referenced type declarations.',
} as const
const EVENT_OUTPUT = {
description: 'Compact Event directory, or one exact Event contract with only its referenced type declarations.',
} as const
/* jscpd:ignore-end */
const SUBTREE_OUTPUT = {
description: 'Compact purpose/topology trees. With root, selected also contains that Slot\'s full contract and live occupants.',
} as const
const SUBTREE_INPUT = {
type: 'object',
properties: {
root: {
type: 'string',
description: 'Exact live Slot key. When supplied, selected contains the full contract for this Slot.',
},
},
additionalProperties: false,
} as const
/** Exact Client closure symbols exposed by the evaluator and guard. */
export const CLIENT_BUILTIN_INSPECTION: readonly JsonValue[] = [
{
name: 'ctx',
description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject only for hard dependencies.',
signatures: [
'ctx.get(name: string): unknown | undefined',
'ctx.on(name: string, listener: Function): () => void',
'ctx.provide(name: string, value: unknown): () => void',
'ctx.effect(callback: Function, label?: string): () => void',
],
},
{
name: 'React',
description: 'React runtime exposed without JSX transformation.',
signatures: ['React.createElement(type, props, ...children): ReactElement', 'React.useState(initial)', 'React.useEffect(effect, deps)'],
},
{
name: 'host',
description: 'Package-private JSON RPC from Client to this Package\'s Host half.',
signatures: ['host.call(method: string, args?: JsonValue): Promise<JsonValue>'],
},
{
name: 'styles',
description: 'Package-owned stylesheet insertion cleaned up with the Client run.',
signatures: ['styles.insert(css: string): () => void'],
},
{
name: 'console',
description: 'Package-tagged browser logging.',
signatures: ['console.log(...values): void', 'console.error(...values): void'],
},
]
/**
* Construct the first-party Client provider registrations.
* @param ctx - Client context used for live Service-backed queries.
* @returns registrations for static catalogs and live Client capabilities.
*/
export function clientInspectProviders(ctx: Context): ClientCordisInspectProviderRegistration[] {
return [
registration(
'Service',
'Progressive Client Service discovery: compact capability/signature directory, then one exact coding contract.',
'listService',
input => queryServiceApi(readExact(input, 'service')) as unknown as JsonValue,
SERVICE_INPUT,
SERVICE_OUTPUT,
),
registration(
'Event',
'Progressive Client Event discovery: compact listener directory, then one exact event contract.',
'listEvents',
input => queryEventApi(readExact(input, 'event')) as unknown as JsonValue,
EVENT_INPUT,
EVENT_OUTPUT,
),
registration('Builtin', 'Plain-JavaScript symbols available to a dynamic Client half.', 'listBuiltins', () => ({
builtins: [...CLIENT_BUILTIN_INSPECTION],
referencedTypes: [],
})),
{
manifest: {
id: 'Slots',
description: 'Progressive live Slot inspection: compact purpose/topology trees plus one exact Slot contract.',
methods: [{
name: 'listSubTree',
description: 'Return compact live Slot trees for navigation. With root, also return the selected Slot\'s full contract and occupants.',
inputSchema: SUBTREE_INPUT,
outputSchema: SUBTREE_OUTPUT,
}],
},
query(method, input) {
if (method !== 'listSubTree') throw new Error(`unknown Slots inspect method "${method}"`)
const slots = ctx.get('slots')
if (slots === undefined) throw new Error('Client Slots service is not running')
const root = typeof input === 'object' && input !== null && !Array.isArray(input)
&& typeof input.root === 'string' ? input.root : undefined
const trees = slots.snapshot(root)
const selected = trees[0]
return Promise.resolve({
...root === undefined ? {} : { requestedRoot: { name: root, available: trees.length > 0 } },
trees: trees.map(compactSlotTree),
...root === undefined || selected === undefined ? {} : { selected: inspectLiveSlot(selected) },
referencedTypes: [],
})
},
},
registration('Theme', 'Current theme token names and light/dark override requirements.', 'listTokens', () => {
const theme = ctx.get('theme')
if (theme === undefined) throw new Error('Client Theme service is not running')
return { tokens: theme.exportInspectTokens(), referencedTypes: [] } as unknown as JsonValue
}),
]
}
/* jscpd:ignore-start */
function registration(
id: string,
description: string,
method: string,
query: (input: JsonValue | undefined) => JsonValue | Promise<JsonValue>,
inputSchema: JsonValue = EMPTY_INPUT,
outputSchema: JsonValue = ANY_OUTPUT,
): ClientCordisInspectProviderRegistration {
return {
manifest: {
id,
description,
methods: [{
name: method,
description,
inputSchema,
outputSchema,
}],
},
async query(requested, input) {
if (requested !== method) throw new Error(`unknown ${id} inspect method "${requested}"`)
return await query(input)
},
}
}
function exactInput(field: string, description: string): JsonValue {
return { type: 'object', properties: { [field]: { type: 'string', description } }, additionalProperties: false }
}
function readExact(input: JsonValue | undefined, field: string): string | undefined {
if (input === undefined || input === null || Array.isArray(input) || typeof input !== 'object') return undefined
const value = input[field]
return typeof value === 'string' ? value : undefined
}
/* jscpd:ignore-end */
type LiveSlotNode = ReturnType<SlotRegistry['snapshot']>[number]
const SLOT_CATALOG = new Map(CLIENT_SLOT_API.map(entry => [entry.key, entry]))
const GUARDED_SLOT_KEYS = new Map<string, {
description: string
values: readonly { value: string; description: string }[]
}>([
['tool.view.cordis', {
description: 'fixed by the dynamic Client Guard',
values: [{
value: 'self',
description: 'The only accepted key. The Guard binds it to this Package\'s pluginId and packageId.',
}],
}],
])
function compactSlotTree(node: LiveSlotNode): JsonValue {
const catalog = SLOT_CATALOG.get(node.name)
const guardedKeys = catalog === undefined ? undefined : GUARDED_SLOT_KEYS.get(catalog.key)
return {
name: node.name,
kind: node.kind,
scope: node.scope,
...catalog === undefined ? {} : {
purpose: catalog.summary,
replaceRisk: catalog.replaceRisk,
...catalog.registerOptions.length === 0 ? {} : {
registration: catalog.registerOptions.map(option => ({
name: option.name,
type: option.type,
required: option.requirement === 'required',
})),
},
...catalog.keyDomain === '' ? {} : {
keyDomain: guardedKeys?.description ?? catalog.keyDomain,
...guardedKeys === undefined ? {} : { allowedKeys: guardedKeys.values.map(value => ({ ...value })) },
},
},
children: node.children.map(compactSlotTree),
}
}
function inspectLiveSlot(node: LiveSlotNode): JsonValue {
const catalog = SLOT_CATALOG.get(node.name)
return {
name: node.name,
kind: node.kind,
scope: node.scope,
...node.declaredBy === undefined ? {} : { declaredBy: node.declaredBy },
occupants: node.occupants.map(occupant => ({ ...occupant })),
...catalog === undefined ? {} : { catalog: inspectSlotCatalog(catalog) },
}
}
function inspectSlotCatalog(entry: ClientSlotEntry): JsonValue {
const guardedKeys = GUARDED_SLOT_KEYS.get(entry.key)
return {
description: entry.doc,
registration: entry.registerOptions.map(option => ({
name: option.name,
type: option.type,
required: option.requirement === 'required',
description: option.doc,
})),
ownerProps: [...entry.ownerProps],
ownerPropsReferences: [...entry.ownerPropsReferences],
standardProps: [...entry.standardProps],
keyDomain: guardedKeys?.description ?? entry.keyDomain,
...guardedKeys === undefined ? {} : { allowedKeys: guardedKeys.values.map(value => ({ ...value })) },
hookContext: entry.hookContext,
slotInject: entry.slotInject,
replaceRisk: entry.replaceRisk,
}
}

View File

@@ -0,0 +1,507 @@
/**
* Per-package browser lifecycle: evaluate the closure, wrap `apply` in the guard
* facade, seat a ready-made factory in the module table, and create a loader
* entry — so dynamic packages ride the exact machinery static plugins do
* (activation gating on inject, fiber-effect cleanup, status projection). Unload
* = loader entry removal (fiber disposal cascades slot entries and facade
* effects) + factory invalidation + style removal.
*
* The engine answers its caller: `load` resolves with what this page ended up
* with, which is what the run orchestration reports back to the host. Loads
* converge by Plugin Run ID against live state, not history: loading the exact
* activation this page already runs is a no-op that still answers, another run
* replaces it, and the same Package after a retract loads afresh. Per-Plugin
* serialization keeps a second request from interleaving with one in flight.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
import type {
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, DynamicCordisPackage,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
import type { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { DynamicCordisStyles, evaluateClientHalf, DYNAMIC_CLIENT_REDIRECTS } from './evaluator.ts'
import type { DynamicCordisEvaluatedPlugin } from './evaluator.ts'
import { dynamicCordisContext } from './guard.ts'
import type { DynamicCordisSlotLedgerRow } from './guard.ts'
/**
* Snapshot source a surface can subscribe to (the render seam's observable
* shape). Lives here because both this engine and the run orchestration publish
* through it, and the orchestration already depends on this module.
*/
export interface CordisObservable<T> {
/** Current value; the reference is stable between mutations. */
getSnapshot(): T
/**
* Observe mutations.
* @param fn - notified after each committed change.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
}
/** Which stage of a load failed, as the page classified it. */
export type DynamicCordisLoadErrorCause = 'evaluate' | 'module-import' | 'activate'
/** Error fields retained by the page runner and Host transport. */
export interface CordisErrorDetails {
/** Original error message. */
message: string
/** Original stack when the thrown value supplied one. */
stack?: string
}
/** One package's browser half as the host handed it over. */
export interface DynamicCordisClientHalf {
/** Stable Plugin instance. */
pluginId: CordisDynamicPluginId
/** Immutable Package source version. */
packageId: CordisDynamicPackageId
/** Exact activation. */
pluginRunId: CordisDynamicPluginRunId
/** Session the run is carried out for; a later render failure is reported under it. */
agentId: SessionId
/** Label from the define call; also the plugin name. */
name: string
/** Browser-half source: an async function body returning a plugin. */
code: string
}
/**
* One render-time crash of a dynamic package's slot entry, as this page reports
* it. Post-settle diagnosis only: the run it belongs to was answered long before
* (a package that crashes while rendering loaded successfully), so this never
* reaches a run resolution.
*/
export interface DynamicCordisRenderFailure {
/** Slot key the crashed entry rendered under. */
slot: string
/** What the author has to read to fix it: the crash text, plus a redirect when it names a withheld global. */
message: string
/** Original render failure stack when available. */
stack?: string
/** Whether the crash retired the entry from its cell — the package's UI is gone, not merely broken. */
abdicated: boolean
}
/**
* What this page ended up with. A parked package is a success — the browser half
* settled and waits on declared services this page has not got.
*/
export type DynamicCordisLoadResult =
| { ok: true; pluginRunId: CordisDynamicPluginRunId; waitingFor?: string[] }
| ({ ok: false; cause: DynamicCordisLoadErrorCause; error?: unknown } & CordisErrorDetails)
/** The `window.__ModuleLoader__` registration sink (client-modules contract C6). */
interface ModuleLoaderSink {
__ModuleLoader__?: {
load(handoff: { id: string; factory: (require: (spec: string) => unknown) => unknown }): void
}
}
/** One live package's bookkeeping. */
interface LivePackage {
pkg: DynamicCordisPackage
entryId: string
styles: DynamicCordisStyles
ledger: DynamicCordisSlotLedgerRow[]
/** Services the browser half declared and this page has not got (parked, still a success). */
waitingFor: string[]
}
/** Runner dependencies, resolved by the plugin entry at activation. */
export interface DynamicCordisRunnerEnv {
/** The client root context (service reads and the guard's fiber owner). */
ctx: Context
/** Client cordis Loader: dynamic packages become entries under it. */
loader: Loader
/** Module table, for factory invalidation before every (re-)registration. */
modules: ClientModuleSystem
/** Slot registry, for the entry-crash supervision seam. */
slots: SlotRegistry
/** Route one `host.call` to the package's host half through the Remote namespace. */
invoke(
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
method: string,
args: unknown,
): Promise<unknown>
/**
* Send one render-time crash back to the session that authored the package.
* Fire-and-forget by contract: the crash already happened, and a failed report
* must not become a second failure.
* @param agentId - session the crashed package was run for.
* @param id - the crashed package.
* @param failure - slot, teaching text, and whether the entry was retired.
*/
reportRenderFailure(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
failure: DynamicCordisRenderFailure,
): void
/** Send one post-activation Client guard rejection to the owning Agent. */
reportGuardFailure(
agentId: SessionId,
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
failure: CordisErrorDetails,
): void
}
/** Module-table id of one package (also its loader entry name and fiber name). */
function moduleIdOf(id: CordisDynamicPluginId): string {
return `dyn/${id}`
}
/** One live package's contribution summary in this page. */
export interface DynamicCordisLivePackage {
/** Stable Plugin instance. */
pluginId: CordisDynamicPluginId
/** Immutable Package source version. */
packageId: CordisDynamicPackageId
/** Exact activation loaded in this page. */
pluginRunId: CordisDynamicPluginRunId
/** Label from the define call. */
name: string
/** Slot names this package registered into here. */
slots: string[]
/** Live injected-style tag count. */
styleCount: number
}
/** The browser-side load engine for dynamic packages. */
export class DynamicCordisPackageRunner {
private readonly live = new Map<CordisDynamicPluginId, LivePackage>()
/** Serializes load/unload per package id (a second request can outrun a slow load). */
private readonly queues = new Map<CordisDynamicPluginId, Promise<unknown>>()
private readonly changeListeners = new Set<() => void>()
/** Page-local shadowing rank. A later registration receives a lower priority. */
private nextPriority = 0
/**
* Which package seated which component, and for whom. Component identity is the
* only attribution key that holds:
* - the registry stores the component verbatim, so a crashed entry carries its
* own way back — no parallel entry ledger to keep in step;
* - `entry.registrant` is `options.registrant ?? fiber.name` and the facade does
* not strip a package-supplied one, so a package could name itself something
* else — attributing by it would let a package impersonate another;
* - the assigned shadowing priority is unique but absent on chain entries (their
* election is deliberately left alone), so it would miss chain crashes;
* - a package torn down between the crash and the report is still attributable,
* because this index does not depend on the live record.
*
* Two packages cannot collide here: each browser half is evaluated in its own
* closure, so no component object reaches two of them. A collision is only
* possible inside ONE package (the same component seated twice), where both
* entries map to the same id and the value is identical.
*/
private readonly owners = new WeakMap<object, {
pluginId: CordisDynamicPluginId
pluginRunId: CordisDynamicPluginRunId
agentId: SessionId
}>()
/** This page's last render crash per package: what a run surface shows on the row. */
private readonly failures = new Map<CordisDynamicPluginId, DynamicCordisRenderFailure>()
private readonly unwatch: () => void
private snapshotCache: readonly DynamicCordisLivePackage[] | undefined
private failureCache: ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure> | undefined
/** @param env - loader/module/slot wiring plus the two host verbs this engine uses. */
constructor(private readonly env: DynamicCordisRunnerEnv) {
// The supervision seam fires for EVERY entry crash on the page, factory UI
// included; only the ones this runner seated are ours to report.
this.unwatch = env.slots.onEntryError((slot, entry, error, info) => {
const component: unknown = (entry as { component?: unknown }).component
const owner = indexable(component) ? this.owners.get(component) : undefined
if (owner === undefined) return
const details = errorDetails(error)
const failure: DynamicCordisRenderFailure = {
slot,
message: renderFailureMessage(slot, details.message),
...details.stack === undefined ? {} : { stack: details.stack },
abdicated: info.abdicated,
}
// One observation, two outlets with different owners and lifetimes: the host
// keeps the last crash ACROSS pages for the model, this map is what THIS page
// currently shows. Neither is derived from the other.
env.reportRenderFailure(owner.agentId, owner.pluginId, owner.pluginRunId, failure)
this.failures.set(owner.pluginId, failure)
this.notify()
})
}
/**
* Observe live-set changes (the run-state surface's re-render seam).
* @param fn - notified after every converged mutation.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
this.changeListeners.add(fn)
return () => { this.changeListeners.delete(fn) }
}
/**
* This page's last render crash per package, on the same notification channel as
* the live set — a surface that already subscribed learns about a crash without
* a second mechanism to wire.
*/
readonly renderFailures: CordisObservable<ReadonlyMap<CordisDynamicPluginId, DynamicCordisRenderFailure>> = {
getSnapshot: () => this.failureCache ??= new Map(this.failures),
subscribe: fn => this.subscribe(fn),
}
/**
* What this page currently has loaded (stable reference between mutations, so
* it can back a snapshot selector).
* @returns one row per live package.
*/
getSnapshot(): readonly DynamicCordisLivePackage[] {
return this.snapshotCache ??= [...this.live.values()].map(({ pkg, ledger, styles }) => ({
pluginId: pkg.pluginId,
packageId: pkg.packageId,
pluginRunId: pkg.pluginRunId,
name: pkg.name,
slots: [...new Set(ledger.map(row => row.slot))],
styleCount: styles.count,
}))
}
/**
* Whether this page has the browser half loaded — page-local truth, never the
* host's "it is running".
* @param pluginId - stable Plugin identity.
* @returns true while one activation of the Plugin is live here.
*/
isLoaded(pluginId: CordisDynamicPluginId): boolean {
return this.live.has(pluginId)
}
/**
* Load one browser half into this page and answer what happened.
* @param half - source for one exact Host activation.
* @returns the outcome the run orchestration reports to the host.
*/
load(half: DynamicCordisClientHalf): Promise<DynamicCordisLoadResult> {
return this.enqueue(half.pluginId, async () => {
const current = this.live.get(half.pluginId)
if (current !== undefined) {
// Already running this activation here: nothing to load, but the caller
// still needs an answer (a replayed run must not look unacknowledged).
if (current.pkg.pluginRunId === half.pluginRunId) return settled(current)
await this.teardown(current.pkg.pluginId, current.entryId, current.styles)
}
const result = await this.mount(half)
this.notify()
return result
})
}
/**
* Unload one package (`cordis/dynamic-retract`: a stop, or an undefine
* that stops first).
* @param pluginId - stable Plugin identity.
* @param pluginRunId - exact activation being retracted; a newer run survives.
*/
retract(pluginId: CordisDynamicPluginId, pluginRunId: CordisDynamicPluginRunId): void {
void this.enqueue(pluginId, async () => {
const current = this.live.get(pluginId)
if (current === undefined || current.pkg.pluginRunId !== pluginRunId) return
await this.teardown(pluginId, current.entryId, current.styles)
this.notify()
})
}
/** Unload everything (plugin disposal path). */
async dispose(): Promise<void> {
this.unwatch()
for (const current of [...this.live.values()]) {
await this.teardown(current.pkg.pluginId, current.entryId, current.styles)
}
this.notify()
}
private notify(): void {
this.snapshotCache = undefined
this.failureCache = undefined
for (const fn of [...this.changeListeners]) fn()
}
/** Queue one package operation behind that package's previous ones. */
private enqueue<T>(id: CordisDynamicPluginId, op: () => Promise<T>): Promise<T> {
const previous = this.queues.get(id) ?? Promise.resolve()
const next = previous.then(op)
// The queue tail must survive this operation's failure, or one rejection
// would wedge every later operation on the same package.
this.queues.set(id, next.then(() => {}, () => {}))
return next
}
private async mount(half: DynamicCordisClientHalf): Promise<DynamicCordisLoadResult> {
const styles = new DynamicCordisStyles(half.pluginId)
const ledger: DynamicCordisSlotLedgerRow[] = []
let plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
try {
plugin = await evaluateClientHalf(half.pluginId, half.code, {
invoke: (method, args) => this.env.invoke(half.pluginId, half.pluginRunId, method, args),
noteError: (message) => {
// A loaded package's own console.error: a page-local diagnostic with
// no wire carrier (the run round trip settled long before).
console.error(`[cordis-client-runner] ${half.pluginId} logged an error:`, message)
},
}, styles)
} catch (error) {
styles.dispose()
return { ok: false, cause: 'evaluate', ...errorDetails(error), error }
}
const pkg: DynamicCordisPackage = {
pluginId: half.pluginId,
packageId: half.packageId,
pluginRunId: half.pluginRunId,
name: half.name,
}
const surface = this.guardedSurface(pkg, half.agentId, plugin, ledger)
const moduleId = moduleIdOf(half.pluginId)
// Invalidate-then-register keeps re-loading legal: the module table throws
// loudly on a duplicate factory registration.
this.env.modules.invalidate(moduleId)
const sink = (globalThis as ModuleLoaderSink).__ModuleLoader__
if (sink === undefined) {
throw new Error('cordis-client-runner: window.__ModuleLoader__ is missing (booted outside the web shell?)')
}
sink.load({ id: moduleId, factory: () => surface })
const entryId = await this.env.loader.create({ name: moduleId })
const fiber = this.env.loader.resolve(entryId).fiber
if (fiber === undefined) {
await this.teardown(half.pluginId, entryId, styles)
return { ok: false, cause: 'module-import', message: 'module import failed (see the browser console)' }
}
try {
await fiber.await()
} catch (error) {
await this.teardown(half.pluginId, entryId, styles)
return { ok: false, cause: 'activate', ...errorDetails(error), error }
}
// Settled but not active = legal pending on an unsatisfied declaration. The
// record is seated only now, so an error mirrored during `apply` cannot
// claim the package is already live.
const waitingFor = Object.keys(fiber.inject).filter(name => this.env.ctx.get(name) === undefined)
const record: LivePackage = { pkg, entryId, styles, ledger, waitingFor }
this.live.set(half.pluginId, record)
// A fresh load answers for itself: whatever this page last showed as crashed
// is no longer true of what is mounted now.
this.failures.delete(half.pluginId)
return settled(record)
}
/**
* Wrap the evaluated plugin so `apply` sees the guard facade; the surface
* doubles as the module-table module. The plugin's OWN `inject` survives (the
* object form's declaration is the facade's service gate, mirroring the host
* sandbox reading `ctx.fiber.inject`); the function form has no declaration
* site and therefore reaches no service.
*/
private guardedSurface(
pkg: DynamicCordisPackage,
agentId: SessionId,
plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown),
ledger: DynamicCordisSlotLedgerRow[],
): DynamicCordisEvaluatedPlugin {
const claim = (component: unknown): void => {
if (indexable(component)) {
this.owners.set(component, { pluginId: pkg.pluginId, pluginRunId: pkg.pluginRunId, agentId })
}
}
const guarded = (ctx: unknown): Context => dynamicCordisContext(ctx as Context, {
pkg,
ledger,
claim,
allocatePriority: () => --this.nextPriority,
reportFailure: (error) => {
this.env.reportGuardFailure(agentId, pkg.pluginId, pkg.pluginRunId, errorDetails(error))
},
})
if (typeof plugin === 'function') {
return { name: moduleIdOf(pkg.pluginId), apply: (ctx: unknown) => plugin(guarded(ctx)) }
}
return {
...plugin,
name: moduleIdOf(pkg.pluginId),
apply: (ctx: unknown, config?: unknown) => plugin.apply(guarded(ctx), config),
}
}
/**
* Unload one package's contributions. Takes the pieces rather than the record
* because a load can fail before any record is seated.
*/
private async teardown(
id: CordisDynamicPluginId,
entryId: string,
styles: DynamicCordisStyles,
): Promise<void> {
this.live.delete(id)
// Nothing of this package renders here any more, so a crash row would outlive
// the thing it described.
this.failures.delete(id)
// Entry removal disposes the fiber (slot entries and facade effects
// cascade); the factory invalidation makes a later re-load legal.
await this.env.loader.remove(entryId)
this.env.modules.invalidate(moduleIdOf(id))
styles.dispose()
}
}
/** The success answer for a package that is live here, parked or active. */
function settled(record: { pkg: DynamicCordisPackage; waitingFor: string[] }): DynamicCordisLoadResult {
return {
ok: true,
pluginRunId: record.pkg.pluginRunId,
...record.waitingFor.length > 0 ? { waitingFor: record.waitingFor } : {},
}
}
/**
* Whether a component can key the ownership index. Identity is the key, so only
* objects and functions qualify — a package may register anything, and what it
* registered is what a crash report carries back.
* @param component - whatever a package passed as its component.
* @returns true when the value can be indexed by identity.
*/
function indexable(component: unknown): component is object {
return typeof component === 'object' && component !== null || typeof component === 'function'
}
/**
* Preserve error fields for a load result without fabricating a stack.
* @param error - original thrown value.
* @returns its message and original string stack, when present.
*/
/* jscpd:ignore-start */
export function errorDetails(error: unknown): CordisErrorDetails {
if (typeof error !== 'object' || error === null) return { message: String(error) }
const message = 'message' in error && typeof error.message === 'string'
? error.message
: Object.prototype.toString.call(error)
const stack = 'stack' in error && typeof error.stack === 'string' ? error.stack : undefined
return { message, ...stack === undefined ? {} : { stack } }
}
/* jscpd:ignore-end */
/**
* What the authoring session reads about one render crash. The slot says where it
* happened, the crash message says what broke, and a withheld global named in that
* text pulls in its redirect — a package that reached `window.setInterval` around
* the closure trap crashes with the engine's bare message, which teaches nothing.
*/
function renderFailureMessage(slot: string, message: string): string {
const redirect = Object.entries(DYNAMIC_CLIENT_REDIRECTS)
.find(([name, text]) => message.includes(name) && !message.includes(text))?.[1]
return `your entry in slot "${slot}" crashed while React rendered it: ${message}`
+ (redirect === undefined ? '' : `\n${redirect}`)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
/** Browser implementation of the Cordis timer Service. */
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
/*
* The browser Service preserves the vendored Host TimerService's erased callback tuples and arbitrary
* async-iterator return and rejection values, so narrowing these positions would change the public API.
*/
/* oxlint-disable typescript/no-explicit-any -- Exact Host TimerService API compatibility; see above. */
/* oxlint-disable typescript/no-unsafe-argument -- The erased callback tuples pass through unchanged. */
/* oxlint-disable typescript/no-unsafe-assignment -- The erased callback tuples pass through unchanged. */
/* oxlint-disable typescript/no-unsafe-member-access -- The returned wrapper retains its dispose property. */
/* oxlint-disable typescript/no-unsafe-return -- The erased generic return values pass through unchanged. */
/* oxlint-disable typescript/prefer-promise-reject-errors -- Async iterators preserve arbitrary throw reasons. */
declare module '@deepseek-ai/cordis' {
interface Context extends Pick<ClientTimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
/** Browser timer Service used by the mixed-in Context helpers. */
timer: ClientTimerService
}
}
type WithDispose<T> = T & { dispose: () => void }
// These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator
// return/rejection values must pass through without narrowing them to one caller's invocation.
/** Browser timer Service with the same public API as the Host Cordis TimerService. */
export class ClientTimerService extends Service {
/** Register the Service and mix its lifecycle-safe helpers onto Context. */
constructor(ctx: Context) {
super(ctx, 'timer')
ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
}
/**
* Run a callback once through {@link timeout}.
* @param callback - Work to run after the delay.
* @param delay - Delay in milliseconds.
* @returns Disposer that cancels the pending callback early.
* @deprecated Use `ctx.timeout()` instead.
*/
setTimeout(callback: () => void, delay: number): () => void {
return this.timeout(callback, delay)
}
/**
* Run a callback repeatedly through {@link interval}.
* @param callback - Work to run on each tick.
* @param delay - Interval in milliseconds.
* @returns Disposer that stops the interval early.
* @deprecated Use `ctx.interval()` instead.
*/
setInterval(callback: () => void, delay: number): () => void {
return this.interval(callback, delay)
}
/**
* Run a callback once after a delay.
* @param callback - work to run.
* @param delay - delay in milliseconds.
* @returns disposer that cancels the callback.
*/
timeout(callback: () => void, delay: number): () => void
/**
* Wait for a delay.
* @param delay - delay in milliseconds.
* @returns promise resolved after the delay.
*/
timeout(delay: number): Promise<void>
timeout(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
const delay = args[0] as number
if (callback !== undefined) {
const dispose = this.ctx.effect(() => {
const timer = globalThis.setTimeout(() => {
void dispose()
callback()
}, delay)
return () => { globalThis.clearTimeout(timer) }
}, 'ctx.timeout()')
return dispose
}
const { promise, resolve, reject } = Promise.withResolvers<void>()
const dispose = this.ctx.effect(() => {
const timer = globalThis.setTimeout(resolve, delay)
return () => {
globalThis.clearTimeout(timer)
reject(new Error('Context has been disposed'))
}
}, 'ctx.timeout()')
return promise.finally(() => { void dispose() })
}
/**
* Run a callback repeatedly.
* @param callback - work to run on each tick.
* @param delay - interval in milliseconds.
* @returns disposer that stops the interval.
*/
interval(callback: () => void, delay: number): () => void
/**
* Iterate over timer ticks.
* @param delay - interval in milliseconds.
* @returns async iterator of ticks.
*/
interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
interval(...args: any[]): any {
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
const delay = args[0] as number
if (callback !== undefined) {
return this.ctx.effect(() => {
const timer = globalThis.setInterval(callback, delay)
return () => { globalThis.clearInterval(timer) }
}, 'ctx.interval()')
}
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
const dispose = this.ctx.effect(() => {
const timer = globalThis.setInterval(() => {
nextTask?.resolve({ done: false, value: undefined })
}, delay)
return () => {
globalThis.clearInterval(timer)
if (done !== undefined) return
done = { kind: 'throw', reason: new Error('Context has been disposed') }
nextTask?.reject(done.reason)
}
}, 'ctx.interval()')
return {
next: () => {
if (done === undefined) return (nextTask = Promise.withResolvers()).promise
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
return Promise.reject(done.reason)
},
return: (value: any) => {
if (done === undefined) done = { kind: 'return', value }
nextTask?.resolve({ done: true, value })
void dispose()
return Promise.resolve({ done: true, value })
},
throw: (reason: any) => {
if (done === undefined) done = { kind: 'throw', reason }
nextTask?.reject(reason)
void dispose()
return Promise.resolve({ done: true, value: undefined })
},
[Symbol.asyncIterator]() {
return this
},
} satisfies AsyncIterableIterator<void>
}
/** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any {
let timer: number | undefined
const dispose = this.ctx.effect(() => () => {
disposed = true
globalThis.clearTimeout(timer)
}, label)
const wrapper: any = (...args: any[]): void => {
globalThis.clearTimeout(timer)
timer = trigger(args, disposed)
}
wrapper.dispose = dispose
return wrapper
}
/**
* Return a throttled function whose timer is disposed with the calling Fiber.
* @param callback - Function to throttle.
* @param delay - Minimum interval between calls in milliseconds.
* @param noTrailing - Whether to suppress a delayed trailing call.
* @returns Throttled function with an early disposer.
*/
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
let lastCall = -Infinity
const execute = (...args: Parameters<F>): void => {
lastCall = Date.now()
callback(...args)
}
return this.schedule('ctx.throttle()', (args, disposed) => {
const remaining = delay - Date.now() + lastCall
if (remaining <= 0) {
execute(...args as Parameters<F>)
} else if (!disposed) {
return globalThis.setTimeout(execute, remaining, ...args)
}
}, noTrailing)
}
/**
* Return a debounced function whose timer is disposed with the calling Fiber.
* @param callback - Function to debounce.
* @param delay - Quiet period in milliseconds.
* @returns Debounced function with an early disposer.
*/
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
return this.schedule('ctx.debounce()', (args, disposed) => {
if (disposed) return
return globalThis.setTimeout(callback, delay, ...args)
})
}
}
/**
* Install the browser timer Service on one Client composition.
* @param ctx - Client context that owns the Service and mixed-in helpers.
* @returns Nothing after registering the Service.
*/
export function provideClientTimer(ctx: Context): void {
new ClientTimerService(ctx)
}

View File

@@ -0,0 +1,9 @@
/**
* Dynamic-package runner plugin, node half. Pure browser-side capability: the
* empty apply exists so the row appears in the host cordis.yml / Loader, while
* the browser half ships through exports["./client"], discovered from the
* package.json dshClient declaration.
*/
/** Host plugin body — this package contributes nothing host-side. */
export function apply(): void {}

View File

@@ -0,0 +1,33 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-cordis-client-runner`.
* @module @deepseek-ai/dsh-cordis-client-runner/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-cordis-client-runner'
/** Cordis companion plugin name. */
export const name = 'cordis-client-runner-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the owned relation (a live
* Plugin's loader entry exists exactly while one Plugin Run ID is live) is
* browser-only state reachable through the client half's service, which the
* node-plane companion cannot observe. The relation is asserted by the
* package's own load/teardown coverage instead.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,195 @@
/**
* @vitest-environment jsdom
*
* Closure evaluation account: the symbol surface a browser half receives, the
* teaching traps shadowing ambient globals, the parse/return diagnostics, and
* the style bookkeeping whose disposal the runner owns.
*/
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
import {
DynamicCordisStyles,
DYNAMIC_CLIENT_REDIRECTS,
evaluateClientHalf,
isDynamicCordisPlugin,
} from '../src/client/evaluator.ts'
import type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from '../src/client/evaluator.ts'
const ID = 'dyn-1' as CordisDynamicPluginId
function env(overrides: Partial<DynamicCordisClosureEnv> = {}): DynamicCordisClosureEnv {
return {
invoke: () => Promise.resolve(null),
noteError: () => {},
...overrides,
}
}
/** Evaluate one source with fresh style bookkeeping. */
async function run(source: string, closure: DynamicCordisClosureEnv = env()): Promise<{
plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
styles: DynamicCordisStyles
}> {
const styles = new DynamicCordisStyles(ID)
const plugin = await evaluateClientHalf(ID, source, closure, styles)
return { plugin, styles }
}
describe('evaluateClientHalf', () => {
it('returns the object-form plugin and hands the page React instance to the closure', async () => {
const { plugin } = await run(`
if (React.createElement === undefined) throw new Error('React symbol missing')
return { name: 'ignored', inject: ['slots'], apply(ctx) { return React } }
`)
expect(typeof plugin).toBe('object')
const object = plugin as DynamicCordisEvaluatedPlugin
expect(object.inject).toEqual(['slots'])
// Same instance as the page's React: a second copy would break hooks.
expect(object.apply({})).toBe(React)
})
it('accepts the function form', async () => {
const { plugin } = await run('return (ctx) => "applied"')
expect(typeof plugin).toBe('function')
expect((plugin as (ctx: unknown) => unknown)({})).toBe('applied')
})
it('redirects browser timers to the ctx facade', async () => {
for (const timer of ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] as const) {
const { plugin } = await run(`return () => ${timer}(() => {}, 1)`)
expect(() => (plugin as (ctx: unknown) => unknown)({}))
.toThrow(DYNAMIC_CLIENT_REDIRECTS[timer])
}
})
it('redirects fetch to the host half and require to the closure symbols', async () => {
const { plugin: fetcher } = await run('return () => fetch("/x")')
expect(() => (fetcher as (ctx: unknown) => unknown)({})).toThrow(/network belongs to the HOST half/)
const { plugin: importer } = await run('return () => require("react")')
expect(() => (importer as (ctx: unknown) => unknown)({})).toThrow(/React arrives as the `React` closure symbol/)
})
it('teaches the half split on any harness access', async () => {
const { plugin } = await run('return () => harness.handle("m", () => {})')
expect(() => (plugin as (ctx: unknown) => unknown)({}))
.toThrow(/harness\.handle belongs to the HOST half/)
})
it('routes host.call to the runner invoke seam', async () => {
const invoke = vi.fn(() => Promise.resolve({ ok: 1 }))
const { plugin } = await run('return { apply: (ctx) => host.call("ping", { a: 1 }) }', env({ invoke }))
await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual({ ok: 1 })
expect(invoke).toHaveBeenCalledWith('ping', { a: 1 })
})
it('sends null for a host.call written without arguments', async () => {
const invoke = vi.fn(() => Promise.resolve(['fs', 'web']))
// A handler that takes nothing is the natural case ("list the services"), and
// `undefined` is not JSON — so the omission travels as null rather than
// making the wire refuse the call.
const { plugin } = await run('return { apply: (ctx) => host.call("listServices") }', env({ invoke }))
await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual(['fs', 'web'])
expect(invoke).toHaveBeenCalledWith('listServices', null)
})
it('reports a parse failure as a plain-JavaScript teaching error', async () => {
await expect(run('return (')).rejects.toThrow(/client half failed to parse in this browser/)
await expect(run('return (')).rejects.toThrow(/no JSX, no TypeScript/)
})
it('names the missing return, and rejects a non-plugin value', async () => {
await expect(run('const x = 1')).rejects.toThrow(/did you forget `return`/)
await expect(run('return 42')).rejects.toThrow(/must `return` a plugin/)
})
it('propagates a non-syntax construction failure untouched', async () => {
const boom = new TypeError('engine refused')
// The constructor is the only failure seam before evaluation; a
// non-SyntaxError must not be reinterpreted as a source problem.
vi.stubGlobal('Function', function stub(): never { throw boom })
try {
await expect(run('return () => {}')).rejects.toBe(boom)
} finally {
vi.unstubAllGlobals()
}
expect(typeof Function).toBe('function')
})
})
describe('tagged console', () => {
it('mirrors only error lines, and stringifies every argument shape', async () => {
const seen: string[] = []
const closure = env({ noteError: message => seen.push(message) })
const circular: Record<string, unknown> = {}
circular.self = circular
const { plugin } = await run(`
return { apply: (ctx) => {
console.log('quiet')
console.warn('also quiet')
console.error('text', new Error('boom'), { a: 1 }, undefined, ctx.circular)
console.debug('quiet too')
} }
`, closure)
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'debug').mockImplementation(() => {})
;(plugin as DynamicCordisEvaluatedPlugin).apply({ circular })
vi.restoreAllMocks()
expect(seen).toHaveLength(1)
expect(seen[0]).toBe('text boom {"a":1} undefined [unserializable console argument]')
})
it('truncates a long mirrored error', async () => {
const seen: string[] = []
const { plugin } = await run(
'return { apply: () => console.error("x".repeat(900)) }',
env({ noteError: message => seen.push(message) }),
)
vi.spyOn(console, 'error').mockImplementation(() => {})
;(plugin as DynamicCordisEvaluatedPlugin).apply({})
vi.restoreAllMocks()
expect(seen[0]).toHaveLength(500)
})
})
describe('DynamicCordisStyles', () => {
it('stamps ownership, counts live tags, and disposes one tag or all of them', () => {
const styles = new DynamicCordisStyles(ID)
const first = styles.insert('.a { color: red }')
styles.insert('.b { color: blue }')
expect(styles.count).toBe(2)
const tags = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
expect(tags).toHaveLength(2)
expect(tags[0]?.textContent).toBe('.a { color: red }')
first()
expect(styles.count).toBe(1)
expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(1)
styles.dispose()
expect(styles.count).toBe(0)
expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(0)
})
it('rejects a non-string stylesheet', () => {
const styles = new DynamicCordisStyles(ID)
expect(() => styles.insert(42 as unknown as string)).toThrow(/needs a CSS string/)
})
it('exposes styles.insert to the closure', async () => {
const { plugin, styles } = await run('return { apply: () => styles.insert(".c {}") }')
;(plugin as DynamicCordisEvaluatedPlugin).apply({})
expect(styles.count).toBe(1)
styles.dispose()
})
})
describe('isDynamicCordisPlugin', () => {
it('accepts both mountable forms and rejects everything else', () => {
expect(isDynamicCordisPlugin(() => {})).toBe(true)
expect(isDynamicCordisPlugin({ apply: () => {} })).toBe(true)
expect(isDynamicCordisPlugin({})).toBe(false)
expect(isDynamicCordisPlugin(null)).toBe(false)
expect(isDynamicCordisPlugin(42)).toBe(false)
})
})

View File

@@ -0,0 +1,255 @@
/**
* @vitest-environment jsdom
*
* Guard facade account: the whitelist a dynamic plugin's `apply` sees, the
* automatic shadowing priority on the slots seat, the theme seat's pinned
* override source and fiber-owned disposer, and the Context denial that keeps a
* dynamic package from reaching a foreign context. Registrations ride the
* CALLING fiber, so disposing it must remove them (HMR safety).
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type {
CordisDynamicPackageId,
CordisDynamicPluginId,
CordisDynamicPluginRunId,
DynamicCordisPackage,
} from '@deepseek-ai/dsh-api-remotes/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { dynamicCordisContext } from '../src/client/guard.ts'
import type { DynamicCordisSlotLedgerRow } from '../src/client/guard.ts'
const C: FC<object> = () => null
/** The exact running package carried by a Client dispatch. */
function pkg(): DynamicCordisPackage {
return {
pluginId: 'dyn-1' as CordisDynamicPluginId,
packageId: 'pkg-1' as CordisDynamicPackageId,
pluginRunId: 'run-1' as CordisDynamicPluginRunId,
name: 'demo',
}
}
/** Erased facade view: a dynamic package reads services off plain properties. */
type Facade = Record<string, unknown> & { get(name: string): unknown }
interface Bench {
ctx: Context
slots: SlotRegistry
facade: Facade
ledger: DynamicCordisSlotLedgerRow[]
/** Components the facade claimed for the package, in registration order. */
claimed: unknown[]
dispose: () => Promise<void>
overrideTokens: ReturnType<typeof vi.fn>
themeLayerDispose: ReturnType<typeof vi.fn>
}
/**
* Mount a dynamic-plugin fiber declaring `inject`, and capture the facade its
* apply receives (the real product path: the facade wraps the fiber's own ctx).
*/
async function boot(inject: string[], extras: Record<string, unknown> = {}): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(SlotRegistry)
const themeLayerDispose = vi.fn()
const overrideTokens = vi.fn(() => themeLayerDispose)
ctx.reflect.provide('theme', {
overrideTokens,
getTheme: () => ({ preference: 'light' }),
reload: () => Promise.resolve('reloaded'),
revision: 3,
escape: () => new Context(),
escapeLater: () => Promise.resolve(new Context()),
})
for (const [name, value] of Object.entries(extras)) ctx.reflect.provide(name, value)
const ledger: DynamicCordisSlotLedgerRow[] = []
const claimed: unknown[] = []
let nextPriority = 0
let facade: Facade | undefined
const fiber = ctx.plugin({
name: 'dyn/dyn-1',
inject,
apply: (own: Context) => {
facade = dynamicCordisContext(own, {
pkg: pkg(),
ledger,
claim: (component) => { claimed.push(component) },
allocatePriority: () => --nextPriority,
reportFailure: () => {},
}) as unknown as Facade
},
})
await fiber
if (facade === undefined) throw new Error('facade was not captured')
return {
ctx,
slots: ctx.slots,
facade,
ledger,
claimed,
dispose: async () => { await fiber.dispose() },
overrideTokens,
themeLayerDispose,
}
}
describe('facade surface', () => {
it('forwards whitelisted lifecycle verbs to the real ctx', async () => {
const bench = await boot([])
const seen: string[] = []
const on = bench.facade.on as (event: string, listener: (key: string) => void) => void
on('slots/changed', key => seen.push(key))
bench.ctx.emit('slots/changed', 'root')
expect(seen).toEqual(['root'])
})
it('teaches the object form when an existing service was not declared', async () => {
const bench = await boot([])
expect(() => bench.facade.slots).toThrow(/service "slots" is not declared by your plugin/)
expect(() => bench.facade.slots).toThrow(/a plain `function` has no declaration site/)
})
it('withholds framework internals with a teaching list', async () => {
const bench = await boot([])
expect(() => bench.facade.registry).toThrow(/dynamic ctx does not expose "registry"/)
expect(() => bench.facade.registry).toThrow(/any service your returned plugin declared in inject/)
})
it('answers `get` and `has` over the same whitelist, and refuses writes', async () => {
const bench = await boot(['slots'])
expect(typeof bench.facade.get('slots')).toBe('object')
expect('get' in bench.facade).toBe(true)
expect('on' in bench.facade).toBe(true)
expect('slots' in bench.facade).toBe(true)
expect('registry' in bench.facade).toBe(false)
expect(Symbol.iterator in bench.facade).toBe(false)
expect((bench.facade as unknown as Record<symbol, unknown>)[Symbol.iterator]).toBeUndefined()
expect(() => { bench.facade.slots = 1 }).toThrow(/dynamic ctx is read-only/)
})
it('denies a service value or return that is a cordis Context', async () => {
const bench = await boot(['leaky'], {
leaky: { escape: () => new Context(), later: () => Promise.resolve(new Context()), plain: 7 },
})
const leaky = bench.facade.leaky as { escape(): unknown; later(): Promise<unknown>; plain: number }
expect(() => leaky.escape()).toThrow(/returned a cordis Context/)
await expect(leaky.later()).rejects.toThrow(/returned a cordis Context/)
expect(leaky.plain).toBe(7)
})
it('passes a primitive service through untouched', async () => {
const bench = await boot(['flag'], { flag: 'on' })
expect(bench.facade.flag).toBe('on')
})
})
describe('slots seat', () => {
it('assigns a descending shadowing priority per registration and ledgers it', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
slots.register({ name: 'root' }, C)
slots.register({ name: 'root' }, C)
expect(bench.ledger).toEqual([
{ slot: 'root', priority: -1 },
{ slot: 'root', priority: -2 },
])
// Newest-wins ordering is what "registering IS shadowing" means.
const priorities = bench.slots.entries('root').map(entry => entry.options.priority)
expect(priorities).toContain(-1)
expect(priorities).toContain(-2)
})
it('keeps an explicit priority when the target elects its own order', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
const spec = vi.spyOn(bench.slots, 'spec').mockReturnValue({ kind: 'chain', scope: 'root' })
slots.register({ name: 'root', priority: 5 }, C)
spec.mockRestore()
expect(bench.ledger).toEqual([{ slot: 'root', priority: 5 }])
})
it('rejects a malformed register call before touching the registry', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { register(options: unknown, component: unknown): () => void }
expect(() => slots.register(null, C)).toThrow(/needs an options object with a `name`/)
expect(() => slots.register({}, C)).toThrow(/need a string `name`/)
expect(bench.slots.entries('root')).toHaveLength(0)
})
it('forwards non-register slot methods through the generic guard', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as {
register(options: object, component: unknown): () => void
entries(key: string): readonly unknown[]
}
slots.register({ name: 'root' }, C)
expect(slots.entries('root')).toHaveLength(1)
})
it('denies a non-callable slots member that would hand out a context', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { ctx: unknown }
// The service's own ctx is the classic escape route out of the facade.
expect(() => slots.ctx).toThrow(/service "slots" returned a cordis Context/)
})
it('removes its registrations when the calling fiber unloads (HMR safety)', async () => {
const bench = await boot(['slots'])
const slots = bench.facade.slots as { register(options: object, component: unknown): () => void }
slots.register({ name: 'root' }, C)
expect(bench.slots.entries('root')).toHaveLength(1)
await bench.dispose()
expect(bench.slots.entries('root')).toHaveLength(0)
})
})
describe('theme seat', () => {
it('pins the override source to the package id whatever the caller passes', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens: unknown): () => void }
const tokens = { '--dsw-alias-x': { light: '#fff', dark: '#000' } }
theme.overrideTokens('pretend-to-be-someone-else', tokens)
expect(bench.overrideTokens).toHaveBeenCalledWith('dyn-1.pkg-1', tokens)
})
it('teaches the two-argument shape when the token map arrives first', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens?: unknown): () => void }
expect(() => theme.overrideTokens({ '--x': { light: 'a', dark: 'b' } }))
.toThrow(/takes two arguments; source is replaced with your package id/)
expect(bench.overrideTokens).not.toHaveBeenCalled()
})
it('hangs the layer disposer on the fiber while still returning it', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as { overrideTokens(source: unknown, tokens: unknown): () => void }
const handle = theme.overrideTokens('mine', {})
expect(handle).toBe(bench.themeLayerDispose)
expect(bench.themeLayerDispose).not.toHaveBeenCalled()
// Model code cannot be trusted to keep the handle: unload must restore.
await bench.dispose()
expect(bench.themeLayerDispose).toHaveBeenCalledTimes(1)
})
it('forwards other theme methods, including asynchronous ones', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as {
getTheme(): { preference: string }
reload(): Promise<string>
revision: number
}
expect(theme.getTheme().preference).toBe('light')
await expect(theme.reload()).resolves.toBe('reloaded')
expect(theme.revision).toBe(3)
})
it('denies a Context a theme method hands back, synchronously or awaited', async () => {
const bench = await boot(['theme'])
const theme = bench.facade.theme as { escape(): unknown; escapeLater(): Promise<unknown> }
expect(() => theme.escape()).toThrow(/service "theme" returned a cordis Context/)
await expect(theme.escapeLater()).rejects.toThrow(/service "theme" returned a cordis Context/)
})
})

View File

@@ -0,0 +1,460 @@
/**
* Run-orchestration account: the order the halves run in (and what a host-only
* definition skips), what each failure answers the host, and what a surface can
* read while it happens. The host seam and the load engine are stood in, because
* what is under test is the round trip itself — the engine has its own account in
* runner.spec.
*/
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
import { describe, expect, it, vi } from 'vitest'
import type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
DynamicCordisClientSource, DynamicCordisHostHalfResult, DynamicCordisResolveAck,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { CordisRunOrchestrator } from '../src/client/orchestrator.ts'
import type { CordisUserRunRequest } from '../src/client/orchestrator.ts'
import type { DynamicCordisLoadResult, DynamicCordisPackageRunner } from '../src/client/runtime.ts'
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
const RUN = 'run-1' as CordisDynamicPluginRunId
const AGENT = 's-1' as SessionId
const REQ = 'rr-1' as ApprovalRequestId
const HOST_OK: Extract<DynamicCordisHostHalfResult, { ok: true }> = {
ok: true,
pluginId: PLUGIN,
packageId: PACKAGE,
pluginRunId: RUN,
waitingFor: [],
startedHere: true,
}
/** A user's own run of a two-half definition: the host half, then this page's half. */
const DUAL: CordisUserRunRequest = {
agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run', hasClientHalf: true,
}
/** A user's own run of a host-only definition: nothing for this page to load. */
const HOST_ONLY: CordisUserRunRequest = { ...DUAL, hasClientHalf: false }
interface Bench {
orchestrator: CordisRunOrchestrator
host: {
runHostHalf: ReturnType<typeof vi.fn>
getClientCode: ReturnType<typeof vi.fn>
resolveRequestRun: ReturnType<typeof vi.fn>
settleUserRun: ReturnType<typeof vi.fn>
}
load: ReturnType<typeof vi.fn>
/** Resolutions the host received, in order. */
answers: unknown[]
}
function boot(overrides: {
hostHalf?: () => Promise<DynamicCordisHostHalfResult>
clientCode?: () => Promise<DynamicCordisClientSource>
loaded?: () => Promise<DynamicCordisLoadResult>
resolve?: () => Promise<DynamicCordisResolveAck>
} = {}): Bench {
const answers: unknown[] = []
const host = {
runHostHalf: vi.fn(overrides.hostHalf ?? (() => Promise.resolve(HOST_OK))),
getClientCode: vi.fn(overrides.clientCode ?? (() => Promise.resolve({
code: 'return {}', name: 'demo', pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN,
}))),
resolveRequestRun: vi.fn((_requestId: unknown, resolution: unknown) => {
answers.push(resolution)
return (overrides.resolve ?? (() => Promise.resolve({ accepted: true })))()
}),
settleUserRun: vi.fn((_agentId: SessionId, _pluginId: CordisDynamicPluginId, resolution: unknown) =>
Promise.resolve({
ok: true as const,
status: 'running' as const,
pluginId: PLUGIN,
packageId: PACKAGE,
pluginRunId: (resolution as { pluginRunId: CordisDynamicPluginRunId }).pluginRunId,
waitingFor: [],
mode: 'run' as const,
})),
}
const load = vi.fn(overrides.loaded ?? (() => Promise.resolve({ ok: true as const, pluginRunId: RUN })))
const orchestrator = new CordisRunOrchestrator({
runner: { load } as unknown as DynamicCordisPackageRunner,
host,
})
return { orchestrator, host, load, answers }
}
/** Register one request the way the `cordis/request-run` event does. */
function ask(bench: Bench, requestId: ApprovalRequestId = REQ): void {
bench.orchestrator.open({
requestId,
agentId: AGENT,
pluginId: PLUGIN,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'draw a clock',
requiresApproval: true,
})
}
describe('the waiting affordance', () => {
it('publishes failures on their own observable', async () => {
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'nope' }) })
let notified = 0
const unsubscribe = bench.orchestrator.lastRunError.subscribe(() => { notified++ })
const empty = bench.orchestrator.lastRunError.getSnapshot()
expect(bench.orchestrator.lastRunError.getSnapshot()).toBe(empty)
await bench.orchestrator.startUserRun(DUAL)
expect(notified).toBeGreaterThan(0)
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN)?.reason).toBe('host-half-failed')
unsubscribe()
})
it('publishes one activity per definition, carrying the whole ask', () => {
const bench = boot()
ask(bench)
// Everything a surface needs to show and group the row without a registry
// read: the ask names the session, the plugin, and the model's reason.
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'awaiting-approval',
requestId: REQ,
agentId: AGENT,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'draw a clock',
})
})
it('keeps naming the session once the decision is made', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
const running = bench.orchestrator.startUserRun(DUAL)
// A run must not fall out of its session group by advancing past the decision.
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'orchestrating',
agentId: AGENT,
packageId: PACKAGE,
mode: 'run',
})
release()
await running
})
it('keeps a stable snapshot reference between mutations, and notifies on each', () => {
const bench = boot()
let notified = 0
const unsubscribe = bench.orchestrator.activeRuns.subscribe(() => { notified++ })
const empty = bench.orchestrator.activeRuns.getSnapshot()
expect(bench.orchestrator.activeRuns.getSnapshot()).toBe(empty)
ask(bench)
expect(notified).toBe(1)
expect(bench.orchestrator.activeRuns.getSnapshot()).not.toBe(empty)
unsubscribe()
bench.orchestrator.close(REQ)
expect(notified).toBe(1)
})
it('drops only the waiting affordance when the request settles elsewhere', async () => {
const bench = boot()
ask(bench)
bench.orchestrator.close(REQ)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
// Answering a settled request is a no-op, not an error.
await bench.orchestrator.approve(REQ, false)
await bench.orchestrator.decline(REQ)
expect(bench.host.runHostHalf).not.toHaveBeenCalled()
expect(bench.answers).toEqual([])
})
it('leaves an orchestration alone when its own request settles elsewhere', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
ask(bench)
const running = bench.orchestrator.approve(REQ, false)
// The host announced the request settled (this page answered it) — the work
// this page is doing owns its entry until it finishes.
bench.orchestrator.close(REQ)
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
})
release()
await running
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('refuses to decline a request whose definition is already orchestrating', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
const running = bench.orchestrator.startUserRun(DUAL)
const late = 'rr-late-decline' as ApprovalRequestId
ask(bench, late)
await bench.orchestrator.decline(late)
expect(bench.answers).toEqual([]) // the decision was made; a refusal now would contradict it
release()
await running
})
it('ignores a close for a request it never saw', () => {
const bench = boot()
bench.orchestrator.close('rr-unknown' as ApprovalRequestId)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('closes a request whose activity is already gone', async () => {
const bench = boot()
const second = 'rr-second' as ApprovalRequestId
ask(bench)
ask(bench, second) // same definition asked twice: the first keeps the affordance
await bench.orchestrator.approve(REQ, false) // settles and clears the activity
bench.orchestrator.close(second)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('does not downgrade an orchestration to a waiting decision', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
const started = bench.orchestrator.startUserRun(DUAL)
ask(bench, 'rr-late' as ApprovalRequestId)
// A request arriving mid-orchestration must not offer a decision already made.
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
})
release()
await started
})
})
describe('approve', () => {
it('runs the host half first, then loads the browser half, then answers', async () => {
const bench = boot()
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', REQ, false)
expect(bench.host.getClientCode).toHaveBeenCalledWith(AGENT, PLUGIN, RUN)
// The load carries the session too: a crash while React renders it is
// reported back to whoever the run was carried out for.
expect(bench.load).toHaveBeenCalledWith({
pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, agentId: AGENT, name: 'demo', code: 'return {}',
})
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN }])
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('carries the services a parked browser half waits for', async () => {
const bench = boot({ loaded: () => Promise.resolve({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] }) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN, waitingFor: ['absent'] }])
})
it('short-circuits when the host half fails: nothing is fetched or loaded', async () => {
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'vm exploded' }) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.host.getClientCode).not.toHaveBeenCalled()
expect(bench.load).not.toHaveBeenCalled()
expect(bench.answers).toEqual([{ ok: false, reason: 'host-half-failed', message: 'vm exploded' }])
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'vm exploded' })
})
it('folds a transport rejection of the host verb into its own failure shape', async () => {
const bench = boot({ hostHalf: () => Promise.reject(new Error('socket closed')) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.answers).toEqual([{
ok: false,
reason: 'host-half-failed',
message: 'socket closed',
stack: expect.any(String),
}])
})
it('reports a source fetch that failed as the browser half failing', async () => {
const bench = boot({ clientCode: () => Promise.reject(new Error('definition vanished')) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.load).not.toHaveBeenCalled()
expect(bench.answers).toEqual([{
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true,
message: 'definition vanished', stack: expect.any(String),
}])
})
it('carries the failing load stage into the answer', async () => {
const bench = boot({ loaded: () => Promise.resolve({ ok: false, cause: 'activate', message: 'apply threw' }) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.answers).toEqual([{
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true, message: 'activate: apply threw',
}])
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'client-half-failed', message: 'activate: apply threw' })
})
it('treats a load that rejects outright as a browser-half failure', async () => {
const bench = boot({ loaded: () => Promise.reject(new Error('module table missing')) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.answers).toEqual([{
ok: false, reason: 'client-half-failed', pluginRunId: RUN, startedHere: true,
message: 'evaluate: module table missing', stack: expect.any(String),
}])
})
it('joins a second approve into the orchestration already in flight', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
ask(bench)
const first = bench.orchestrator.approve(REQ, false)
const second = bench.orchestrator.approve(REQ, false)
release()
await Promise.all([first, second])
expect(bench.host.runHostHalf).toHaveBeenCalledTimes(1)
expect(bench.answers).toHaveLength(1)
})
it('logs an answer the host refused, and settles anyway', async () => {
const bench = boot({ resolve: () => Promise.reject(new Error('stream gone')) })
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
ask(bench)
await bench.orchestrator.approve(REQ, false)
const complaints = logged.mock.calls.filter(call => String(call[0]).includes('answering run request'))
logged.mockRestore()
expect(complaints).toHaveLength(1)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('clears a previous failure when the same definition is tried again', async () => {
const outcomes: DynamicCordisLoadResult[] = [
{ ok: false, cause: 'activate', message: 'first try' },
{ ok: true, pluginRunId: RUN },
]
const bench = boot({ loaded: () => Promise.resolve(outcomes.shift() ?? { ok: true, pluginRunId: RUN }) })
ask(bench)
await bench.orchestrator.approve(REQ, false)
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(1)
await bench.orchestrator.startUserRun(DUAL)
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
})
})
describe('decline', () => {
it('answers rejected without touching either half', async () => {
const bench = boot()
ask(bench)
await bench.orchestrator.decline(REQ)
expect(bench.host.runHostHalf).not.toHaveBeenCalled()
expect(bench.load).not.toHaveBeenCalled()
expect(bench.answers).toEqual([{ ok: false, reason: 'rejected' }])
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
// A refusal is not this page failing.
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
})
it('is a no-op once the decision was already made', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
ask(bench)
const running = bench.orchestrator.approve(REQ, false)
await bench.orchestrator.decline(REQ)
expect(bench.answers).toEqual([])
release()
await running
expect(bench.answers).toEqual([{ ok: true, pluginRunId: RUN }])
})
it('ignores an unknown request', async () => {
const bench = boot()
await bench.orchestrator.decline('rr-unknown' as ApprovalRequestId)
expect(bench.answers).toEqual([])
})
})
describe('startUserRun', () => {
it('orchestrates both halves with nothing to answer', async () => {
const bench = boot()
await bench.orchestrator.startUserRun(DUAL)
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', null, false)
expect(bench.load).toHaveBeenCalledTimes(1)
// No request was asked, so there is no blocked tool call to settle.
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('records its own failure for the surface to show', async () => {
const bench = boot({ hostHalf: () => Promise.resolve({ ok: false, message: 'no definition' }) })
await bench.orchestrator.startUserRun(DUAL)
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'no definition' })
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
})
it('records a source fetch failure with nothing to answer', async () => {
const bench = boot({ clientCode: () => Promise.reject(new Error('gone')) })
await bench.orchestrator.startUserRun(DUAL)
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({
packageId: PACKAGE,
reason: 'client-half-failed',
message: 'gone',
stack: expect.any(String),
})
})
it('records a load failure, stringifying a non-Error rejection', async () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the case under test
const bench = boot({ loaded: () => Promise.reject('plain rejection') })
await bench.orchestrator.startUserRun(DUAL)
expect(bench.host.resolveRequestRun).not.toHaveBeenCalled()
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'client-half-failed', message: 'evaluate: plain rejection' })
})
it('is idempotent per definition while one attempt is in flight', async () => {
let release = (): void => {}
const bench = boot({ hostHalf: () => new Promise((resolve) => { release = (): void => { resolve(HOST_OK) } }) })
const first = bench.orchestrator.startUserRun(DUAL)
const second = bench.orchestrator.startUserRun(DUAL)
release()
await Promise.all([first, second])
expect(bench.host.runHostHalf).toHaveBeenCalledTimes(1)
})
it('brings a host-only definition up without fetching or loading anything', async () => {
const bench = boot()
await bench.orchestrator.startUserRun(HOST_ONLY)
expect(bench.host.runHostHalf).toHaveBeenCalledWith(AGENT, PLUGIN, PACKAGE, 'run', null, false)
// There is no second half: asking for source that does not exist would be a
// mistake, and folding its error into `client-half-failed` would report a run
// that succeeded as a failure of a half the definition never had.
expect(bench.host.getClientCode).not.toHaveBeenCalled()
expect(bench.load).not.toHaveBeenCalled()
expect(bench.orchestrator.lastRunError.getSnapshot().size).toBe(0)
expect(bench.orchestrator.activeRuns.getSnapshot().size).toBe(0)
})
it('publishes a host-only run while it is in flight, and its own failure', async () => {
let release = (): void => {}
const bench = boot({
hostHalf: () => new Promise((resolve) => {
release = (): void => { resolve({ ok: false, message: 'vm exploded' }) }
}),
})
const running = bench.orchestrator.startUserRun(HOST_ONLY)
// The control a surface disables comes from this entry, and a host half can
// take real time to evaluate — so a host-only run is in flight like any other.
expect(bench.orchestrator.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'orchestrating', agentId: AGENT, packageId: PACKAGE, mode: 'run',
})
release()
await running
expect(bench.orchestrator.lastRunError.getSnapshot().get(PLUGIN))
.toEqual({ packageId: PACKAGE, reason: 'host-half-failed', ok: false, message: 'vm exploded' })
})
})

View File

@@ -0,0 +1,460 @@
/**
* @vitest-environment jsdom
*
* Plugin composition account: the dispatch family reaches the runner with its
* envelope rpcId, the service face is provided for UI surfaces, a load failure
* always reaches the console, and the fiber owns the runner's teardown. Plus the two plane-level companions: the
* node half's empty apply and the invariant registration.
*/
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import type {
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: resolves `ctx.remote` and with it the `$on`/`$dispatch` surface.
import type {} from '@deepseek-ai/dsh-api-gateway/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import * as NodeHalf from '../src/index.ts'
import * as Invariant from '../src/invariant.ts'
import * as ClientHalf from '../src/client/index.ts'
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
const RUN = 'run-1' as CordisDynamicPluginRunId
const AGENT = 's-1' as SessionId
const USER_RUN = {
agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' as const, hasClientHalf: true,
}
/**
* Deliver one forwarded Host event the way the runtime's frame bridge does: the
* bridge hands `host/remote-event` to the Remote service, which fans it out to
* `$on` subscribers with the Host's own argument list.
*/
function forward(ctx: Context, event: string, payload: object): void {
ctx.remote.$dispatch(event, [payload])
}
interface Bench {
ctx: Context
/** Source the host hands over for the next run. */
source: { current: {
code: string
name: string
pluginId: CordisDynamicPluginId
packageId: CordisDynamicPackageId
pluginRunId: CordisDynamicPluginRunId
} }
/** Resolutions the host received. */
resolved: { requestId: string; resolution: unknown }[]
/** What the namespace received. */
invoked: { pluginId: CordisDynamicPluginId; pluginRunId: CordisDynamicPluginRunId; method: string; args: unknown }[]
/** Answer of the next invoke call. */
invokeResult: { current: DynamicCordisInvokeResult }
/** Rejection the namespace throws instead of answering (the codec refusing a payload). */
invokeThrow: { current: unknown }
/** Render failures the namespace received, in order. */
renderFailures: {
agentId: string
pluginId: CordisDynamicPluginId
pluginRunId: CordisDynamicPluginRunId
failure: unknown
}[]
/** Whether the namespace refuses the next render-failure report. */
reportRefused: { current: boolean }
/**
* Report one entry crash the way the renderer's boundary does. Production calls
* this from web-react's boundary through the render host; a test has no React
* tree, so it stands in for that caller on the same core seam.
*/
crash: (slot: string, entry: unknown, abdicate: boolean, error: unknown) => void
dispose: () => Promise<void>
settle: () => Promise<void>
}
/** Mount the browser half over a module table and a loader standing on real fibers. */
async function boot(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(SlotRegistry)
const factories = new Map<string, () => unknown>()
const fibers = new Map<string, { fiber: unknown }>()
let next = 0
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = {
load: (handoff: { id: string; factory: () => unknown }) => { factories.set(handoff.id, handoff.factory) },
}
ctx.reflect.provide('loader', {
create: (options: { name: string }) => {
const entryId = `entry-${++next}`
const fiber = ctx.plugin(factories.get(options.name)?.() as Parameters<Context['plugin']>[0])
// The runner reads activation failure through fiber.await(); terminate this
// handle too, or a failing package also lands as an unhandled rejection.
void Promise.resolve(fiber).catch(() => {})
fibers.set(entryId, { fiber })
return Promise.resolve(entryId)
},
resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
remove: async (entryId: string) => {
const entry = fibers.get(entryId)
fibers.delete(entryId)
await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
},
})
ctx.reflect.provide('modules', { invalidate: () => {} })
const invoked: Bench['invoked'] = []
const invokeResult: { current: DynamicCordisInvokeResult } = { current: { ok: true, value: 'pong' } }
const invokeThrow: { current: unknown } = { current: undefined }
const source: Bench['source'] = { current: {
code: 'return { apply(ctx) {} }',
name: 'demo',
pluginId: PLUGIN,
packageId: PACKAGE,
pluginRunId: RUN,
} }
const resolved: { requestId: string; resolution: unknown }[] = []
const renderFailures: Bench['renderFailures'] = []
const reportRefused = { current: false }
// Every generated Remote method resolves to a RemoteResult: the carrier folds
// its own failures into the error branch, and only an assembly fault rejects.
const answered = <T>(value: T): Promise<{ ok: true; value: T }> => Promise.resolve({ ok: true as const, value })
const namespace = {
syncInspectManifest: () => answered(null),
resolveInspectQuery: () => answered({ accepted: true }),
runHostHalf: () => answered({
ok: true, pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, waitingFor: [], startedHere: true,
}),
settleUserRun: () => answered({
ok: true, pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, waitingFor: [],
}),
reportRenderFailure: (
agentId: string,
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
failure: unknown,
) => {
renderFailures.push({ agentId, pluginId, pluginRunId, failure })
return reportRefused.current ? Promise.reject(new Error('stream gone')) : answered(undefined)
},
getClientCode: () => answered(source.current),
resolveRequestRun: (requestId: string, resolution: unknown) => {
resolved.push({ requestId, resolution })
return answered({ accepted: true })
},
invoke: (
pluginId: CordisDynamicPluginId,
pluginRunId: CordisDynamicPluginRunId,
method: string,
args: unknown,
) => {
invoked.push({ pluginId, pluginRunId, method, args })
const refusal = invokeThrow.current
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is a case under test
if (refusal !== undefined) return Promise.reject(refusal)
return answered(invokeResult.current)
},
}
// Minimal stand-in for the gateway's Client Remote: the fan-out under test is
// this plugin's subscriptions, so registration order and delivery are all the
// stub owes (api-gateway covers isolation and disposal on the real one).
const listeners = new Map<string, ((...args: never[]) => void)[]>()
const remote = {
dynamicCordisRunner: namespace,
$on: (event: string, listener: (...args: never[]) => void) => {
const bucket = listeners.get(event) ?? []
bucket.push(listener)
listeners.set(event, bucket)
return () => {
const at = bucket.indexOf(listener)
if (at >= 0) bucket.splice(at, 1)
}
},
$dispatch: (event: string, args: readonly unknown[]) => {
for (const listener of [...listeners.get(event) ?? []]) {
(listener as (...a: readonly unknown[]) => void)(...args)
}
},
}
ctx.reflect.provide('remote', remote)
ctx.reflect.provide('remote.dynamicCordisRunner', namespace)
const fiber = ctx.plugin(ClientHalf)
await fiber
return {
ctx,
source,
resolved,
invoked,
invokeResult,
invokeThrow,
renderFailures,
reportRefused,
crash: (slot, entry, abdicate, error) => {
const core = (ctx.slots as unknown as {
_core: { reportEntryError(key: string, entry: unknown, error: unknown, info: { abdicate: boolean }): void }
})._core
core.reportEntryError(slot, entry, error, { abdicate })
},
dispose: async () => { await fiber.dispose() },
settle: async () => { await new Promise((resolve) => { setTimeout(resolve, 0) }) },
}
}
describe('browser half', () => {
it('provides the load engine as the page run-state face', async () => {
const bench = await boot()
expect(bench.ctx.dynamicCordisRunner.getSnapshot()).toEqual([])
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
})
it('unloads on a forwarded withdrawal event', async () => {
const bench = await boot()
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
forward(bench.ctx, 'cordis/dynamic-retract', {
pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN,
})
await bench.settle()
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
})
it('runs a host-only definition through the face without loading anything here', async () => {
const bench = await boot()
await bench.ctx.dynamicCordisRunner.startUserRun({ ...USER_RUN, hasClientHalf: false })
// The host half is up and this page has nothing — and no failure, which is
// what the surface's control promised.
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
expect(bench.ctx.dynamicCordisRunner.lastRunError.getSnapshot().size).toBe(0)
})
it('routes host.call through the namespace and unwraps the result', async () => {
const bench = await boot()
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", { a: 1 })'
+ '.then((value) => value, (error) => error.message) } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
delete (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
await expect(call).resolves.toBe('pong')
expect(bench.invoked).toEqual([{
pluginId: PLUGIN, pluginRunId: RUN, method: 'ping', args: { a: 1 },
}])
})
it('carries an omitted host.call argument to the namespace as null', async () => {
const bench = await boot()
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("listServices") } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
delete (globalThis as { __dynCall?: Promise<unknown> }).__dynCall
await call
// `undefined` is not JSON, so the wire would refuse the call the model wrote
// most naturally; the omission travels as null instead.
expect(bench.invoked).toEqual([{
pluginId: PLUGIN, pluginRunId: RUN, method: 'listServices', args: null,
}])
})
it('teaches the JSON contract when the namespace refuses the payload', async () => {
const bench = await boot()
// What the generated codec throws for a value that is not JSON: a bare field
// name, with no idea which call it belonged to or what to write instead.
bench.invokeThrow.current = new Error('client api: dynamicCordisRunner/invoke rejected "args"')
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", 1)'
+ '.then(() => "resolved", (error) => error.message) } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
await expect(call).resolves.toMatch(/host\.call\("ping"\) on dyn-1 did not complete: client api: .*rejected "args"/)
await expect(call).resolves.toMatch(/omit it, and the handler receives null/)
await expect(call).resolves.toMatch(/`return null` when there is nothing to report/)
})
it('stringifies a non-Error refusal into the same teaching error', async () => {
const bench = await boot()
bench.invokeThrow.current = 'stream gone'
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping")'
+ '.then(() => "resolved", (error) => error.message) } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
await expect(call).resolves.toMatch(/did not complete: stream gone/)
})
it('sends a render crash of its own entry to the host, and survives a refused report', async () => {
const bench = await boot()
bench.source.current = { ...bench.source.current,
code: `return {
inject: ['slots'],
apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
}`,
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const [entry] = bench.ctx.slots.entries('root')
bench.crash('root', entry, true, new Error('Cannot read properties of undefined'))
expect(bench.renderFailures).toEqual([{
agentId: AGENT,
pluginId: PLUGIN,
pluginRunId: RUN,
failure: {
slot: 'root',
message: 'your entry in slot "root" crashed while React rendered it: Cannot read properties of undefined',
stack: expect.any(String),
abdicated: true,
},
}])
// The same observation also reaches the page's own surface, so a row can show
// it without reading the host back.
expect(bench.ctx.dynamicCordisRunner.renderFailures.getSnapshot().get(PLUGIN)).toEqual(bench.renderFailures[0]?.failure)
// A report the host refuses is logged and dropped: one crash must not become
// two, and nothing waits on this answer.
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
bench.reportRefused.current = true
bench.crash('root', entry, false, new Error('again'))
await bench.settle()
const complaints = logged.mock.calls.filter(call => String(call[0]).includes('reporting a render failure'))
logged.mockRestore()
expect(complaints).toHaveLength(1)
})
it('turns each routing failure code into its own teaching error', async () => {
const codes = [
['plugin-not-running', /found no active Host half/],
['stale-run', /activation that has already been replaced/],
['method-not-found', /must declare it with harness\.handle\("ping", fn\)/],
['handler-error', /failed inside the host handler: boom/],
] as const
for (const [code, expected] of codes) {
const bench = await boot()
bench.invokeResult.current = { ok: false, code, message: 'boom' }
bench.source.current = { ...bench.source.current,
code: 'return { apply: () => { globalThis.__dynCall = host.call("ping", 1)'
+ '.then(() => "resolved", (error) => error.message) } }',
}
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const call = (globalThis as { __dynCall?: Promise<string> }).__dynCall
delete (globalThis as { __dynCall?: Promise<string> }).__dynCall
await expect(call).resolves.toMatch(expected)
}
})
it('answers a run request after the surface approves it', async () => {
const bench = await boot()
const request = 'rr-1' as ApprovalRequestId
forward(bench.ctx, 'cordis/request-run', {
requestId: request,
agentId: AGENT,
pluginId: PLUGIN,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'show a clock',
requiresApproval: true,
})
await bench.settle()
// The event's own fields reach the activity: a surface groups the row by
// session and shows the reason without a registry read.
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().get(PLUGIN)).toEqual({
phase: 'awaiting-approval',
requestId: request,
agentId: AGENT,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'show a clock',
})
await bench.ctx.dynamicCordisRunner.approve(request, false)
expect(bench.resolved).toEqual([{
requestId: request, resolution: { ok: true, pluginRunId: RUN },
}])
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().size).toBe(0)
})
it('drops the affordance when another page answers the request', async () => {
const bench = await boot()
const request = 'rr-2' as ApprovalRequestId
forward(bench.ctx, 'cordis/request-run', {
requestId: request,
agentId: AGENT,
pluginId: PLUGIN,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'p',
requiresApproval: true,
})
await bench.settle()
forward(bench.ctx, 'cordis/request-run-resolved', {
requestId: request, outcome: 'approved',
})
await bench.settle()
expect(bench.ctx.dynamicCordisRunner.activeRuns.getSnapshot().size).toBe(0)
// Answering a settled request is a no-op, not an error.
await bench.ctx.dynamicCordisRunner.approve(request, false)
expect(bench.resolved).toEqual([])
})
it('exposes the refusal and the load observer on the face', async () => {
const bench = await boot()
const request = 'rr-3' as ApprovalRequestId
forward(bench.ctx, 'cordis/request-run', {
requestId: request,
agentId: AGENT,
pluginId: PLUGIN,
packageId: PACKAGE,
mode: 'run',
name: 'demo',
purpose: 'p',
requiresApproval: true,
})
await bench.settle()
let loads = 0
const unsubscribe = bench.ctx.dynamicCordisRunner.subscribe(() => { loads++ })
await bench.ctx.dynamicCordisRunner.decline(request)
expect(bench.resolved).toEqual([{ requestId: request, resolution: { ok: false, reason: 'rejected' } }])
expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(false)
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
expect(loads).toBeGreaterThan(0)
unsubscribe()
})
it('unloads every package when its own fiber goes away', async () => {
const bench = await boot()
await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
const runner = bench.ctx.dynamicCordisRunner
await bench.dispose()
await bench.settle()
expect(runner.getSnapshot()).toEqual([])
})
})
describe('node half', () => {
it('contributes nothing host-side', () => {
NodeHalf.apply()
expect(typeof NodeHalf.apply).toBe('function')
})
})
describe('invariant companion', () => {
it('reserves package ownership with an explained empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = ctx.plugin(Invariant)
await fiber
expect(Invariant.name).toBe('cordis-client-runner-invariant')
// No relation to audit here: the owned one is browser-local runner state.
// An event this plugin declares nothing about: the bridge must not route it here.
expect(() => { (ctx.emit as (type: string) => void)('unrelated/event') }).not.toThrow()
await fiber.dispose()
})
})

View File

@@ -0,0 +1,517 @@
/**
* @vitest-environment jsdom
*
* Load-engine account: what `load` answers its caller (that answer is what the
* run orchestration reports to the host), Plugin Run convergence against live
* state, per-Plugin serialization, the three-step teardown, and each failing stage.
*
* The loader is stood in by real `ctx.plugin` fibers: entry creation must run the
* guarded surface as a genuine plugin, or neither activation gating nor the
* disposal cascade under test would be real.
*/
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
import { Context } from '@deepseek-ai/cordis'
import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
import { describe, expect, it, vi } from 'vitest'
import type {
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { DYNAMIC_CLIENT_REDIRECTS } from '../src/client/evaluator.ts'
import { DynamicCordisPackageRunner } from '../src/client/runtime.ts'
import type { DynamicCordisClientHalf, DynamicCordisRenderFailure } from '../src/client/runtime.ts'
const PLUGIN = 'dyn-1' as CordisDynamicPluginId
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
const RUN = 'run-1' as CordisDynamicPluginRunId
const AGENT = 's-1' as SessionId
function runId(value: number): CordisDynamicPluginRunId {
return `run-${value}` as CordisDynamicPluginRunId
}
/** One browser half as the host hands it over. */
function half(overrides: Partial<DynamicCordisClientHalf> = {}): DynamicCordisClientHalf {
return {
pluginId: PLUGIN,
packageId: PACKAGE,
pluginRunId: RUN,
agentId: AGENT,
name: 'demo',
code: 'return { apply(ctx) {} }',
...overrides,
}
}
interface Bench {
ctx: Context
slots: SlotRegistry
runner: DynamicCordisPackageRunner
invalidated: string[]
removed: string[]
created: string[]
invoke: ReturnType<typeof vi.fn>
/** Render failures the runner sent upstream, in order. */
reported: {
agentId: SessionId
pluginId: CordisDynamicPluginId
pluginRunId: CordisDynamicPluginRunId
failure: DynamicCordisRenderFailure
}[]
/**
* Report one entry crash the way the renderer's boundary does: the runner
* subscribed through the supervision seam, and this calls what it registered.
*/
crash: (slot: string, entry: unknown, error: unknown, abdicated?: boolean) => void
/** Whether the runner released its subscription. */
watching: () => boolean
settle: () => Promise<void>
}
/**
* Terminate the awaitable fiber handle. The runner reads activation failure
* through `fiber.await()`; without a handler on the fiber itself, a deliberately
* failing package would also surface as an unhandled rejection.
*/
function seated<T>(fiber: T): T {
void Promise.resolve(fiber).catch(() => {})
return fiber
}
async function boot(): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(SlotRegistry)
const invalidated: string[] = []
const removed: string[] = []
const created: string[] = []
const factories = new Map<string, () => unknown>()
const fibers = new Map<string, { fiber: unknown }>()
let next = 0
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = {
load: (handoff: { id: string; factory: () => unknown }) => { factories.set(handoff.id, handoff.factory) },
}
const loader = {
create: (options: { name: string }) => {
created.push(options.name)
const factory = factories.get(options.name)
if (factory === undefined) throw new Error(`no factory for ${options.name}`)
const entryId = `entry-${++next}`
fibers.set(entryId, { fiber: seated(ctx.plugin(factory() as Parameters<Context['plugin']>[0])) })
return Promise.resolve(entryId)
},
resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
remove: async (entryId: string) => {
removed.push(entryId)
const entry = fibers.get(entryId)
fibers.delete(entryId)
await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
},
} as unknown as Loader
const invoke = vi.fn(() => Promise.resolve(null))
const reported: Bench['reported'] = []
// The crash seam is stood in so a test can report an entry failure without a
// React render, exactly as the renderer's boundary would; registrations still
// go through the real service, so the entries are real.
type EntryErrorListener = (slot: string, entry: unknown, error: unknown, info: { abdicated: boolean }) => void
let listener: EntryErrorListener | undefined
const runner = new DynamicCordisPackageRunner({
ctx,
loader,
modules: { invalidate: (id: string) => { invalidated.push(id) } } as unknown as ClientModuleSystem,
slots: {
onEntryError: (fn: EntryErrorListener) => {
listener = fn
return () => { listener = undefined }
},
} as unknown as SlotRegistry,
invoke,
reportGuardFailure: () => {},
reportRenderFailure: (agentId, pluginId, pluginRunId, failure) => {
reported.push({ agentId, pluginId, pluginRunId, failure })
},
})
return {
ctx,
slots: ctx.slots,
runner,
invalidated,
removed,
created,
invoke,
reported,
crash: (slot, entry, error, abdicated = true) => {
if (listener === undefined) throw new Error('the runner is not watching the crash seam')
listener(slot, entry, error, { abdicated })
},
watching: () => listener !== undefined,
settle: async () => { await new Promise((resolve) => { setTimeout(resolve, 0) }) },
}
}
describe('load', () => {
it('mounts a browser half through the module table and the loader, then answers active', async () => {
const bench = await boot()
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
expect(bench.invalidated).toEqual(['dyn/dyn-1'])
expect(bench.created).toEqual(['dyn/dyn-1'])
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
expect(bench.runner.getSnapshot()).toEqual([
{ pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, name: 'demo', slots: [], styleCount: 0 },
])
})
it('projects the contributions the package made', async () => {
const bench = await boot()
await bench.runner.load(half({
code: `return {
inject: ['slots'],
apply(ctx) {
styles.insert('.x {}')
ctx.slots.register({ name: 'root' }, () => null)
},
}`,
}))
expect(bench.runner.getSnapshot()).toEqual([
{ pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN, name: 'demo', slots: ['root'], styleCount: 1 },
])
})
it('answers from live state when the revision is already loaded here', async () => {
const bench = await boot()
await bench.runner.load(half())
// A replayed run must not look unacknowledged, and must not reload.
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
expect(bench.created).toEqual(['dyn/dyn-1'])
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
})
it('replays the parked services a live package still waits for', async () => {
const bench = await boot()
const parked = half({ code: "return { inject: ['absent'], apply() {} }" })
await expect(bench.runner.load(parked)).resolves.toEqual({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] })
await expect(bench.runner.load(parked)).resolves.toEqual({ ok: true, pluginRunId: RUN, waitingFor: ['absent'] })
expect(bench.created).toEqual(['dyn/dyn-1'])
})
it('replaces a live load when a newer revision arrives', async () => {
const bench = await boot()
await bench.runner.load(half())
await expect(bench.runner.load(half({ pluginRunId: runId(2) }))).resolves.toEqual({ ok: true, pluginRunId: runId(2) })
expect(bench.removed).toEqual(['entry-1'])
expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1', 'dyn/dyn-1'])
expect(bench.created).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
expect(bench.runner.getSnapshot()[0]?.pluginRunId).toBe(runId(2))
})
it('loads the function form, which declares no services', async () => {
const bench = await boot()
await expect(bench.runner.load(half({ code: 'return (ctx) => { globalThis.__dynFnForm = true }' })))
.resolves.toEqual({ ok: true, pluginRunId: RUN })
expect((globalThis as { __dynFnForm?: boolean }).__dynFnForm).toBe(true)
delete (globalThis as { __dynFnForm?: boolean }).__dynFnForm
})
it('serializes operations of one package id', async () => {
const bench = await boot()
const first = bench.runner.load(half())
const second = bench.runner.load(half({ pluginRunId: runId(2) }))
await expect(first).resolves.toEqual({ ok: true, pluginRunId: RUN })
await expect(second).resolves.toEqual({ ok: true, pluginRunId: runId(2) })
expect(bench.created).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
})
it('keeps the queue usable after a failed operation', async () => {
const bench = await boot()
const sink = (globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__
delete (globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__
await expect(bench.runner.load(half())).rejects.toThrow(/__ModuleLoader__ is missing/)
;(globalThis as { __ModuleLoader__?: unknown }).__ModuleLoader__ = sink
await expect(bench.runner.load(half())).resolves.toEqual({ ok: true, pluginRunId: RUN })
})
})
describe('failure stages', () => {
it('classifies a closure that will not evaluate, and leaves no styles behind', async () => {
const bench = await boot()
await expect(bench.runner.load(half({ code: 'styles.insert(".leak {}"); return 42' }))).resolves.toEqual({
ok: false,
cause: 'evaluate',
message: expect.stringContaining('must `return` a plugin') as string,
stack: expect.any(String),
error: expect.any(Error),
})
const leaked = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
.filter(tag => tag.textContent === '.leak {}')
expect(leaked).toHaveLength(0)
expect(bench.created).toEqual([])
})
it('classifies an apply that throws, and tears the entry down', async () => {
const bench = await boot()
await expect(bench.runner.load(half({ code: 'return { apply() { throw new Error("apply exploded") } }' })))
.resolves.toEqual({
ok: false,
cause: 'activate',
message: 'apply exploded',
stack: expect.any(String),
error: expect.any(Error),
})
expect(bench.removed).toEqual(['entry-1'])
expect(bench.runner.isLoaded(PLUGIN)).toBe(false)
})
it('stringifies a closure that rejects with a non-Error value', async () => {
const bench = await boot()
await expect(bench.runner.load(half({ code: 'throw "raw rejection"' })))
.resolves.toEqual({ ok: false, cause: 'evaluate', message: 'raw rejection', error: 'raw rejection' })
})
it('classifies a loader entry that produced no fiber', async () => {
const bench = await boot()
const env = bench.runner as unknown as { env: { loader: { resolve: (id: string) => unknown } } }
vi.spyOn(env.env.loader, 'resolve').mockReturnValue({ fiber: undefined })
await expect(bench.runner.load(half())).resolves.toEqual({
ok: false,
cause: 'module-import',
message: 'module import failed (see the browser console)',
})
vi.restoreAllMocks()
expect(bench.removed).toEqual(['entry-1'])
})
it('mirrors a loaded package runtime error to the console without unloading it', async () => {
const bench = await boot()
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
await bench.runner.load(half({
code: 'return { apply: (ctx) => { ctx.on("t/ping", () => console.error("after load")) } }',
}))
;(bench.ctx.emit as (type: string) => void)('t/ping')
const mirrored = logged.mock.calls.filter(call => String(call[0]).includes('logged an error'))
logged.mockRestore()
expect(mirrored).toHaveLength(1)
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
})
})
describe('retract', () => {
it('unloads at the named revision', async () => {
const bench = await boot()
await bench.runner.load(half())
bench.runner.retract(PLUGIN, RUN)
await bench.settle()
expect(bench.removed).toEqual(['entry-1'])
expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
expect(bench.runner.isLoaded(PLUGIN)).toBe(false)
})
it('ignores a retract of a superseded revision', async () => {
const bench = await boot()
await bench.runner.load(half({ pluginRunId: runId(3) }))
bench.runner.retract(PLUGIN, runId(2))
await bench.settle()
expect(bench.runner.isLoaded(PLUGIN)).toBe(true)
})
it('ignores a retract of a package this page never loaded', async () => {
const bench = await boot()
bench.runner.retract(PLUGIN, RUN)
await bench.settle()
expect(bench.removed).toEqual([])
})
})
describe('observation and disposal', () => {
it('notifies subscribers and re-derives the snapshot after each convergence', async () => {
const bench = await boot()
let notified = 0
const unsubscribe = bench.runner.subscribe(() => { notified++ })
const empty = bench.runner.getSnapshot()
expect(bench.runner.getSnapshot()).toBe(empty) // stable between mutations
await bench.runner.load(half())
expect(notified).toBe(1)
expect(bench.runner.getSnapshot()).not.toBe(empty)
unsubscribe()
bench.runner.retract(PLUGIN, RUN)
await bench.settle()
expect(notified).toBe(1)
})
it('unloads every live package on disposal', async () => {
const bench = await boot()
await bench.runner.load(half())
await bench.runner.dispose()
expect(bench.removed).toEqual(['entry-1'])
expect(bench.runner.getSnapshot()).toEqual([])
expect(bench.slots.entries('root')).toHaveLength(0)
})
it('routes host.call through the invoke seam it was given', async () => {
const bench = await boot()
await bench.runner.load(half({ code: 'return { apply: () => host.call("ping", 1) }' }))
expect(bench.invoke).toHaveBeenCalledWith(PLUGIN, RUN, 'ping', 1)
})
})
describe('render failures', () => {
/** A package that seats one component in `root`, so a crash has something to name. */
const CONTRIBUTOR = `return {
inject: ['slots'],
apply(ctx) { ctx.slots.register({ name: 'root' }, () => null) },
}`
it('reports a crash of an entry it seated, under the session the run was for', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('Cannot read properties of undefined'))
expect(bench.reported).toEqual([{
agentId: AGENT,
pluginId: PLUGIN,
pluginRunId: RUN,
failure: {
slot: 'root',
message: 'your entry in slot "root" crashed while React rendered it: Cannot read properties of undefined',
stack: expect.any(String),
abdicated: true,
},
}])
})
it('carries the retirement bit as the seam reported it', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
// A chain crash keeps its cell: the package's UI is broken, not gone, and the
// author needs to be able to tell those apart.
bench.crash('root', entry, new Error('boom'), false)
expect(bench.reported[0]?.failure.abdicated).toBe(false)
})
it('ignores a crash of an entry no dynamic package seated', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
// Factory UI crashing is not this runner's business, and neither is an entry
// whose component cannot even be indexed by identity.
bench.crash('root', { component: () => null }, new Error('boom'))
bench.crash('root', { component: 'not-a-component' }, new Error('boom'))
bench.crash('root', { component: null }, new Error('boom'))
expect(bench.reported).toEqual([])
})
it('seats a package that registers an unindexable component without claiming it', async () => {
const bench = await boot()
// A component that is not an object has no identity to key ownership on; the
// registration still stands, and a crash on it simply goes unattributed.
await expect(bench.runner.load(half({
code: `return {
inject: ['slots'],
apply(ctx) {
ctx.slots.register({ name: 'root' }, 'not-a-component')
ctx.slots.register({ name: 'root' }, null)
},
}`,
}))).resolves.toEqual({ ok: true, pluginRunId: RUN })
for (const entry of bench.slots.entries('root')) bench.crash('root', entry, new Error('boom'))
expect(bench.reported).toEqual([])
})
it('appends the redirect a bare crash text is missing, and never twice', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
// Reaching the global around the closure trap (window.setInterval) crashes
// with the engine's own text, which teaches nothing on its own.
bench.crash('root', entry, new TypeError('window.setInterval is not a function'))
const bare = bench.reported[0]?.failure.message ?? ''
expect(bare).toMatch(/is not a function\n/)
const timerRedirect = DYNAMIC_CLIENT_REDIRECTS.setInterval
if (timerRedirect === undefined) throw new Error('setInterval redirect is missing')
expect(bare).toContain(timerRedirect)
// The trap's own error already carries that sentence: appending it again
// would make the model read the same paragraph twice.
bench.crash('root', entry, new Error(
`setInterval is not available in a dynamic client half — ${timerRedirect}`,
))
const trapped = bench.reported[1]?.failure.message ?? ''
expect(trapped.indexOf(timerRedirect)).toBe(trapped.lastIndexOf(timerRedirect))
})
it('stops watching the seam when the engine is disposed', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
expect(bench.watching()).toBe(true)
await bench.runner.dispose()
expect(bench.watching()).toBe(false)
})
it('publishes the crash on the live set\'s own notification channel', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
let notified = 0
let alsoNotified = 0
const unsubscribe = bench.runner.subscribe(() => { notified++ })
const unobserve = bench.runner.renderFailures.subscribe(() => { alsoNotified++ })
const empty = bench.runner.renderFailures.getSnapshot()
expect(bench.runner.renderFailures.getSnapshot()).toBe(empty) // stable between mutations
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('boom'), false)
// A surface already subscribed for load changes learns about a crash too: one
// channel, two derived snapshots — and the observable's own subscribe is that
// same channel, so a surface may take either handle.
expect(notified).toBe(1)
expect(alsoNotified).toBe(1)
const published = bench.runner.renderFailures.getSnapshot().get(PLUGIN)
expect(published?.slot).toBe('root')
expect(published?.abdicated).toBe(false)
expect(published?.message).toMatch(/boom/)
unsubscribe()
unobserve()
})
it('keeps only the latest crash per package', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('first'))
bench.crash('root', entry, new Error('second'))
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
expect(bench.runner.renderFailures.getSnapshot().get(PLUGIN)?.message).toMatch(/second/)
})
it('clears the crash when the package is retracted', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('boom'))
bench.runner.retract(PLUGIN, RUN)
await bench.settle()
// A row must never show a failure of something that no longer renders here.
expect(bench.runner.renderFailures.getSnapshot().size).toBe(0)
})
it('clears the crash when the package loads again', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('boom'))
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
await bench.runner.load(half({ code: CONTRIBUTOR, pluginRunId: runId(2) }))
expect(bench.runner.renderFailures.getSnapshot().size).toBe(0)
})
it('keeps the crash when a replayed run loads nothing', async () => {
const bench = await boot()
await bench.runner.load(half({ code: CONTRIBUTOR }))
const [entry] = bench.slots.entries('root')
bench.crash('root', entry, new Error('boom'))
// Same revision: nothing was re-run, so the failure the page is showing is
// still true of what is mounted.
await bench.runner.load(half({ code: CONTRIBUTOR }))
expect(bench.runner.renderFailures.getSnapshot().size).toBe(1)
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../../client/connection/tsconfig.client.json"
},
{
"path": "../../client/modules"
},
{
"path": "../../client/runtime"
},
{
"path": "../../client/ui-slots"
},
{
"path": "../../client/ui-theme"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-cordis-client-runner', ['lib/types/index.js', 'lib/types/invariant.js'])