fix: address ds-review-bot round — insert-aliasing clones, settlement gates, closure module fallback

- Clone patch lists per generation (boot + composeLive): the include pushes
  insert rows by reference and mutates them in place, so a reused object
  baked user overrides into bundle rows and removal could not revert; the
  built-bin hot-reload e2e now asserts an override AND its removal reverting.
- The headless runner awaits Loader settlement before prompting (its inject
  gate covers only apiProxy/httpServer) and abandons cleanly when the tree
  died during the wait.
- healProfilesModuleFallback walks the app's full dependency+peer closure:
  out-of-tree plugins import seam packages (dsh-compact, dsh-subprocess, ...)
  that only implementations reach, and peers are how seams are declared.
- Profile init writes pnpm-workspace.yaml (nodeLinker: hoisted), not .npmrc
  — pnpm >=10 reads settings from the workspace manifest.
- Web dumps reject boot-only flags instead of printing a tree that differs
  from the same invocation's boot; --port validates at the flag;
  --dump-default-config no longer parses the (possibly broken) user layer;
  trustedHosts flag derivation merges over the composed value instead of
  replacing it; web-runtime gains surfaceContext (headless disables the GUI
  prompt/bash-vars the old -p never mounted); 'node_modules' is a reserved
  profile name; plugin-warning names the recovery step; client AGENTS.md
  registration surfaces point at the web-app bundle.
- Ship session-reference/tmux-context/tool-ask-user as app dependencies for
  terminal front-door patch layers (turtle-ui), same stance as mcp-client.
