Forráskód Böngészése

Merge remote-tracking branch 'origin/master' into xtr/durable-inbox-recovery

# Conflicts:
#	packages/api/session-controller/src/control.ts
_Kerman 1 hónapja
szülő
commit
35371d0f59

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: a7e2839c8716fe19db8effc4820a0817cbf013d5
-config-catalog.zh.md: dcd36c41ca471ccbae1b82dfb5a462db78e7a0a5
+config-catalog.md: 2ed8dd593e69b00d659ac71e96e856456a5e9185
+config-catalog.zh.md: dd22bea8e2f47179d9458cfdba1f74726fb78ae2

+ 3 - 3
docs/config-catalog.md

@@ -779,7 +779,7 @@ Source: [`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src
 
 ## `@deepseek-ai/dsh-goal`
 
-Requires: `agents`
+Requires: `agents` · `sessionProjections`
 
 ```ts config-catalog
 /** Deployment defaults for goal creation. */
@@ -1531,7 +1531,7 @@ Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedb
 
 ## `@deepseek-ai/dsh-permission-presets`
 
-Requires: `shell` · `approval` · `sessions`
+Requires: `shell` · `approval` · `sessions` · `sessionProjections`
 
 ```ts config-catalog
 /** The {@link PermissionPresetService} config: preset table and composition default. */
@@ -1594,7 +1594,7 @@ Source: [`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/s
 
 ## `@deepseek-ai/dsh-plan-mode`
 
-Requires: `tools` · `systemPrompt`
+Requires: `tools` · `systemPrompt` · `sessionProjections`
 
 ```ts config-catalog
 /** Deployment-owned plan guidance. */

+ 3 - 3
docs/config-catalog.zh.md

@@ -781,7 +781,7 @@ export type Config = LocalConfig
 
 ## `@deepseek-ai/dsh-goal`
 
-需要:`agents`
+需要:`agents` · `sessionProjections`
 
 ```ts config-catalog
 /** Deployment defaults for goal creation. */
@@ -1533,7 +1533,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-permission-presets`
 
-需要:`shell` · `approval` · `sessions`
+需要:`shell` · `approval` · `sessions` · `sessionProjections`
 
 ```ts config-catalog
 /** The {@link PermissionPresetService} config: preset table and composition default. */
@@ -1596,7 +1596,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-plan-mode`
 
-需要:`tools` · `systemPrompt`
+需要:`tools` · `systemPrompt` · `sessionProjections`
 
 ```ts config-catalog
 /** Deployment-owned plan guidance. */

+ 22 - 27
packages/api/session-controller/src/control.ts

@@ -4,7 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { Agent, InboxState } from '@deepseek-ai/dsh-agent'
 import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
 import type {
-  JsonValue, Session, SessionId,
+  JsonValue, Session, SessionId, UserMessage,
 } from '@deepseek-ai/dsh-session'
 import type {
   SessionControlBaseline,
@@ -21,23 +21,21 @@ export class SessionControlController {
 
   /** @param ctx - Host context carrying live Agent, projection, and jobs services. */
   constructor(private readonly ctx: Context) {
-    ctx.inject(['sessionProjections'], (projectionCtx) => {
-      projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
-        this.broadcast({
-          type: 'projection',
-          sessionId: session.id,
-          key,
-          value: value as JsonValue,
-          seq,
-        })
-        if (key !== 'inbox') return
-        const agent = this.ctx.agents.get(session.id)
-        if (agent?.session !== session) return
-        this.broadcast({
-          type: 'queue',
-          sessionId: session.id,
-          items: queueItemsFromInbox(value as InboxState),
-        })
+    ctx.sessionProjections.onChanged((session, key, value, seq) => {
+      this.broadcast({
+        type: 'projection',
+        sessionId: session.id,
+        key,
+        value: value as JsonValue,
+        seq,
+      })
+      if (key !== 'inbox') return
+      const agent = this.ctx.agents.get(session.id)
+      if (agent?.session !== session) return
+      this.broadcast({
+        type: 'queue',
+        sessionId: session.id,
+        items: queueItemsFromInbox(value as InboxState),
       })
     })
     ctx.inject(['jobs'], (jobsCtx) => {
@@ -90,17 +88,14 @@ export class SessionControlController {
   private projectionBaseline(
     sessions: readonly Session[],
   ): Readonly<Record<SessionId, SessionProjectionBaseline>> {
-    const registry = this.ctx.get('sessionProjections')
     const blocks = Object.create(null) as Record<SessionId, SessionProjectionBaseline>
     for (const session of sessions) {
-      const snapshot = registry?.snapshot(session)
-      blocks[session.id] = snapshot === undefined
-        ? { asOfSeq: session.seq - 1, values: {} }
-        : {
-          asOfSeq: snapshot.asOfSeq,
-          // Every projection definition validates its value before snapshot publication.
-          values: snapshot.values as SessionProjectionValues,
-        }
+      const snapshot = this.ctx.sessionProjections.snapshot(session)
+      blocks[session.id] = {
+        asOfSeq: snapshot.asOfSeq,
+        // Every projection definition validates its value before snapshot publication.
+        values: snapshot.values as SessionProjectionValues,
+      }
     }
     return blocks
   }

+ 11 - 13
packages/api/session-controller/src/list.ts

@@ -87,25 +87,23 @@ export class ApiSessionList {
     private readonly ctx: Context,
     private readonly coldBlankProbeMaxBytes: number,
   ) {
-    ctx.inject(['sessionProjections'], (projectionCtx) => {
-      projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
-        key: 'sessionListMetadata',
-        stateSchema: sessionListMetadataSchema,
-        init: () => ({ blank: true, lastPromptAt: null }),
-        apply: applySessionListMetadata,
-        wire: { viewSchema: sessionListMetadataSchema, view: state => state },
-        stateVersion: 1,
-      })
+    ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
+      key: 'sessionListMetadata',
+      stateSchema: sessionListMetadataSchema,
+      init: () => ({ blank: true, lastPromptAt: null }),
+      apply: applySessionListMetadata,
+      wire: { viewSchema: sessionListMetadataSchema, view: state => state },
+      stateVersion: 1,
     })
-    ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => {
-      projectionCtx.sessionProjections.register<'imageLimits', null>({
+    ctx.inject(['attachments'], (attachmentCtx) => {
+      ctx.sessionProjections.register<'imageLimits', null>({
         key: 'imageLimits',
         stateSchema: z.null(),
         init: () => null,
         apply: state => state,
         wire: {
           viewSchema: imageLimitsSchema,
-          view: () => projectionCtx.attachments.imageLimits,
+          view: () => attachmentCtx.attachments.imageLimits,
         },
         stateVersion: 1,
       })
@@ -332,7 +330,7 @@ export class ApiSessionList {
     try {
       const block = session === undefined
         ? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header)
-        : this.ctx.get('sessionProjections')?.cachedSnapshot(session)
+        : this.ctx.sessionProjections.cachedSnapshot(session)
       return block !== undefined && Object.keys(block.values).length > 0
         ? {
           asOfSeq: block.asOfSeq,

+ 3 - 2
packages/api/session-controller/tests/control-jobs.host.spec.ts

@@ -28,7 +28,7 @@ function producer(label = 'sleep 60') {
   return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } }
 }
 
-async function harness(withRegistry: boolean): Promise<{
+async function harness(withJobs: boolean): Promise<{
   ctx: Context
   session: Session
   agent: Agent
@@ -38,7 +38,8 @@ async function harness(withRegistry: boolean): Promise<{
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(AgentRegistry)
-  if (withRegistry) {
+  await ctx.plugin(SessionProjectionRegistry)
+  if (withJobs) {
     await ctx.plugin(LocalJobRegistry)
     ctx.jobs.attachController('session-controller-test')
   }

+ 1 - 0
packages/api/session-controller/tests/control-queue.host.spec.ts

@@ -19,6 +19,7 @@ async function harness(): Promise<{
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(AgentRegistry)
+  await ctx.plugin(SessionProjectionRegistry)
   const session = ctx.sessions.create(SessionId('queue-session'))
   const agent = { id: session.id, session, inbox: undefined as never, status: 'running', ctx } as unknown as Agent
   Object.assign(agent, { inbox: createInboxFixture(ctx.sessionProjections, session).inbox })

+ 0 - 16
packages/api/session-controller/tests/session-projections.host.spec.ts

@@ -26,7 +26,6 @@ import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/
 import Storage from '@deepseek-ai/dsh-storage'
 import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
 import * as StorageJson from '@deepseek-ai/dsh-storage-json'
-import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts'
 import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
 import { createInboxFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { createSessionTestRemote, testSessionPersistence, type TestSessionRemote } from './test-remote.ts'
@@ -667,19 +666,4 @@ describe('Session control projection frames', () => {
     const tail = await opening(proxy, session.id)
     expect(tail.projections.asOfSeq).toBe(pushes.at(-1)?.seq)
   })
-
-  it('emits no projection frames when the composition has no registry', async () => {
-    const { ctx, session } = await harness(false)
-    const control = new SessionControlController(ctx)
-    const abort = new AbortController()
-    const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
-    const baseline = await iterator.next()
-    const next = iterator.next()
-    seedMessages(session, 2)
-    await new Promise(resolve => setTimeout(resolve, 0))
-    abort.abort()
-    if (baseline.done) throw new Error('Control stream ended before its baseline')
-    expect(baseline.value.type).toBe('baseline')
-    await expect(next).resolves.toEqual({ done: true, value: undefined })
-  })
 })

+ 2 - 0
packages/api/session-controller/tests/session-search.host.spec.ts

@@ -10,6 +10,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
 import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import {
   SessionQueryEngine,
   SessionQueryError,
@@ -56,6 +57,7 @@ async function baseContext(): Promise<Context> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
+  await ctx.plugin(SessionProjectionRegistry)
   return ctx
 }
 

+ 2 - 2
packages/goal/goal/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/goal/goal/README.md
-README.md: 1d1fb17db63c15cba2a0e4244352ad566314d355
-README.zh.md: a96a574894b4e080605418d3055802fd76080bf1
+README.md: 119c5e246494c7cb14c84b18e03ca04155f2158c
+README.zh.md: f17d9fa6b84fff50c3c7263c92725959bde99775

+ 5 - 1
packages/goal/goal/README.md

@@ -47,6 +47,10 @@ Load the package with a composition entry; the only deployment choice is the def
 
 `defaultMaxGoalRounds` must be a positive safe integer; a create request that names its own cap overrides it. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-goal) is the exhaustive source for every accepted field.
 
+### Session projection
+
+`GoalService` requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `goal` projection unit at startup; a composition that omits the projection registry cannot activate `ctx.goals`. The unit's version 6 host state retains the latest valid current goal, every previously used goal id, and the first strict replay failure. Its client view exposes the current goal or `null` before the first create and after a clear tombstone. The key merges into both `SessionProjectionStateMap` and `SessionProjectionMap`; carriers serve the client value on the history tail page and the `session/projection` push frame.
+
 ### Drive the lifecycle
 
 A goal moves through four durable phases — `active`, `paused`, `blocked`, `complete` — plus a process-local flag that says whether automatic continuation is armed. The verbs:
@@ -94,7 +98,7 @@ This section explains how the service realizes the behavior above; the observabl
 - **Compare-and-set mutations.** `ctx.goals` accepts only the exact live `Agent` registered under its id. `get()` returns a detached `GoalView`; mutations take a `GoalRef { id, revision }` and reject stale refs. Creation resolves the deployment default internally before committing.
 - **Activation is process-local.** `armed` and `disarmed` live in a per-session cache and are never persisted. A fresh cache and every `agent/session-start` edge disarm continuation even when replay finds an active durable phase; `disarm()` removes authority without writing a revision or emitting a mutation.
 - **Strict replay.** The fold derives lifecycle mutations only from `goal/change` and rejects malformed shapes, discontinuous revisions, illegal phase transitions, non-monotonic per-goal timestamps, and non-sequential admitted rounds. Positive rounds advance only on admitted goal-sourced `user/message` events, and mutation timestamps clamp against the preceding update when wall time moves backward.
-- **Projection unit.** The package registers a last-wins `goal` projection (the whole current goal, or `null`) that activates only when a projection registry is composed. `GoalService` reads this unit and fails on the first dependent access if the registry or key is absent.
+- **Projection unit.** The package requires the projection registry and registers a strict `goal` unit. Its host state retains replay validation data and the first failure, while its client view exposes the latest valid whole goal or `null`; `GoalService` rejects access after a retained replay failure.
 
 ### Source map
 

+ 5 - 1
packages/goal/goal/README.zh.md

@@ -47,6 +47,10 @@ goal 适合一个需要跨自动 Goal Round 持续的长期完成目标——例
 
 `defaultMaxGoalRounds` 必须是正的安全整数;指定了自身上限的 create 请求会覆盖它。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-goal)是每个受支持字段的穷尽式真源。
 
+### 会话投影
+
+`GoalService` 要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.zh.md)),并在启动时注册 `goal` 投影单元;未组合投影注册表的组合无法激活 `ctx.goals`。该单元版本为 6,其宿主状态保留最新的有效当前 goal、所有曾使用的 goal id,以及第一次严格回放失败。客户端 view 提供当前 goal;首次 create 前与 clear tombstone 后为 `null`。该 key 同时合并到 `SessionProjectionStateMap` 与 `SessionProjectionMap`;载体通过历史尾页和 `session/projection` 推送帧提供客户端值。
+
 ### 驱动生命周期
 
 goal 经历四种持久 phase——`active`、`paused`、`blocked`、`complete`——外加一个进程本地标志,表示自动续行是否已启用。动词如下:
@@ -94,7 +98,7 @@ view.activation                        // 'armed' | 'disarmed' — not persisted
 - **比较并设置的变更。** `ctx.goals` 只接受以对应 id 注册的完全相同的活跃 `Agent` 实例。`get()` 返回脱离状态的 `GoalView`;变更携带 `GoalRef { id, revision }` 并拒绝陈旧引用。创建在提交前于内部解析部署默认值。
 - **续行启用状态是进程本地的。** `armed` 与 `disarmed` 保存在每会话缓存中,绝不持久化。新缓存与每次 `agent/session-start` 边界都会停用续行,即使回放发现持久 phase 为 active;`disarm()` 移除续行权限,不写入 revision 也不发出变更事件。
 - **严格回放。** 折叠只从 `goal/change` 派生生命周期变更,并拒绝形状错误、不连续 revision、非法 phase 转换、每目标时间戳非单调,以及不连续的已准入 Round。只有已准入的来源为 goal 的 `user/message` 事件会推进正数 Round;挂钟时间倒退时,变更时间戳会限制在不早于上一次更新的值。
-- **投影单元。** 本包注册一个 last-wins 的 `goal` 投影(完整当前 goal,或 `null`),仅当组合了投影注册表时才激活。`GoalService` 读取该单元;注册表或 key 缺失时,第一次依赖它们的访问会失败。
+- **投影单元。** 本包要求提供投影注册表,并注册一个严格的 `goal` 单元。其宿主状态保留回放校验数据与第一次失败,客户端 view 提供最新有效的完整 goal 或 `null`;保留回放失败后,`GoalService` 会拒绝访问。
 
 ### 源码地图
 

+ 5 - 9
packages/goal/goal/src/index.ts

@@ -49,7 +49,7 @@ import type {
 // The pure payload outlet (./types.ts, ONE home of the `goal` projection-key
 // declaration) re-exported onto the package root keeps the module edge in
 // the emitted index.d.ts, so aggregate programs consuming the declarations
-// still receive the SessionProjectionMap merge.
+// still receive the SessionProjectionStateMap merge.
 export type * from './types.ts'
 export type * from './domain.ts'
 export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
@@ -234,7 +234,7 @@ function resolveBlockReason(reason: unknown): GoalBlockReason {
 
 /** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
 export class GoalService extends TypertRemoteService {
-  static inject = ['agents']
+  static inject = ['agents', 'sessionProjections']
 
   static Config: z<Config> = z.object({
     defaultMaxGoalRounds: z.number().default(256),
@@ -251,9 +251,7 @@ export class GoalService extends TypertRemoteService {
     ctx.on('agent/session-start', ({ agent }) => {
       this.runtimeState(agent.session).activation = 'disarmed'
     })
-    ctx.inject(['sessionProjections'], (projectionCtx) => {
-      projectionCtx.sessionProjections.register(goalProjectionDefinition)
-    })
+    ctx.sessionProjections.register(goalProjectionDefinition)
     ctx.on('session/event', (session, event) => {
       if (event.type !== 'goal/change') return
       const runtime = this.runtimeState(session)
@@ -467,11 +465,9 @@ export class GoalService extends TypertRemoteService {
     }
   }
 
-  /** Read the current durable projection or fail at the first service access. */
+  /** Read the current durable projection maintained by the registry. */
   private state(session: Session): GoalProjection | null {
-    const projections = this.ctx.get('sessionProjections')
-    if (projections === undefined) throw new Error('goal: session projection registry is unavailable')
-    const state = projections.stateOf(session, 'goal')
+    const state = this.ctx.sessionProjections.stateOf(session, 'goal')
     if (state === undefined) throw new Error('goal projection is not registered')
     if (state.failure !== null) throw new Error(state.failure)
     return state.current

+ 2 - 5
packages/goal/goal/tests/goal.spec.ts

@@ -103,13 +103,11 @@ function appendRound(session: Session, ref: GoalRef, round: number): void {
 }
 
 describe('GoalService creation and replay', () => {
-  it('fails on first state access when the projection registry is absent', async () => {
+  it('does not activate without the required projection registry', async () => {
     const ctx = new Context()
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(GoalService)
-    const stub = stubAgent('goal-missing-projections')
-    ctx.agents.register(stub.agent)
-    expect(() => ctx.goals.get(stub.agent)).toThrow('goal: session projection registry is unavailable')
+    expect(ctx.get('goals')).toBeUndefined()
   })
 
   it('applies the configured default and writes one durable goal change', async () => {
@@ -169,7 +167,6 @@ describe('GoalService creation and replay', () => {
     const stub = stubAgent('goal-direct-construction')
     ctx.agents.register(stub.agent)
     const goals = new GoalService(ctx)
-    expect(() => goals.get(stub.agent)).toThrow('goal projection is not registered')
     await new Promise(resolve => setImmediate(resolve))
     expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
       objective: 'direct', maxGoalRounds: 256,

+ 15 - 21
packages/interaction/permission-presets/src/index.ts

@@ -180,7 +180,7 @@ export class PermissionPresetService extends Service {
     defaultPreset: z.string(),
   })
 
-  static inject = ['shell', 'approval', 'sessions']
+  static inject = ['shell', 'approval', 'sessions', 'sessionProjections']
 
   private readonly presets: Record<string, PresetSpec>
   private defaultSettings: () => PermissionSettings
@@ -232,24 +232,20 @@ export class PermissionPresetService extends Service {
       })),
       currentValue: zod.string().min(1),
     }) as unknown as zod.ZodType<PermissionSelect>
-    // The projection child activates only when a registry is composed. Service
-    // reads reject a missing registry or key instead of substituting defaults.
-    ctx.inject(['sessionProjections'], (projectionCtx) => {
-      projectionCtx.sessionProjections.register({
-        key: 'permissions',
-        stateVersion: 2,
-        stateSchema: permissionStateSchema,
-        init: () => ({ ...EMPTY_KNOBS, seeded: false }),
-        apply: applyPermissionEvent,
-        wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
-      })
-      projectionCtx.on('session/created', (session) => {
-        this.pinInitialPermission(session)
-      })
-      for (const session of projectionCtx.sessions.list()) {
-        this.pinInitialPermission(session)
-      }
+    ctx.sessionProjections.register({
+      key: 'permissions',
+      stateVersion: 2,
+      stateSchema: permissionStateSchema,
+      init: () => ({ ...EMPTY_KNOBS, seeded: false }),
+      apply: applyPermissionEvent,
+      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
@@ -295,9 +291,7 @@ export class PermissionPresetService extends Service {
   }
 
   private permissionState(session: Session): PermissionProjectionState {
-    const projections = this.ctx.get('sessionProjections')
-    if (projections === undefined) throw new Error('permission: session projection registry is unavailable')
-    const state = projections.stateOf(session, 'permissions')
+    const state = this.ctx.sessionProjections.stateOf(session, 'permissions')
     if (state === undefined) throw new Error('permission: permissions session projection is not registered')
     return state
   }

+ 2 - 3
packages/interaction/permission-presets/tests/permission-presets.spec.ts

@@ -87,10 +87,9 @@ describe('permission preset fold', () => {
 })
 
 describe('PermissionPresetService', () => {
-  it('fails on first state access when the projection registry is absent', async () => {
+  it('does not activate without the required projection registry', async () => {
     const ctx = await mounted({ projection: false })
-    expect(() => ctx.permissionPresets.current(freshSession('missing-permission-projections')))
-      .toThrow('permission: session projection registry is unavailable')
+    expect(ctx.get('permissionPresets')).toBeUndefined()
   })
 
   it('fails when the permissions projection key is absent', async () => {

+ 4 - 13
packages/plan/plan-mode/src/index.ts

@@ -169,7 +169,7 @@ export const planProjectionDefinition = {
  * Client carriers expose the projection's cropped `{ active, pending }` view.
  */
 export class PlanModeController extends Service {
-  static inject = ['tools', 'systemPrompt']
+  static inject = ['tools', 'systemPrompt', 'sessionProjections']
 
   /** Validated deployment-owned guidance. */
   private readonly section: string
@@ -219,10 +219,7 @@ export class PlanModeController extends Service {
       },
     })
 
-    // The `plan` projection unit registers through the projection registry.
-    ctx.inject(['sessionProjections'], (projectionCtx) => {
-      projectionCtx.sessionProjections.register(planProjectionDefinition)
-    })
+    ctx.sessionProjections.register(planProjectionDefinition)
 
     // The command child activates only when a command registry is composed.
     ctx.inject(['commands'], (commandCtx) => {
@@ -367,14 +364,8 @@ export class PlanModeController extends Service {
     return this.planState(session).active
   }
 
-  private projectionRegistry(): Context['sessionProjections'] {
-    const projections = this.ctx.get('sessionProjections')
-    if (projections === undefined) throw new Error('plan-mode requires the session projection registry')
-    return projections
-  }
-
   private hasOpenTurn(session: Session): boolean {
-    const state = this.projectionRegistry().stateOf(session, 'turnBoundary')
+    const state = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')
     if (state === undefined) throw new Error('plan-mode requires the turnBoundary session projection')
     return state.openTurnStartSeq !== null
   }
@@ -385,7 +376,7 @@ export class PlanModeController extends Service {
 
   /** Read the required plan projection state or fail at the first service access. */
   private planState(session: Session): PlanUnitState {
-    const state = this.projectionRegistry().stateOf(session, 'plan')
+    const state = this.ctx.sessionProjections.stateOf(session, 'plan')
     if (state === undefined) throw new Error('plan-mode requires the plan session projection')
     return state
   }

+ 11 - 5
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -222,24 +222,30 @@ describe('foldPlanMode', () => {
 })
 
 describe('ctx.planMode: get/set', () => {
-  it('fails on first state access when the projection registry is absent', async () => {
+  it('does not activate without the required projection registry', async () => {
     const ctx = new Context()
     await ctx.plugin(SystemPrompt)
     await ctx.plugin(ToolRuntime)
     await ctx.plugin(PlanModeController, PLAN_CONFIG)
-    const agent = await agentWithSession(ctx, 'missing-plan-projections')
-    expect(() => ctx.planMode.get(agent)).toThrow('plan-mode requires the session projection registry')
+    expect(ctx.get('planMode')).toBeUndefined()
+  })
+
+  it('fails when the required plan projection key is absent', async () => {
+    const ctx = await setup()
+    const agent = await agentWithSession(ctx, 'missing-plan-projection')
+    vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
+    expect(() => ctx.planMode.get(agent)).toThrow('plan-mode requires the plan session projection')
   })
 
-  it('fails when direct construction has not registered plan or turnBoundary state', async () => {
+  it('registers plan state directly but requires turnBoundary state', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(SystemPrompt)
     await ctx.plugin(ToolRuntime)
     const agent = await agentWithSession(ctx, 'missing-plan-projection-keys')
     const planMode = new PlanModeController(ctx, PLAN_CONFIG)
-    expect(() => planMode.get(agent)).toThrow('plan-mode requires the plan session projection')
     await new Promise(resolve => setImmediate(resolve))
+    expect(planMode.get(agent)).toEqual({ active: false })
     expect(() => planMode.set(agent, true)).toThrow('plan-mode requires the turnBoundary session projection')
   })