merge(master): checkpoint post-review base movement
Master advanced from e03b51d7db to ee223e6545 after bot-thread resolution and the PR-body policy fix. Merge-forward preserves the reviewed issue commits and explicit checkpoint history instead of rewriting the branch. The only textual conflict was the generated translation-pairing record for docs/config-catalog. Regenerate the English catalog from the combined source tree and re-record the bilingual hashes so current-master command-line changes and this PR's compression configuration coexist. Verified with pnpm run verify-config-catalog, pnpm run verify-translation-pairing docs/config-catalog.md, and git diff --cached --check.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: 9639f1c0a2ffe91fd509a2ffdf04be5f0895b700
|
||||
README.zh.md: e8bf0374aad2be2311b6e72e91e41f02f403b48a
|
||||
README.md: 4c82aa749edbeada0a344b0b119d1644544d9732
|
||||
README.zh.md: 38298e091af4aa1b09c31dfa8141ce68fa1d3f50
|
||||
|
||||
@@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home:
|
||||
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
|
||||
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
|
||||
|
||||
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
Every profile boot keeps `cordis.patch.yml` live through `watchUserPatches` (a one-shot surface disposes the watcher through its bounded shutdown). The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`
|
||||
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
|
||||
- **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。
|
||||
|
||||
长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
每次 profile 启动都由 `watchUserPatches` 持续应用 `cordis.patch.yml` 的变更(一次性 surface 经由有界关闭 dispose 监视器)。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -110,21 +110,33 @@ function entryConfig(ctx: Context, id: string): unknown {
|
||||
}
|
||||
|
||||
describe('Loader config interpolation', () => {
|
||||
it("resolves Include's own !!js options", async () => {
|
||||
it("keeps Include's config literal — a nested row's !!js belongs to that row's fiber", async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
writeFileSync(join(dir, 'reader.mjs'), [
|
||||
'export const name = "reader"',
|
||||
'export function apply(ctx, config) { ctx.provide("observedValue", config.value) }',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: reader\n name: ./reader.mjs\n')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href)
|
||||
ctx.provide('answer', 42)
|
||||
try {
|
||||
// The include is a tree carrier: its own config (path, patches) stays
|
||||
// literal, and the expression nested inside the patched row's config
|
||||
// resolves against the row's fiber, not the include's.
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: { __jsExpr: "ctx.get('includePath')" } },
|
||||
config: {
|
||||
path: pathToFileURL(join(dir, 'cordis.yml')).href,
|
||||
patches: [{ id: 'reader', name: './reader.mjs', config: { value: { __jsExpr: "ctx.get('answer')" } } }],
|
||||
},
|
||||
})
|
||||
await ctx.loader.await()
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true)
|
||||
const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
|
||||
expect(reader?.options.config).toEqual({ value: { __jsExpr: "ctx.get('answer')" } })
|
||||
expect(ctx.get('observedValue')).toBe(42)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md
|
||||
README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96
|
||||
README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114
|
||||
README.md: 2e8e58b23785fa78bd2663a459817669309a81be
|
||||
README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd
|
||||
|
||||
@@ -51,8 +51,6 @@ Every row configured from those values uses ordinary service injection and direc
|
||||
|
||||
Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset.
|
||||
|
||||
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering.
|
||||
|
||||
### Shared immutable arguments
|
||||
|
||||
`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments.
|
||||
|
||||
@@ -51,8 +51,6 @@ export function apply(ctx: Context): void {
|
||||
|
||||
Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
|
||||
|
||||
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。
|
||||
|
||||
### 共享不可变参数
|
||||
|
||||
`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
// Empty type import carries the Loader Context merge used by enableRow.
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
|
||||
/**
|
||||
* The invocation's inner arguments: everything after the launcher's own flags,
|
||||
@@ -133,28 +131,6 @@ export function parseCmdline<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on a row this composition ships disabled, because this invocation asked
|
||||
* for it (`dsh web --dev` and its client-plugin reload chain).
|
||||
*
|
||||
* A row cannot be inserted from inside a mounting plugin — the Loader returns a
|
||||
* prefixed id it then fails to resolve — so a conditional row ships disabled
|
||||
* and a row mounted beside it enables it after startup resolves the invocation.
|
||||
* The Loader keeps that activation in memory, separate from serialized options,
|
||||
* so reapplying the composition cannot restore the invocation's row to disabled.
|
||||
* @param ctx - plugin context whose Loader tree carries the row.
|
||||
* @param id - the row id.
|
||||
* @returns nothing once the row has started or is waiting for its dependencies.
|
||||
* @throws when the Loader or named row is absent.
|
||||
*/
|
||||
export async function enableRow(ctx: Context, id: string): Promise<void> {
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service')
|
||||
const entry = [...loader.entries()].find(candidate => candidate.options.id === id)
|
||||
if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
|
||||
await entry.enableRuntime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a thrown value is commander's own control-flow error (help, version,
|
||||
* a parse error, or `program.error`).
|
||||
|
||||
@@ -14,9 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan,
|
||||
} from '../src/index.ts'
|
||||
import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts'
|
||||
|
||||
/** Every value one boot of the fixture tree observed. */
|
||||
interface Observed {
|
||||
@@ -175,71 +173,6 @@ describe('parseCmdline', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('enableRow', () => {
|
||||
it('enables the named Loader row and fails loud when the Loader or row is absent', async () => {
|
||||
const withoutLoader = new Context()
|
||||
await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service')
|
||||
|
||||
const ctx = new Context()
|
||||
let enabled = false
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
enableRuntime: async () => { enabled = true },
|
||||
}],
|
||||
} as never)
|
||||
await enableRow(ctx, 'client-hmr')
|
||||
expect(enabled).toBe(true)
|
||||
await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable')
|
||||
})
|
||||
|
||||
it('keeps invocation-only activation through config reapplication', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-'))
|
||||
const observed = { starts: 0, stops: 0 }
|
||||
;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed
|
||||
writeFileSync(join(dir, 'conditional.mjs'), `
|
||||
export function apply(ctx) {
|
||||
globalThis.__runtimeEnableObserved.starts += 1
|
||||
ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 })
|
||||
}
|
||||
`)
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: conditional',
|
||||
` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`,
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(join(dir, 'cordis.yml')).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional')
|
||||
const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include')
|
||||
expect(conditional).toBeDefined()
|
||||
expect(include?.fiber).toBeDefined()
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 0, stops: 0 })
|
||||
|
||||
await enableRow(ctx, 'conditional')
|
||||
await ctx.loader.await()
|
||||
expect(conditional?.disabled).toBe(false)
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 1, stops: 0 })
|
||||
|
||||
await include!.fiber!.update(include!.options.config, true)
|
||||
await ctx.loader.await()
|
||||
expect(conditional?.disabled).toBe(false)
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 1, stops: 0 })
|
||||
disposers.push(async () => { await ctx.fiber.dispose() })
|
||||
})
|
||||
})
|
||||
|
||||
describe('provideCmdline', () => {
|
||||
it('hands the app a snapshot the caller cannot mutate afterwards', () => {
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
|
||||
README.md: 31a4894dbb191d2244371ca7272339e96e253053
|
||||
README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a
|
||||
README.md: 3d9ca350f5f8891e60cfc57c9ca89ef57d9790d3
|
||||
README.zh.md: 1dcba9635b37efebeb0cc1129cc67bc7c01d0d1d
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin.
|
||||
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -17,4 +17,4 @@ None; the runner adds nothing to the request prefix.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
|
||||
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the hook.
|
||||
- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
|
||||
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -17,4 +17,4 @@ Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
|
||||
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该钩子。
|
||||
- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求。
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
persona: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
# One-shot runs never watch or reload their composition.
|
||||
# The shared module-reload HMR row stays off; the launcher's watch-only
|
||||
# fallback still keeps the user patch layers live until the run exits.
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Empty type import carries the loader Context merge for the settlement await.
|
||||
// Empty type imports carry the loader Context merge for the settlement await
|
||||
// and the cmdline Context merge for the appExit host value.
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-cmdline'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'headless-runner'
|
||||
@@ -41,22 +43,18 @@ interface RunOutcome {
|
||||
reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-facing effects of one run, injectable for tests. The launcher owns
|
||||
* bounded tree shutdown and wires `exit()` to it.
|
||||
*/
|
||||
export interface HeadlessIo {
|
||||
/** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
|
||||
interface HeadlessIo {
|
||||
stdout: { write(chunk: string): unknown }
|
||||
stderr: { write(chunk: string): unknown }
|
||||
/** Request process exit with `code` after the tree disposes. */
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Process-facing effects provided before the headless tree mounts. */
|
||||
headlessIo?: HeadlessIo
|
||||
}
|
||||
/** The process streams the runner writes to; tests substitute captures. */
|
||||
export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}
|
||||
|
||||
/** Aggregate the last assistant text and turn outcome in one owned interval. */
|
||||
@@ -137,13 +135,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
|
||||
|
||||
/**
|
||||
* Mount the one-shot direct driver.
|
||||
* @param ctx - plugin context carrying core services and the launcher-owned IO seam.
|
||||
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
||||
* @param config - validated task config.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const io = ctx.headlessIo
|
||||
if (io === undefined) {
|
||||
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
|
||||
// Read through the global service store, not the property proxy: appExit is
|
||||
// an optional host value, never an injected dependency.
|
||||
const exit = ctx.get('appExit')
|
||||
if (exit === undefined) {
|
||||
throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
|
||||
}
|
||||
const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
|
||||
void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
@@ -8,7 +8,10 @@ import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { apply, Config, type HeadlessIo } from '../src/index.ts'
|
||||
import { apply, Config, internals } from '../src/index.ts'
|
||||
|
||||
const originalInternals = { ...internals }
|
||||
afterEach(() => { Object.assign(internals, originalInternals) })
|
||||
|
||||
interface Script {
|
||||
before?(session: Session): void
|
||||
@@ -93,13 +96,10 @@ async function bench(script: Script): Promise<{
|
||||
let err = ''
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
const io: HeadlessIo = {
|
||||
stdout: { write: (chunk: string) => { out += chunk; return true } },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: (code) => { order.push('exit'); resolve(code) },
|
||||
}
|
||||
ctx.provide('headlessIo', io)
|
||||
ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
|
||||
})
|
||||
apply(ctx, { task: 'do the thing' })
|
||||
return { code: await exited, out, err, order }
|
||||
@@ -181,12 +181,10 @@ describe('headless runner', () => {
|
||||
it('reports a direct Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('appExit', resolve)
|
||||
})
|
||||
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
||||
@@ -200,12 +198,10 @@ describe('headless runner', () => {
|
||||
it('stringifies a non-Error Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('appExit', resolve)
|
||||
})
|
||||
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
||||
@@ -224,11 +220,9 @@ describe('headless runner', () => {
|
||||
it('abandons a run when the tree is disposed during Loader settlement', async () => {
|
||||
const ctx = new Context()
|
||||
let exited = false
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
exit: () => { exited = true },
|
||||
} satisfies HeadlessIo)
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: () => true }
|
||||
ctx.provide('appExit', () => { exited = true })
|
||||
const services = ctx.plugin((child: Context) => {
|
||||
child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
child.provide('sessions', {} as never)
|
||||
@@ -246,9 +240,9 @@ describe('headless runner', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails loud without the launcher-owned headlessIo seam', () => {
|
||||
it('fails loud without the launcher-provided exit request', () => {
|
||||
const ctx = new Context()
|
||||
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
|
||||
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
|
||||
})
|
||||
|
||||
it('validates config: the task is required', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
|
||||
README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd
|
||||
README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68
|
||||
README.md: 06856a47cd8ccc2c6ee5a53c40928b1bd2933cc7
|
||||
README.zh.md: 8befc7c7404ea1b082842f122769967fff32df2f
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -10,7 +10,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered.
|
||||
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -18,7 +18,7 @@ One source line and one prompt paragraph per session plus two managed-environmen
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The prompt section sits near the system prompt's head and is stable for the life of the process (port and mode are boot facts), so it does not invalidate the cache across turns.
|
||||
The prompt section sits near the system prompt's head and is stable for the life of the process (the port is a boot fact), so it does not invalidate the cache across turns.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。
|
||||
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -18,7 +18,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。
|
||||
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口是启动期事实),因此不会使跨轮次缓存失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an
|
||||
# ordinary Cordis service. Rows configured from flags inject that service, so
|
||||
# Loader resolves their expressions only after it exists. The web runtime then
|
||||
# provides bind-dependent `webRuntime` values to the trust fence and client
|
||||
# roster. `dsh --profile web --help` provides neither service, so no server binds.
|
||||
# provides bind-dependent `webRuntime` values to the trust fence.
|
||||
# `dsh --profile web --help` provides neither service, so no server binds.
|
||||
|
||||
# ── surface-specific values the base deliberately omits ─────────────────────
|
||||
|
||||
@@ -105,39 +105,34 @@
|
||||
# Web glue owned by this bundle: resolves the built frontend dist (an
|
||||
# assembly fact of dsh-web-app, never user config), mounts the
|
||||
# frontend-static fallback owner, registers the web-surface prompt
|
||||
# section and bash runtime variables, and prints the URL line. The webStartup
|
||||
# provider supplies invocation-only values; after the server binds, this row
|
||||
# samples LAN trust once and provides `webRuntime`. A complete agent-preset
|
||||
# persona suppresses the prompt section for that agent while retaining
|
||||
# these host-owned shell variables.
|
||||
# section and the bash runtime variable, and prints the URL line. The
|
||||
# webStartup provider supplies invocation-only values; after the server
|
||||
# binds, this row samples LAN trust once and provides `webRuntime`. A
|
||||
# complete agent-preset persona suppresses the prompt section for that
|
||||
# agent while retaining the host-owned shell variable.
|
||||
- id: web-runtime
|
||||
name: '@deepseek-ai/dsh-web-app'
|
||||
inject: [webStartup]
|
||||
config:
|
||||
mode: !!js ctx.webStartup.mode
|
||||
printUrl: true
|
||||
surfaceContext: true
|
||||
trustedHosts: !!js ctx.webStartup.trustedHosts
|
||||
|
||||
# The client-plugin reload chain: a dev-only row this bundle ships off,
|
||||
# which the runtime row turns on before client discovery. It is a row rather
|
||||
# than a child of web-runtime because its node half is a client-side package,
|
||||
# which a host-side bundle cannot import.
|
||||
# The client-plugin reload chain, always mounted: it is idle until a
|
||||
# rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It
|
||||
# is a row rather than a child of web-runtime because its node half is a
|
||||
# client-side package, which a host-side bundle cannot import.
|
||||
- id: client-hmr
|
||||
name: '@deepseek-ai/dsh-client-hmr'
|
||||
inject: [webStartup]
|
||||
disabled: true
|
||||
|
||||
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
|
||||
|
||||
# Dual-face: this waits for the runtime row to decide whether HMR belongs
|
||||
# in the first graph. The node half then scans this tree, composes
|
||||
# window.__DSH_BOOT__, and serves /plugins/<id>/client.js; the browser half
|
||||
# is the module table the shell kernel constructs before cordis exists
|
||||
# (adopted as a plugin entry by the kernel, never fetched).
|
||||
# Dual-face: the node half scans this tree, composes window.__DSH_BOOT__,
|
||||
# and serves /plugins/<id>/client.js; the browser half is the module table
|
||||
# the shell kernel constructs before cordis exists (adopted as a plugin
|
||||
# entry by the kernel, never fetched).
|
||||
- id: modules
|
||||
name: '@deepseek-ai/dsh-client-modules'
|
||||
inject: [webRuntime]
|
||||
|
||||
# Owns both ends of the web transport: node half binds the gateway to the
|
||||
# webserver under /api; browser half is the fetch/SSE client.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* the built frontend dist (workspace knowledge of this bundle, never user
|
||||
* config), mounts the `frontend-static` fallback owner over it, registers the
|
||||
* harness-source and web-surface prompt sections, the bash-visible web runtime
|
||||
* variables, and the URL line. App command-line values arrive through the
|
||||
* variable, and the URL line. App command-line values arrive through the
|
||||
* `webStartup` service expressions in the bundle patch.
|
||||
* @module @deepseek-ai/dsh-web-app
|
||||
*/
|
||||
@@ -16,7 +16,6 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import { enableRow } from '@deepseek-ai/dsh-cmdline'
|
||||
import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
@@ -28,7 +27,6 @@ export const name = 'web-app'
|
||||
|
||||
/** This dsh installation's root, from either this package's source or built entry. */
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
|
||||
const HMR_ROW_ID = 'client-hmr'
|
||||
|
||||
/** Runtime service that releases Web rows after bind-dependent values resolve. */
|
||||
const WEB_RUNTIME_SERVICE = 'webRuntime'
|
||||
@@ -36,19 +34,14 @@ const WEB_RUNTIME_SERVICE = 'webRuntime'
|
||||
/** Services required before the web runtime can mount. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
|
||||
export type WebMode = 'production' | 'development'
|
||||
|
||||
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
|
||||
export interface Config {
|
||||
/** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
|
||||
mode: WebMode
|
||||
/** Print the URL line on activation; a non-interactive layer can turn it off. */
|
||||
printUrl: boolean
|
||||
/**
|
||||
* Register the model-visible surface context (the `app:web-surface` prompt
|
||||
* section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
|
||||
* non-interactive layer can turn it off when its user is not in the GUI, so the
|
||||
* section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
|
||||
* layer can turn it off when its user is not in the GUI, so the
|
||||
* orientation text would be false.
|
||||
*/
|
||||
surfaceContext: boolean
|
||||
@@ -57,7 +50,6 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union([z.const('production'), z.const('development')]).default('production'),
|
||||
printUrl: z.boolean().default(true),
|
||||
surfaceContext: z.boolean().default(true),
|
||||
trustedHosts: z.array(String).default([]),
|
||||
@@ -73,8 +65,6 @@ export interface WebRuntimeValues {
|
||||
|
||||
/** Environment variable naming the canonical local URL of this Web GUI. */
|
||||
const DSH_WEB_URL = 'DSH_WEB_URL' as const
|
||||
/** Environment variable naming the Web runtime mode. */
|
||||
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
|
||||
|
||||
// Display-only mirror of the webserver schema's loopback host: the address the
|
||||
// local URL always prints. Not a source of truth — the schema is.
|
||||
@@ -102,13 +92,10 @@ export function resolveLanTrust(bindHost: string, extra: readonly string[]): Web
|
||||
}
|
||||
|
||||
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
|
||||
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
|
||||
const updateContract = mode === 'development'
|
||||
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
|
||||
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
|
||||
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
|
||||
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
|
||||
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
|
||||
function webSurfacePrompt(webUrl: string): string {
|
||||
const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while '
|
||||
+ '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. '
|
||||
+ 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. '
|
||||
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
|
||||
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
|
||||
+ 'The browser provides no implicit DOM, route, or screenshot context. '
|
||||
@@ -140,20 +127,14 @@ function resolveDistIndex(): string {
|
||||
export const internals: { resolveDistIndex: () => string } = { resolveDistIndex }
|
||||
|
||||
/**
|
||||
* Mount the Web runtime: dist serving, surface prompt, bash runtime
|
||||
* variables, and the URL line.
|
||||
* Mount the Web runtime: dist serving, surface prompt, the bash runtime
|
||||
* variable, and the URL line.
|
||||
* @param ctx - plugin context carrying the httpServer service.
|
||||
* @param config - validated {@link Config}.
|
||||
* @returns nothing once the invocation's client roster and runtime contributions are registered.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// Client discovery must start after the optional HMR row has a pending
|
||||
// fiber. Otherwise its first browser graph omits the reload receiver, which
|
||||
// cannot use that receiver to discover itself later.
|
||||
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts)
|
||||
// Release dependent rows only after the optional row has a pending fiber and
|
||||
// bind-dependent trust has been sampled once.
|
||||
// Release dependent rows only after bind-dependent trust has been sampled once.
|
||||
ctx.provide(WEB_RUNTIME_SERVICE, runtime)
|
||||
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
||||
if (config.surfaceContext) {
|
||||
@@ -162,7 +143,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'app:web-surface',
|
||||
order: -98,
|
||||
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
|
||||
text: () => webSurfacePrompt(localWebUrl(promptCtx)),
|
||||
})
|
||||
})
|
||||
ctx.inject(['bashEnv'], (runtimeCtx) => {
|
||||
@@ -170,9 +151,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
name: 'web-runtime',
|
||||
variables: {
|
||||
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
|
||||
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
|
||||
},
|
||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }),
|
||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx) }),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The web app's command-line provider: it parses the `dsh --profile web` flag
|
||||
* family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help`
|
||||
* family (`--host`, `--port`, `--trusted-host`) and its `--help`
|
||||
* text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
|
||||
* Ordinary rows inject that service before reading it from lazy config.
|
||||
* @module @deepseek-ai/dsh-web-app/startup
|
||||
@@ -25,8 +25,6 @@ export interface WebStartupValues {
|
||||
host?: string
|
||||
/** `--port`, absent when the invocation did not name one. */
|
||||
port?: number
|
||||
/** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */
|
||||
mode: 'production' | 'development'
|
||||
/** Explicit `--trusted-host` authorities, in argument order. */
|
||||
trustedHosts: string[]
|
||||
}
|
||||
@@ -35,7 +33,6 @@ export interface WebStartupValues {
|
||||
interface WebOptions {
|
||||
host?: string
|
||||
port?: string
|
||||
dev?: boolean
|
||||
trustedHost?: string[]
|
||||
}
|
||||
|
||||
@@ -50,14 +47,12 @@ function webCommand(): Command {
|
||||
.helpOption('-h, --help', 'show this help')
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile web serve on the composed host and port
|
||||
dsh --profile web --port 8080 serve on another port
|
||||
dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN
|
||||
dsh --profile web --dev mount the client-plugin HMR receiver
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -74,7 +69,6 @@ function planWebStartup(program: Command): WebStartupValues {
|
||||
return {
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
mode: options.dev === true ? 'development' : 'production',
|
||||
trustedHosts: options.trustedHost ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
|
||||
' config:',
|
||||
" host: !!js ctx.webStartup.host ?? '127.0.0.1'",
|
||||
' port: !!js ctx.webStartup.port ?? 3080',
|
||||
' mode: !!js ctx.webStartup.mode',
|
||||
' trustedHosts: !!js ctx.webStartup.trustedHosts',
|
||||
'- id: provider',
|
||||
` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`,
|
||||
@@ -91,14 +90,12 @@ describe('web command-line provider', () => {
|
||||
const { values, observed } = await bootProvider([
|
||||
'--host', '0.0.0.0',
|
||||
'--port', '8080',
|
||||
'--dev',
|
||||
'--trusted-host', 'lab.internal', 'lab-2.internal',
|
||||
'--trusted-host', '10.0.0.9',
|
||||
])
|
||||
expect(values).toEqual({
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
mode: 'development',
|
||||
trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'],
|
||||
})
|
||||
expect(observed.readerConfig).toEqual(values)
|
||||
@@ -107,11 +104,10 @@ describe('web command-line provider', () => {
|
||||
|
||||
it('leaves deployment values to each consumer when flags omit them', async () => {
|
||||
const { values, observed } = await bootProvider([])
|
||||
expect(values).toEqual({ mode: 'production', trustedHosts: [] })
|
||||
expect(values).toEqual({ trustedHosts: [] })
|
||||
expect(observed.readerConfig).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 3080,
|
||||
mode: 'production',
|
||||
trustedHosts: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,17 +58,9 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
|
||||
return { server, seat: () => fallback }
|
||||
}
|
||||
|
||||
/** Install the optional HMR row the runtime sequences before client discovery. */
|
||||
function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] {
|
||||
const updates: string[] = []
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
enableRuntime: async () => { updates.push('client-hmr') },
|
||||
}],
|
||||
await: settle,
|
||||
} as never)
|
||||
return updates
|
||||
/** A fake Loader whose settlement the test controls (the URL line waits on it). */
|
||||
function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
|
||||
ctx.provide('loader', { await: settle } as never)
|
||||
}
|
||||
|
||||
interface BashContribution {
|
||||
@@ -90,15 +82,14 @@ describe('web-app runtime glue', () => {
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
const enabledRows = provideHmrRow(ctx)
|
||||
provideLoader(ctx)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
|
||||
apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
// Settle the injected registrations.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(seat()).toBeDefined() // frontend-static claimed the fallback
|
||||
expect(enabledRows).toEqual(['client-hmr'])
|
||||
expect(ctx.get('webRuntime')).toEqual({
|
||||
lanAddresses: ['192.168.1.5'],
|
||||
trustedHosts: ['192.168.1.5', 'lab.internal'],
|
||||
@@ -108,24 +99,26 @@ describe('web-app runtime glue', () => {
|
||||
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
|
||||
const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
|
||||
expect(section?.text).toContain('http://127.0.0.1:4567')
|
||||
expect(section?.text).toContain('--dev')
|
||||
// The single update contract: the receiver is always on; no-refresh
|
||||
// reloads additionally need the rebuild watcher.
|
||||
expect(section?.text).toContain('pnpm run dev:web')
|
||||
const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
|
||||
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' })
|
||||
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('stays quiet in production mode with printUrl off and reports the production update contract', async () => {
|
||||
it('stays quiet with printUrl off', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
||||
apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
|
||||
.toContain('without `--dev`')
|
||||
.toContain('rebuilding the affected Web artifacts')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -140,7 +133,7 @@ describe('web-app runtime glue', () => {
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
|
||||
apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
@@ -155,7 +148,7 @@ describe('web-app runtime glue', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -169,9 +162,9 @@ describe('web-app runtime glue', () => {
|
||||
settled.provide('httpServer', fakeHttpServer().server)
|
||||
let release: () => void
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
provideHmrRow(settled, () => settlement)
|
||||
provideLoader(settled, () => settlement)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
release!()
|
||||
@@ -184,8 +177,8 @@ describe('web-app runtime glue', () => {
|
||||
log.mockClear()
|
||||
const failed = new Context()
|
||||
failed.provide('httpServer', fakeHttpServer().server)
|
||||
provideHmrRow(failed, async () => { throw new Error('boot failed') })
|
||||
await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
provideLoader(failed, async () => { throw new Error('boot failed') })
|
||||
apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
await failed.fiber.dispose()
|
||||
@@ -200,8 +193,8 @@ describe('web-app runtime glue', () => {
|
||||
await child
|
||||
let releaseTorn: () => void
|
||||
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
||||
provideHmrRow(torn, () => tornSettlement)
|
||||
await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
provideLoader(torn, () => tornSettlement)
|
||||
apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
await child.dispose() // the httpServer service goes away
|
||||
releaseTorn!()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
@@ -217,7 +210,7 @@ describe('web-app runtime glue', () => {
|
||||
const { server } = fakeHttpServer()
|
||||
Object.defineProperty(server, 'port', { get: () => undefined })
|
||||
ctx.provide('httpServer', server)
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
||||
apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
|
||||
README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2
|
||||
README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef
|
||||
README.md: c355595dd53ddcb74be629a6d5e730c6c5fcebbf
|
||||
README.zh.md: 6ed4d0e79cb755f84784823749994b448ff209b8
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
|
||||
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
|
||||
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate`、`prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* mounts deliver no inotify events), reports content changes through
|
||||
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
|
||||
* broadcasting graph/rebuilt frames to the browser half (src/client/).
|
||||
* Dev-only row: prod compositions never mount this plugin.
|
||||
* The web bundle mounts this row unconditionally: without a rebuild
|
||||
* watcher rewriting client bundles, the poll observes no changes and the
|
||||
* chain stays idle.
|
||||
*/
|
||||
import { statSync } from 'node:fs'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
|
||||
Reference in New Issue
Block a user