This commit is contained in:
Turtle
2026-08-06 09:27:44 +08:00
parent 273f27260d
commit 925daf141b
20 changed files with 240 additions and 83 deletions
+3
View File
@@ -24,6 +24,9 @@
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-tmux-context": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-web-app": "workspace:^",
+10
View File
@@ -168,9 +168,19 @@ Examples:
if (defaultOnly && patches.length > 0) {
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
}
// The dump is boot-free and does not derive flag patches; silently
// dropping them would print a tree that differs from the same
// invocation's boot.
if (options.host !== undefined || options.port !== undefined || options.dev === true
|| options.workspaceRoot !== undefined || options.trustedHost !== undefined) {
program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)')
}
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
return
}
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
resolved = {
mode: 'web',
patches,
+4 -1
View File
@@ -29,7 +29,10 @@ const NAME = 'dsh'
*/
export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
healProfilesModuleFallback(INSTALL_ANCHOR)
const loaded = loadProfile(NAME, profile, INSTALL_ANCHOR)
// The default dump never reads the user layer: it doubles as the recovery
// diagnostic for a broken cordis.patch.yml, so parsing that file here would
// defeat its purpose.
const loaded = loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: !defaultOnly })
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
label: layer.packageName,
patches: layer.patches,
+4 -1
View File
@@ -57,7 +57,10 @@ function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
for (const packageName of afterDeps) {
if (beforeDeps.has(packageName) || plugins.includes(packageName)) continue
if (!exportsPatch(packageName, profileDir)) {
process.stderr.write(`${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer\n`)
process.stderr.write(
`${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer `
+ '(if it gains one later, add it to dsh.plugins in the profile\'s package.json)\n',
)
continue
}
plugins.push(packageName)
+10 -3
View File
@@ -186,15 +186,22 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
composed.profile.layers.reduce((n, layer) => n + layer.patches.length, 0)
+ composed.profile.patches.length,
)
const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => [
// Fresh clones per generation: the include pushes `insert` rows into the
// mounted tree BY REFERENCE and later id-targeted patches mutate those
// objects in place. Reusing one parsed patch object across applications
// would bake a user override into the bundle's in-memory insert row, so
// removing the override could never revert the row to the bundle default.
const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => structuredClone([
...composed.profile.layers.flatMap(layer => layer.patches),
...profilePatches,
...overlayAndFlags,
]
])
// One-shot runs exit through the runner; watching would only hold the
// process open after its exit request.
const watchProfilePatch = options.task === undefined
const ctx = await boot(NAME, rootConfig, composed.patches, async (hostCtx) => {
// Cloned for the same insert-aliasing reason as composeLive: the boot
// application must not mutate the objects later reloads recompose from.
const ctx = await boot(NAME, rootConfig, structuredClone(composed.patches), async (hostCtx) => {
app.current = hostCtx
if (options.task !== undefined) {
const io: HeadlessIo = {
+14 -4
View File
@@ -85,7 +85,15 @@ function deriveWebFlagPatches(
if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot)
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
if (trustedHosts.length > 0) {
// Additive over the composed value: a cordis.patch.yml-configured fence
// authority must survive the derived LAN literals and flag extras — a
// silent drop of security-relevant fence configuration.
const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
}
// mode and lanAddresses are launcher-derived on every boot (--dev also
// inserts the client-hmr row), never pass-throughs of composed values.
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
put('web-runtime', 'lanAddresses', lanAddresses)
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
@@ -98,9 +106,11 @@ function deriveWebFlagPatches(
}
/**
* Serve the browser UI from the web profile. Flags are passed through only
* when given; absent, the composed profile values stand. The URL line is
* printed by the web-app bundle's runtime row after Loader settlement.
* Serve the browser UI from the web profile. Host/port/workspace-root flags
* are passed through only when given (absent, the composed profile values
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
* every boot. The URL line is printed by the web-app bundle's runtime row
* after Loader settlement.
* @param flags - the parsed `dsh web` flag family.
*/
export async function runWeb(flags: WebFlags): Promise<void> {
+6
View File
@@ -76,6 +76,12 @@ describe('parseDshArgs', () => {
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
expect(exitCode(['web', '--patch='])).toBe(1)
// Boot-free dumps derive no flag patches; silently dropping the flags
// would print a tree that differs from the same invocation's boot.
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1)
// A non-numeric port fails at the flag, not deep in the webserver schema.
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
+20 -4
View File
@@ -55,11 +55,15 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
mkdirSync(bundleDir, { recursive: true })
writeFileSync(join(bundleDir, 'plugin.mjs'), [
"import { writeFileSync } from 'node:fs'",
"import { join } from 'node:path'",
"export const name = 'profile-lifecycle-fixture'",
'export function apply(ctx) {',
'export function apply(ctx, config = {}) {',
' let active = true',
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
' const heartbeat = setInterval(() => {}, 1000)',
' // Echo the mounted generation so the hot-reload e2e can assert both an',
' // applied override and its removal reverting to this bundle default.',
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
' void ctx.loader.await().then(() => {',
" if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
@@ -166,24 +170,36 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('fully settles a custom profile, hot-reloads its patch layer, and disposes on a signal', async () => {
it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
const fixture = createProfileLifecycleFixture()
const child = startProfileLifecycle(fixture)
const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
const configFile = join(fixture.home, 'config-echo')
try {
await waitForFile(fixture.settled)
// The live profile layer: even without an hmr row in the composition,
// the launcher mounts a config-only watcher, so an edited
// cordis.patch.yml lands in the running tree (the reload disposes the
// patched row's old fiber — observable as the disposed marker — and
// mounts the new config, which re-writes the ready marker).
// mounts the new config, which echoes its generation and re-writes the
// ready marker).
rmSync(fixture.ready)
writeFileSync(join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml'), [
writeFileSync(profilePatch, [
'- id: profile-lifecycle-fixture',
' config:',
' generation: 2',
'',
].join('\n'))
await waitForFile(fixture.ready)
expect(readFileSync(configFile, 'utf8')).toBe('2')
// Removal reverts: the bundle's inserted row must return to its own
// default config, not keep the removed override — the insert-aliasing
// regression (a shared patch object mutated in place by a former
// generation would make this impossible).
rmSync(fixture.ready)
writeFileSync(profilePatch, '[]\n')
await waitForFile(fixture.ready)
expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
child.kill('SIGTERM')
const result = await child
expect(result.exitCode).toBe(0)
+3 -2
View File
@@ -1,7 +1,8 @@
// Shared scaffold for the keyless browser e2e lane (Agent Note:
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
// Boots the REAL web composition — the shipped base plus web overlay through
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
// Boots the REAL web composition — the dsh-base and dsh-web-app bundle
// patches over the empty profile root through the vendored Loader (the same
// layer stack the profile boot composes), patched the
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// replay (default, keyless: normally disables the llm-deepseek row and
+3 -1
View File
@@ -1,6 +1,7 @@
# The dsh-headless bundle patch: one-shot task mode over dsh-base +
# dsh-web-app. The web composition stays mounted (the session is observable
# in a browser while it runs); this layer silences the URL line, moves the
# in a browser while it runs); this layer silences the URL line and the
# GUI-orientation surface context (this user is not in the GUI), moves the
# webserver to an OS-assigned port so parallel headless runs never collide,
# and mounts the one-shot runner. The launcher patches the runner's `task`.
@@ -13,6 +14,7 @@
config:
mode: production
printUrl: false
surfaceContext: false
- insert:
- id: headless-runner
+1
View File
@@ -41,6 +41,7 @@
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
+12 -3
View File
@@ -17,6 +17,8 @@ import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apipro
// Empty type imports carry the httpServer and agent/status Context merges used below.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
// Empty type import carries the loader Context merge for the settlement await.
import type {} from '@cordisjs/plugin-loader'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -136,13 +138,20 @@ export function apply(ctx: Context, config: Config): void {
// Fire-and-forget by design: the run outlives plugin activation, and every
// failure path inside ends in io.exit, not a rejection.
void (async () => {
// The Loader mounts sibling rows concurrently and this plugin's inject
// gate covers only apiProxy/httpServer; prompting before the agent loop,
// adapters, and tools settle would fail the turn on a half-mounted tree.
// The old launcher ran strictly after settled boot — preserve that.
// A tree disposed mid-settlement (early SIGTERM) has nothing to run.
await ctx.get('loader')?.await()
if (ctx.get('httpServer') === undefined) return
// The headless session is web-observable while it runs (same composition).
io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), io)
// Open the stream before prompting so no frame is lost — kept in this
// order even though in-process delivery has no race, so the code survives
// a move to a remote HTTP carrier unchanged.
// Open the stream before prompting so no frame is lost. The quiescence
// anchor below is an in-process ctx subscription, so a remote-carrier
// port of this runner must replace it with a wire-visible idle signal.
const abort = new AbortController()
const frames = api.events.mux({}, abort.signal)
const idle = new Promise<void>((resolve) => {
@@ -174,6 +174,36 @@ describe('headless runner', () => {
await ctx.fiber.dispose()
})
it('waits for Loader settlement and abandons the run when the tree died during it', async () => {
const ctx = new Context()
let err = ''
let exited = false
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: () => { exited = true },
} satisfies HeadlessIo)
ctx.provide('apiProxy', scriptedApi([]) as never)
// The webserver is provided by a child fiber whose disposal (early
// SIGTERM during the boot window) removes the service; settlement
// resolves only afterwards, and the runner must abandon rather than
// crash on the torn-down port read.
const webserverFiber = ctx.plugin((childCtx: Context) => {
childCtx.provide('httpServer', { port: 1 } as never)
})
await webserverFiber
let release: () => void
const settlement = new Promise<void>((resolve) => { release = resolve })
ctx.provide('loader', { await: () => settlement } as never)
apply(ctx, { task: 't' })
await webserverFiber.dispose()
release!()
await new Promise(resolve => setTimeout(resolve, 10))
expect(err).toBe('')
expect(exited).toBe(false)
await ctx.fiber.dispose()
})
it('fails loud without the launcher-owned headlessIo seam', () => {
const ctx = new Context()
ctx.provide('apiProxy', scriptedApi([]) as never)
+3
View File
@@ -11,6 +11,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/schemastery"
},
+25 -15
View File
@@ -34,6 +34,13 @@ export interface Config {
mode: WebMode
/** Print the URL line on activation; a headless layer over this bundle turns 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
* layer turns it off: its user is not interacting through the GUI, so the
* orientation text would be false.
*/
surfaceContext: boolean
/**
* LAN IPv4 addresses sampled once by the launcher when the effective bind
* is all-interfaces — the exact snapshot the /api trust fence was
@@ -46,6 +53,7 @@ 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),
lanAddresses: z.array(String).default([]),
})
@@ -104,23 +112,25 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
ctx.inject(['systemPrompt'], (promptCtx) => {
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
if (config.surfaceContext) {
ctx.inject(['systemPrompt'], (promptCtx) => {
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
})
})
})
ctx.inject(['bashEnv'], (runtimeCtx) => {
runtimeCtx.bashEnv.register({
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 }),
ctx.inject(['bashEnv'], (runtimeCtx) => {
runtimeCtx.bashEnv.register({
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 }),
})
})
})
}
if (config.printUrl) {
// The URL line is a readiness signal: supervisors (and the keyless CLI
// smoke) RPC as soon as they observe it, so it must not print while
+26 -6
View File
@@ -69,7 +69,7 @@ describe('web-app runtime glue', () => {
},
} as never)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ mode: 'development', printUrl: true, lanAddresses: ['192.168.1.5'] }))
apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] }))
await ctx.plugin(SystemPrompt, { persona: '' })
// Settle the injected registrations.
await new Promise(resolve => setTimeout(resolve, 0))
@@ -90,7 +90,7 @@ describe('web-app runtime glue', () => {
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ mode: 'production', printUrl: false, lanAddresses: [] }))
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
@@ -100,12 +100,32 @@ describe('web-app runtime glue', () => {
await ctx.fiber.dispose()
})
it('skips the surface context when disabled (the one-shot layer): no prompt section, no bash variables', async () => {
stageDist()
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const contributions: BashContribution[] = []
ctx.provide('bashEnv', {
register: (contribution: BashContribution) => {
contributions.push(contribution)
return () => {}
},
} as never)
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false)
expect(contributions).toEqual([])
await ctx.fiber.dispose()
})
it('prints the loopback-only URL line when no LAN snapshot exists', async () => {
stageDist()
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(ctx, new Config({ mode: 'production', printUrl: true, lanAddresses: [] }))
apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
await ctx.fiber.dispose()
@@ -121,7 +141,7 @@ describe('web-app runtime glue', () => {
const settlement = new Promise<void>((resolve) => { release = resolve })
settled.provide('loader', { await: () => settlement } as never)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
apply(settled, new Config({ mode: 'production', printUrl: true, lanAddresses: [] }))
apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled()
release!()
@@ -140,7 +160,7 @@ describe('web-app runtime glue', () => {
let releaseTorn: () => void
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
torn.provide('loader', { await: () => tornSettlement } as never)
apply(torn, new Config({ mode: 'production', printUrl: true, lanAddresses: [] }))
apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] }))
await child.dispose() // the httpServer service goes away
releaseTorn!()
await new Promise(resolve => setTimeout(resolve, 0))
@@ -156,7 +176,7 @@ describe('web-app runtime glue', () => {
const { server } = fakeHttpServer()
Object.defineProperty(server, 'port', { get: () => undefined })
ctx.provide('httpServer', server)
apply(ctx, new Config({ mode: 'production', printUrl: false, lanAddresses: [] }))
apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] }))
await ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
+1 -1
View File
@@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/config/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
+51 -40
View File
@@ -49,6 +49,7 @@ export interface DshManifestSection {
export interface ProfileManifest {
name?: string
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
dsh?: DshManifestSection
}
@@ -85,7 +86,9 @@ export interface Profile {
* @returns the absolute profile directory (which may not exist yet).
*/
export function resolveProfileDir(name: string, home: string = resolveDshHome()): string {
if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..'
// The launcher-maintained flat module fallback lives at this sibling path.
|| name === 'node_modules') {
throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`)
}
return join(home, PROFILES_DIR, name)
@@ -109,9 +112,13 @@ const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied
// The hoisted linker gives out-of-tree plugins a flat node_modules whose
// missing peers (cordis and friends) fall through to the healed
// profiles/node_modules installation fallback, so every plugin shares the
// installation's single cordis instance instead of a duplicate.
const PROFILE_NPMRC = `node-linker=hoisted
auto-install-peers=false
// installation's single cordis instance instead of a duplicate. pnpm ≥10
// reads its settings from pnpm-workspace.yaml, not .npmrc.
const PROFILE_PNPM_WORKSPACE = `packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
`
/**
@@ -138,8 +145,8 @@ export function initProfile(dir: string, plugins: readonly string[]): void {
}
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
const npmrcPath = join(dir, '.npmrc')
if (!existsSync(npmrcPath)) writeFileSync(npmrcPath, PROFILE_NPMRC)
const workspacePath = join(dir, 'pnpm-workspace.yaml')
if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE)
}
/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */
@@ -176,17 +183,20 @@ function ensureSymlink(link: string, target: string): void {
/**
* Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one
* symlink per package that the dsh app and each of its in-box bundle
* dependencies declare, resolved from their own real locations. Node's
* parent-directory walk from any profile finds this directory after the
* profile's own `node_modules`, so every in-box plugin (and its host-shared
* peers like cordis) resolves without pnpm ever managing it — the exact
* "bundles come from the installation" contract. Symlinked packages resolve
* their own dependencies from their real directories (Node's default
* symlink-following), so only this first hop needs maintaining. Idempotent:
* correct links are kept and moved installations are re-pointed; a stale
* link to a vanished package stays until its name is reused (dangling links
* are invisible to resolution).
* symlink per package in the dsh app's resolvable dependency CLOSURE (BFS
* over `dependencies` from the app manifest), each resolved from its own
* real location. Node's parent-directory walk from any profile finds this
* directory after the profile's own `node_modules`, so every in-box plugin
* resolves without pnpm ever managing it — the exact "bundles come from the
* installation" contract. The closure (not just direct dependencies) is
* required for out-of-tree plugins: their peer dependencies name seam
* packages (`dsh-compact`, `dsh-invariants`, ...) that the app reaches only
* through its implementation packages. Symlinked packages resolve their own
* dependencies from their real directories (Node's default
* symlink-following), so each package needs only its one flat link.
* Idempotent: correct links are kept and moved installations are
* re-pointed; a stale link to a vanished package stays until its name is
* reused (dangling links are invisible to resolution).
* @param installAnchor - absolute path of the dsh app's package.json.
* @param home - the Harness home; defaults to {@link resolveDshHome}.
*/
@@ -194,32 +204,27 @@ export function healProfilesModuleFallback(installAnchor: string, home: string =
const profilesDir = join(home, PROFILES_DIR)
const modulesDir = join(profilesDir, 'node_modules')
mkdirSync(modulesDir, { recursive: true })
// The app manifest plus every resolvable direct dependency's manifest that
// itself declares a dsh patch (a bundle): their dependency names form the
// fallback surface.
const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest
const anchors: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
/* v8 ignore next -- a real app manifest always declares dependencies */
for (const dep of Object.keys(appManifest.dependencies ?? {})) {
const dir = packageDirFromAnchor(installAnchor, dep)
if (dir === undefined) continue // declared but not installed — nothing to mirror
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as ProfileManifest
if (manifest.dsh?.patch !== undefined) anchors.push({ anchor: join(dir, 'package.json'), manifest })
}
const links = new Map<string, string>()
for (const { anchor, manifest } of anchors) {
/* v8 ignore next -- bundle anchors reach here only with a dependencies map */
for (const dep of Object.keys(manifest.dependencies ?? {})) {
/* v8 ignore next -- a real app manifest always declares its name */
if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor))
// BFS over the resolvable dependency graph; the visited set is the link
// map itself (first resolution wins, matching Node's own nearest-wins).
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
// Peer dependencies participate: seam packages (dsh-subprocess,
// dsh-compact, ...) are peers of their implementations, never plain
// dependencies, yet out-of-tree plugins import them directly.
/* v8 ignore next -- a real app manifest always declares dependencies */
for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) {
if (links.has(dep)) continue
const dir = packageDirFromAnchor(anchor, dep)
const dir = packageDirFromAnchor(next.anchor, dep)
// A declared-but-uninstalled dependency cannot be a loader-visible
// plugin; skip it rather than fail the whole boot.
if (dir !== undefined) links.set(dep, dir)
}
// The anchor package itself is part of the surface (a profile may list it
// in dsh.plugins or a row may name it).
if (manifest.name !== undefined && !links.has(manifest.name)) {
links.set(manifest.name, dirname(anchor))
if (dir === undefined) continue
links.set(dep, dir)
const manifestPath = join(dir, 'package.json')
queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest })
}
}
for (const [packageName, target] of links) {
@@ -319,10 +324,14 @@ export function resolveBundleDir(
* @param name - the profile name.
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
* @param home - the Harness home; defaults to {@link resolveDshHome}.
* @returns the loaded profile.
* @param options - `userLayer: false` skips reading `cordis.patch.yml`, so a
* bundles-only consumer (`--dump-default-config`, a recovery diagnostic)
* cannot fail on a broken user layer.
* @returns the loaded profile (empty `patches` when the user layer is skipped).
*/
export function loadProfile(
binName: string, name: string, installAnchor: string, home: string = resolveDshHome(),
options: { userLayer?: boolean } = {},
): Profile {
const dir = resolveProfileDir(name, home)
if (!existsSync(join(dir, 'package.json'))) {
@@ -348,7 +357,9 @@ export function loadProfile(
return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
})
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
const patches = existsSync(patchPath) ? loadOverlayPatches(binName, patchPath) : []
const patches = options.userLayer !== false && existsSync(patchPath)
? loadOverlayPatches(binName, patchPath)
: []
return { name, dir, layers, patchPath, patches }
}
+2 -2
View File
@@ -56,14 +56,14 @@ describe('resolveProfileDir', () => {
})
describe('initProfile', () => {
it('creates manifest, user patch layer, and npmrc once, never overwriting', () => {
it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => {
const home = tmp()
const dir = resolveProfileDir('tui', home)
initProfile(dir, ['@deepseek-ai/dsh-base'])
const manifest = readProfileManifest('t', dir)
expect(manifest.dsh?.plugins).toEqual(['@deepseek-ai/dsh-base'])
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
expect(readFileSync(join(dir, '.npmrc'), 'utf8')).toContain('node-linker=hoisted')
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
// Re-init keeps user edits.
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
initProfile(dir, ['other'])
+12
View File
@@ -161,6 +161,15 @@ importers:
'@deepseek-ai/dsh-pty-local':
specifier: workspace:^
version: link:../../packages/pty/pty-local
'@deepseek-ai/dsh-session-reference':
specifier: workspace:^
version: link:../../packages/context/session-reference
'@deepseek-ai/dsh-tmux-context':
specifier: workspace:^
version: link:../../packages/context/tmux-context
'@deepseek-ai/dsh-tool-ask-user':
specifier: workspace:^
version: link:../../packages/ui/tool-ask-user
'@deepseek-ai/dsh-tool-bash-persistent':
specifier: workspace:^
version: link:../../packages/pty/tool-bash-persistent
@@ -1053,6 +1062,9 @@ importers:
specifier: ^3.18.0
version: link:../../../vendor/schemastery
devDependencies:
'@cordisjs/plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent