Просмотр исходного кода

fix(session-projection): close review findings from the fold migration

- permission-presets: register the permissions unit synchronously before the
  existing-session sweep, so a remount reads folded knob state instead of
  treating every session as fresh; add a regression test for that path
- sandbox-policy/terminal-bash: mount the projection registry in the 5 pwsh
  terminal tests, the sdk-minimal bundle, and the e2b fixture composition;
  declare the new package dependency in both manifests
- plan-mode: restore .strict() on the plan unit state schema and drop the
  deleted foldPlanMode from the bilingual READMEs
- session-title/session-projection: sync bilingual READMEs to the mandatory
  projection seam and re-record translation pairing
- docs: turnBoundary reader contract, subagent schema comment, token-meter
  import comment, sandbox-policy module docstring
_Kerman 1 месяц назад
Родитель
Сommit
a6c7c70d4f

+ 5 - 0
packages/bundle/sdk-minimal/cordis.patch.yml

@@ -33,6 +33,11 @@
     - id: sandbox
       name: '@deepseek-ai/dsh-sandbox-local'
 
+    # Shared projection registry: sandbox-policy and terminal-bash fold
+    # sandbox-mode state through its units and require it as a hard injection.
+    - id: session-projection
+      name: '@deepseek-ai/dsh-session-projection'
+
     - id: sandbox-policy
       name: '@deepseek-ai/dsh-sandbox-policy'
       config:

+ 1 - 0
packages/bundle/sdk-minimal/package.json

@@ -50,6 +50,7 @@
     "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^",
     "@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-subprocess-local": "workspace:^",
     "@deepseek-ai/dsh-terminal": "workspace:^",
     "@deepseek-ai/dsh-terminal-bash": "workspace:^",

+ 2 - 1
packages/core/agent/src/types.ts

@@ -34,7 +34,8 @@ export type InboxTarget = 'next-turn' | 'next-step'
  * Reader contract: the key is registered by `dsh-agent-loop` and absent
  * otherwise. Without agent-loop no turn events exist, so readers treat an
  * absent key as "no open turn / no boundaries" — capability absence, not a
- * corrupt state — and never treat it as an error.
+ * corrupt state. A reader whose behavior has no safe fallback for that
+ * absence (the step-open decision, for example) may fail loud instead.
  */
 export interface TurnBoundaryProjection {
   /** Seq of the open turn's `turn/start`, or null between turns. */

+ 1 - 0
packages/e2b/e2b/package.json

@@ -50,6 +50,7 @@
     "@deepseek-ai/dsh-lsp-stdio": "workspace:^",
     "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-subprocess-e2b": "workspace:^",
     "@deepseek-ai/dsh-terminal": "workspace:^",
     "@deepseek-ai/dsh-terminal-bash": "workspace:^",

+ 2 - 0
packages/e2b/e2b/tests/composition.e2e.ts

@@ -14,6 +14,7 @@ import {
 import TerminalSessionService, { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
 import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
 import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { Session, SessionId } from '@deepseek-ai/dsh-session'
 import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b'
 
@@ -52,6 +53,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
         runtimeRoot: '/home/user/.dsh-e2b',
         getSandbox: async () => sandbox,
       } as never)
+      await ctx.plugin(SessionProjectionRegistry)
       const sandboxPolicyFiber = await ctx.plugin(SandboxPolicyService, {
         mode: 'danger-full-access',
         workspaceRoot: '/home/user',

+ 3 - 0
packages/e2b/e2b/tests/fixtures/composition/cordis.yml

@@ -21,6 +21,9 @@
 - id: agents
   name: '@deepseek-ai/dsh-agent'
 
+- id: session-projection
+  name: '@deepseek-ai/dsh-session-projection'
+
 - id: sandbox-policy
   name: '@deepseek-ai/dsh-sandbox-policy'
   config:

+ 17 - 17
packages/interaction/permission-presets/src/index.ts

@@ -189,13 +189,6 @@ export class PermissionPresetService extends Service {
       onChange: () => {},
     })
 
-    ctx.on('session/created', (session) => {
-      this.pinInitialPermission(session)
-    })
-    for (const session of ctx.sessions.list()) {
-      this.pinInitialPermission(session)
-    }
-
     // zod `.optional()` types the key `string | undefined` while the domain
     // says `description?: string`; on the JSON wire the two serialize
     // identically (absent), so the cast records exactly that
@@ -209,18 +202,25 @@ export class PermissionPresetService extends Service {
       currentValue: zod.string().min(1),
     }) as unknown as zod.ZodType<PermissionSelect>
     // The `permissions` projection unit folds the three whole-value knob
-    // events; it registers through the projection registry.
-    ctx.inject(['sessionProjections'], (projectionCtx) => {
-      projectionCtx.sessionProjections.register({
-        key: 'permissions',
-        stateVersion: 1,
-        stateSchema: knobStateSchema,
-        init: () => EMPTY_KNOBS,
-        apply: applyKnobEvent,
-        wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
-      })
+    // events. `sessionProjections` is a hard injection, so the registration is
+    // synchronous and lands before the sweep below; otherwise the sweep would
+    // read an unregistered key and treat every existing session as fresh.
+    ctx.sessionProjections.register({
+      key: 'permissions',
+      stateVersion: 1,
+      stateSchema: knobStateSchema,
+      init: () => EMPTY_KNOBS,
+      apply: applyKnobEvent,
+      wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
     })
 
+    ctx.on('session/created', (session) => {
+      this.pinInitialPermission(session)
+    })
+    for (const session of ctx.sessions.list()) {
+      this.pinInitialPermission(session)
+    }
+
     // The /permission command: the one write path a web client uses (the
     // popup contribution submits the picked preset as this line). The child
     // activates only when a command registry is composed.

+ 25 - 0
packages/interaction/permission-presets/tests/permission-presets.spec.ts

@@ -267,6 +267,31 @@ describe('new-session default', () => {
     expect(ctx.permissionPresets.current(existing)).toBe('workspace-write')
   })
 
+  it('preserves existing knob overrides when the service remounts over a knob-bearing session', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
+    ctx.provide('shell', {
+      sandboxMode: 'workspace-write',
+      resolve() { throw new Error('permission tests do not execute bash') },
+      run() { throw new Error('permission tests do not execute bash') },
+      start() { throw new Error('permission tests do not execute bash') },
+    })
+    ctx.provide('approval', { config: { policy: 'ask' } })
+    const existing = ctx.sessions.create(SessionId('existing-knobs'))
+    existing.append('sandbox/mode', { mode: 'read-only' })
+    existing.append('approval/policy', { policy: 'never' })
+
+    await ctx.plugin(PermissionPresetService, {})
+    // The remount sweep must read the folded knob events instead of treating
+    // the session as fresh; no default preset events may overwrite the
+    // overrides (read-only + never matches no preset table entry).
+    expect(existing.events.map(event => event.type)).toEqual([
+      'sandbox/mode', 'approval/policy',
+    ])
+    expect(ctx.permissionPresets.current(existing)).toBe(CUSTOM_PRESET)
+  })
+
   it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
     const ctx = await mountedStore()
     const partial = freshSession('partial-source')

+ 1 - 0
packages/llm/token-meter/src/index.ts

@@ -10,6 +10,7 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
 import type { LlmImageRequestPricing, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
 import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
+// Type-only: activates the `ctx.sessionProjections` Context declaration.
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type {
   TokenMeasurement,

+ 2 - 2
packages/plan/plan-mode/README.i18n.yaml

@@ -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/plan/plan-mode/README.md
-README.md: 14c24507accb642909c1c9ed050368d9fa9120f1
-README.zh.md: 716f22a7b5bf7970b1eae429155d669805ce7d00
+README.md: 063e91e40fb55ea24e4fbfcefc984cd581d065b3
+README.zh.md: 8da8a7b3e01c42e619f882105dfe9e99b891a918

+ 2 - 2
packages/plan/plan-mode/README.md

@@ -82,7 +82,7 @@ Plan mode is a product package, not a capability seam: there is no swappable bac
 
 ### Durable state and step-boundary appends
 
-The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get`/`foldPlanMode` helpers and their exact return states live in [`src/index.ts`](src/index.ts).
+The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts); the registered `plan` projection unit folds the same state for readers.
 
 ### The `/plan` command
 
@@ -94,7 +94,7 @@ The command child activates only when a commands service is composed. It maps ba
 
 ### Session projection unit
 
-When `ctx.sessionProjections` is composed, the package registers the `plan` unit under an injected child. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key.
+The package requires `ctx.sessionProjections` and registers the `plan` unit on activation. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key.
 
 ### Source map
 

+ 2 - 2
packages/plan/plan-mode/README.zh.md

@@ -82,7 +82,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
 
 ### 持久状态与步骤边界追加
 
-本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get`/`foldPlanMode` 辅助函数及其确切返回状态见 [`src/index.ts`](src/index.ts)。
+本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts);已注册的 `plan` 投影单元为读取方折叠同一状态。
 
 ### `/plan` 命令
 
@@ -94,7 +94,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
 
 ### 会话投影单元
 
-组合 `ctx.sessionProjections` 时,本包在一个注入的子插件中注册 `plan` 单元。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。
+本包要求 `ctx.sessionProjections` 并在激活时注册 `plan` 单元。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。
 
 ### 源码地图
 

+ 6 - 3
packages/plan/plan-mode/src/index.ts

@@ -113,12 +113,15 @@ export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
   return { section }
 }
 
-const planUnitStateSchema = zod.object({
+const planUnitStateSchema: ZodType<PlanUnitState> = zod.object({
   active: zod.boolean(),
   wanted: zod.boolean().nullable(),
-  running: zod.object({ commandId: zod.string() as unknown as ZodType<CommandId>, wanted: zod.boolean() }).nullable(),
+  running: zod.object({
+    commandId: zod.string() as unknown as ZodType<CommandId>,
+    wanted: zod.boolean(),
+  }).strict().nullable(),
   activeAtLastHeader: zod.boolean().nullable(),
-})
+}).strict()
 
 /** Wire payload schema of the `plan` projection. */
 const planProjectionSchema: ZodType<PlanProjection> = zod.object({

+ 3 - 1
packages/sandbox/sandbox-policy/src/index.ts

@@ -2,7 +2,9 @@
  * The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the
  * deployment's sandbox fallbacks plus per-session resolution: the file-effect
  * {@link SandboxMode}, the `workspace-write` root, and the override kit (the
- * `sandbox/mode` event, its fold, and its write path, from `./session-mode.ts`).
+ * `sandbox/mode` event, its fold, and its write path; the fold is the
+ * `sandboxMode` session-projection unit registered here, while the event and
+ * write path come from `./session-mode.ts`).
  * Before each agent request, the owner also contributes the resolved policy to
  * the cache-safe runtime-context snapshot. The agent loop logs that snapshot as
  * model history, so replay reconstructs the same mode and root the enforcing

+ 2 - 2
packages/session/session-projection/README.i18n.yaml

@@ -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/session/session-projection/README.md
-README.md: 95ee09236966c8cbfdaf79cdcb717e04ee0ec367
-README.zh.md: 0402fbe8e3c56e44f7597bb14fb0379843ecdbca
+README.md: 2ef45b42390227d4f5367ea2a4c6023b9e24b5d8
+README.zh.md: 59f0849f5d79b32bb1b5a11ca25aa9af2593ee7f

+ 1 - 1
packages/session/session-projection/README.md

@@ -29,7 +29,7 @@ Mount `dsh-session-projection` wherever client carriers need current values of l
 
 ### When to choose it
 
-Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. Skip it for host-only bookkeeping that no client reads: a unit without a `wire` block stays host-only, and headless assemblies without the registry are unaffected.
+Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. Skip it for host-only bookkeeping that no client reads: a unit without a `wire` block stays host-only. Unit contributors and readers declare `sessionProjections` in their plugin `inject`, so a composition without the registry fails activation instead of silently degrading.
 
 ### Define a projection unit
 

+ 1 - 1
packages/session/session-projection/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 何时选择
 
-当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。当维护的是无客户端读取的 host-only 记账时跳过:不带 `wire` 块的单元保持 host-only,而不带注册表的 headless 组装不受影响。
+当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。当维护的是无客户端读取的 host-only 记账时跳过:不带 `wire` 块的单元保持 host-only。单元贡献方与读取方在插件 `inject` 中声明 `sessionProjections`,因此不带注册表的组合会在激活时失败,而不是静默降级。
 
 ### 定义投影单元
 

+ 4 - 3
packages/session/session-projection/src/index.ts

@@ -171,9 +171,10 @@ interface Registration {
  * older than the registry, folds `init` over the in-memory log on first
  * touch (event or read). Registration is an effect (disposer rides the
  * calling fiber): an unloaded domain plugin's key disappears from snapshots
- * and clients read it as capability absence. Domain
- * plugins register under `ctx.inject(['sessionProjections'], …)` so headless
- * assemblies without the registry stay unaffected. Registrants sharing a key
+ * and clients read it as capability absence. Unit contributors and host
+ * readers declare `sessionProjections` in their plugin `inject`, so a
+ * composition without the registry fails activation instead of silently
+ * degrading. Registrants sharing a key
  * share one unit and are counted: the same tool package mounted in N agent
  * presets registers N times, and the key survives until the last one
  * unloads.

+ 2 - 2
packages/session/session-title/README.i18n.yaml

@@ -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/session/session-title/README.md
-README.md: 12335afc1eccd7c9b9f4a33d72549df296cbfb1b
-README.zh.md: e61297ed555c93273efc108221cb0141a9c997b7
+README.md: debc737e3f572f15856f7f78a58abe8532ae15dc
+README.zh.md: cf8079cd5566023f3dc1da8480eeec5327eebb5a

+ 1 - 1
packages/session/session-title/README.md

@@ -58,7 +58,7 @@ One optional asynchronous provider may be registered through `ctx.sessionTitle.r
 
 ### Reading titles
 
-`get(session)` folds the latest accepted title from the live or replayed log, and `foldSessionTitle(events)` is the pure fold over a log. The service also registers a `title` projection unit — the plain title string — for client list rows when a projection registry is composed. An explicit `refresh(session)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages.
+`get(session)` reads the latest folded title from one live or replayed session, and `foldSessionTitle(events)` is the pure fold over a log. The service requires `ctx.sessionProjections` and registers two units: the client-visible `title` unit (the accepted title string for client list rows) and the host-only `titleInput` unit, which folds the first and latest eligible messages plus their count so scheduling and fallback reads are O(1) through `stateOf()`; the full eligible prefix for one provider generation is scanned from the session log at execution time. An explicit `refresh(session)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages.
 
 ### Failures and recovery
 

+ 1 - 1
packages/session/session-title/README.zh.md

@@ -58,7 +58,7 @@ kind: "package-reference"
 
 ### 读取标题
 
-`get(session)` 从活跃或回放日志折叠最新已接受标题,`foldSessionTitle(events)` 是对日志的纯折叠。服务还会在组合了投影注册表时注册一个 `title` 投影单元——纯标题字符串——供客户端列表行使用。显式 `refresh(session)` 在需要时物化回退,然后对当前符合条件的消息显式运行已注册提供方。
+`get(session)` 从活跃或回放会话读取折叠出的最新标题,`foldSessionTitle(events)` 是对日志的纯折叠。服务要求 `ctx.sessionProjections` 并注册两个单元:客户端可见的 `title` 单元(供客户端列表行使用的已接受标题字符串)和仅供 host 使用的 `titleInput` 单元——后者折叠第一条与最新一条合格消息及其计数,使调度与回退读取通过 `stateOf()` 达到 O(1);某次提供方生成所需的完整合格前缀,则会在执行时从会话日志中扫描取得。显式 `refresh(session)` 在需要时物化回退,然后对当前符合条件的消息显式运行已注册提供方。
 
 ### 失败与恢复
 

+ 0 - 1
packages/subagent/subagent/src/projection.ts

@@ -41,7 +41,6 @@ const timingStateSchema: z.ZodType<TimingState> = z.object({
   settledMs: z.number().int().nonnegative(),
   active: activeIntervalSchema.optional(),
   pendingTurnStart: z.number().int().nonnegative().optional(),
-  /** Whether the fold has crossed a descriptor in this logical log. */
   descriptorSeen: z.boolean(),
 }).strict()
 

+ 5 - 0
packages/terminal/terminal-bash/tests/index.spec.ts

@@ -354,6 +354,7 @@ describe('BashTerminalBackend startup rollback', () => {
   it('bootstraps a pwsh dialect through the prompt function and scrubs bash-only env', async () => {
     const ctx = new Context()
     await ctx.plugin(EmptySandbox)
+    await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
     let spawned: SubprocessTerminalSpawnSpec | undefined
     let sent: TerminalSendRequest | undefined
@@ -391,6 +392,7 @@ describe('BashTerminalBackend startup rollback', () => {
   it('keeps waiting for stdin_read when the first settled output only echoes the prompt literal', async () => {
     const ctx = new Context()
     await ctx.plugin(EmptySandbox)
+    await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
     const sends: TerminalSendRequest[] = []
     const session = {
@@ -425,6 +427,7 @@ describe('BashTerminalBackend startup rollback', () => {
   it('rejects a pwsh bootstrap whose shell exits or times out', async () => {
     const ctx = new Context()
     await ctx.plugin(EmptySandbox)
+    await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
     const sessionFor = (waitReason: TerminalWaitReason): LocalPtySession => ({
       startSend: () => ({
@@ -449,6 +452,7 @@ describe('BashTerminalBackend startup rollback', () => {
     try {
       const ctx = new Context()
       await ctx.plugin(EmptySandbox)
+      await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
       const pending = Promise.withResolvers<{
         viewport: string
@@ -501,6 +505,7 @@ describe('BashTerminalBackend startup rollback', () => {
   it('forwards the spawn signal into the pwsh bootstrap sends', async () => {
     const ctx = new Context()
     await ctx.plugin(EmptySandbox)
+    await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
     const sends: TerminalSendRequest[] = []
     const session = {

+ 6 - 0
pnpm-lock.yaml

@@ -1345,6 +1345,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:^
         version: link:../../session/session-persistence-jsonl
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session/session-projection
       '@deepseek-ai/dsh-subprocess-local':
         specifier: workspace:^
         version: link:../../subprocess/subprocess-local
@@ -4453,6 +4456,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session/session-projection
       '@deepseek-ai/dsh-subprocess-e2b':
         specifier: workspace:^
         version: link:../subprocess-e2b