Bladeren bron

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.
Turtle 1 maand geleden
bovenliggende
commit
925daf141b

+ 3 - 0
apps/cli/package.json

@@ -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 - 0
apps/cli/src/args.ts

@@ -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
apps/cli/src/dump-config.ts

@@ -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
apps/cli/src/plugin.ts

@@ -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
apps/cli/src/profile-boot.ts

@@ -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
apps/cli/src/web.ts

@@ -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 - 0
apps/cli/tests/args.spec.ts

@@ -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
apps/cli/tests/built-bin.e2e.ts

@@ -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
apps/web/tests/scaffold.ts

@@ -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
packages/bundle/headless/cordis.patch.yml

@@ -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 - 0
packages/bundle/headless/package.json

@@ -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
packages/bundle/headless/src/index.ts

@@ -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) => {

+ 30 - 0
packages/bundle/headless/tests/headless.spec.ts

@@ -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 - 0
packages/bundle/headless/tsconfig.json

@@ -11,6 +11,9 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../../vendor/loader"
+    },
     {
       "path": "../../../vendor/schemastery"
     },

+ 25 - 15
packages/bundle/web-app/src/index.ts

@@ -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
packages/bundle/web-app/tests/web-app.spec.ts

@@ -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
packages/client/AGENTS.md

@@ -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
packages/ui/app-boot/src/profile.ts

@@ -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
packages/ui/app-boot/tests/profile.spec.ts

@@ -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 - 0
pnpm-lock.yaml

@@ -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