Browse Source

feat(dx): scopeHost, agent-aware ACP presentation, and the scoped-dispatch drift gate

scopeHost(ctx, services) is the sanctioned way to mint scopes in tests: it
names absent services loudly instead of the cryptic cordis without-inject
dead end, and catches the silent-no-op host (cordis resolves a
dependency-pending fiber's await without running the inject callback).

The ACP ToolPresenter resolves presentations through the session agent's
view (tools.get(name, agent)) so a scoped/shadowed tool renders with the
same definition that executed.

verify-scoped-dispatch (doc-sync + pre-push) pins the dev-invariants
carrier table against the declaration JSDoc set: an event enforced but
undocumented, documented but unenforced, or a registry-subject notification
leaking into the table fails the build. subagent/start|end docs gain their
scoped-dispatch sentence (a real gap the gate caught on first run).
Tianyi Cui 2 months ago
parent
commit
e7bcbb8bc6

+ 4 - 4
docs/cordis-catalog/events.md

@@ -237,13 +237,13 @@ Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/sr
 
 ### `subagent/end` — emit
 
-A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start'].
+A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run.
 
 ```ts cordis-catalog
 'subagent/end'(info: SubagentRunEndInfo): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:107`](../../packages/subagent/subagent/src/index.ts)
 
 ### `subagent/provider-added` — emit
 
@@ -267,13 +267,13 @@ Source: [`packages/subagent/subagent/src/index.ts:84`](../../packages/subagent/s
 
 ### `subagent/start` — emit
 
-A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end'].
+A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by the DELEGATING PARENT — a listener registered through the parent's `agent.ctx` observes only its own delegations; a plain plugin listener observes every run.
 
 ```ts cordis-catalog
 'subagent/start'(info: SubagentRunInfo): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:96`](../../packages/subagent/subagent/src/index.ts)
 
 ## `system-prompt/*`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -197,7 +197,7 @@ list(): string[]
 start(name: string, request: SubagentStartRequest): SubagentRun
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
 
 ## `ctx.systemPrompt` — `SystemPrompt`
 

+ 2 - 2
docs/event-producer-consumer.md

@@ -25,10 +25,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:107`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:73`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:84`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:96`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:54`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:112`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |

+ 2 - 1
package.json

@@ -57,9 +57,10 @@
     "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
     "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check",
     "gen-module-graph": "tsx scripts/gen-module-graph.ts",
+    "verify-scoped-dispatch": "tsx scripts/verify-scoped-dispatch.ts",
     "verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
     "constraints": "tsx scripts/check-workspace-constraints.ts",
-    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
+    "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types",
     "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
     "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",

+ 56 - 0
packages/core/scope/src/index.ts

@@ -235,3 +235,59 @@ export function carrierKeyOf(value: unknown): ScopeKey | undefined {
   // the Scoped<> brand carries no structural kCarrier member to narrow from.
   return (value as { [kCarrier]?: { key: ScopeKey | undefined } })[kCarrier]?.key
 }
+
+/**
+ * A test/tooling host for minting scopes: one mounted plugin whose `inject`
+ * list is the service surface every scope minted through it can reach.
+ */
+export interface ScopeHost {
+  /**
+   * Mint a scope under the host (see {@link createScope}); the scoped context
+   * resolves exactly the host's injected services.
+   * @param key - the scope's identity ({@link ScopeKey}).
+   * @returns the minted scope.
+   */
+  mint(key: ScopeKey): Scope
+  /**
+   * Dispose the host fiber and with it every scope minted through it.
+   * @returns resolves when all collected disposers have settled.
+   */
+  dispose(): Promise<void>
+}
+
+/**
+ * Mount a scope-minting host plugin that injects `services`, THE sanctioned
+ * way to mint scopes in tests (production scopes are minted by the agent
+ * loop). Exists because the naive spelling fails confusingly twice over:
+ * a plugin with no `inject` mints scopes whose service reads throw Cordis's
+ * cryptic `cannot get property … without inject`, and a plugin whose inject
+ * can never be satisfied RESOLVES its fiber await without ever running the
+ * callback — a silent no-op host. This helper fails LOUD instead: when the
+ * callback did not run, it names the absent services and disposes the host.
+ * @param ctx - the context to mount the host under.
+ * @param services - the service names scopes minted through this host reach
+ *   (the host plugin's `inject` list).
+ * @returns the host (mint scopes, dispose them all at once).
+ * @throws when any of `services` is not available on `ctx` — named, not the
+ *   Cordis dead end.
+ */
+export async function scopeHost(ctx: Context, services: string[]): Promise<ScopeHost> {
+  let hostCtx: Context | undefined
+  // A named function statement (not Object.assign({name}) — Function.name is
+  // read-only) so diagnostics read `scopeHost`.
+  function scopeHostPlugin(inner: Context): void { hostCtx = inner }
+  const fiber = ctx.plugin(Object.assign(scopeHostPlugin, { inject: services }))
+  await fiber
+  if (hostCtx === undefined) {
+    // Dependency-pending: cordis resolves the await without running the
+    // callback. Name the absentees and unwind the pending fiber.
+    const missing = services.filter(name => ctx.get(name) === undefined)
+    await fiber.dispose()
+    throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${missing.map(name => `"${name}"`).join(', ') || '(unknown)'} not available on this context — load the providing plugin(s) before minting scopes`)
+  }
+  const host = hostCtx
+  return {
+    mint: (key: ScopeKey) => createScope(host, key),
+    dispose: () => Promise.resolve(fiber.dispose()),
+  }
+}

+ 22 - 1
packages/core/scope/tests/scope.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, expectTypeOf, it } from 'vitest'
 import { Context } from 'cordis'
-import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
+import { carrierKeyOf, createScope, isScopeCarrier, scopeHost, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scope, ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
 
 declare module 'cordis' {
@@ -207,3 +207,24 @@ describe('carrier marks', () => {
     expectTypeOf(base).not.toExtend<Scoped<{ name: string }>>()
   })
 })
+
+describe('scopeHost', () => {
+  it('mints scopes that reach the injected services; dispose unwinds them all', async () => {
+    const ctx = new Context()
+    ctx.provide('answers', { value: 42 })
+    const host = await scopeHost(ctx, ['answers'])
+    const scope = host.mint({ name: 'a' })
+    expect((scope.ctx as Context & { answers: { value: number } }).answers.value).toBe(42)
+    const order: string[] = []
+    scope.ctx.effect(() => () => void order.push('scoped-disposed'))
+    await host.dispose()
+    expect(order).toEqual(['scoped-disposed'])
+    expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
+  })
+
+  it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
+    const ctx = new Context()
+    await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
+      .rejects.toThrow('scopeHost: services "tools", "systemPrompt" not available')
+  })
+})

+ 8 - 0
packages/subagent/subagent/src/index.ts

@@ -86,6 +86,10 @@ declare module 'cordis' {
      * A subagent run started — emitted after the provider is resolved and its
      * capabilities validated, as the child run begins. Paired with
      * {@link Events['subagent/end']}.
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
+     * by the DELEGATING PARENT — a listener registered through the parent's
+     * `agent.ctx` observes only its own delegations; a plain plugin listener
+     * observes every run.
      * @param info - which provider started which child agent.
      * @mode emit
      */
@@ -93,6 +97,10 @@ declare module 'cordis' {
     /**
      * A subagent run settled — emitted when {@link SubagentRun.result}
      * resolves (any stop reason). Paired with {@link Events['subagent/start']}.
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
+     * by the DELEGATING PARENT — a listener registered through the parent's
+     * `agent.ctx` observes only its own delegations; a plain plugin listener
+     * observes every run.
      * @param info - the run identity plus stop reason and final output.
      * @mode emit
      */

+ 14 - 6
packages/ui/acp/src/index.ts

@@ -213,7 +213,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
   const tools = ctx.tools
   // A new ToolPresenter per session (and a throwaway per load replay), each given
   // this warn sink so a throwing tool presenter is logged, not propagated.
-  const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) })
+  const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
 
   // Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
   // reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
@@ -449,7 +449,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
           sessionId,
           agent: handle.agent,
           dispose: () => handle.dispose(),
-          presenter: makePresenter(),
+          presenter: makePresenter(handle.agent),
           terminalEnabled: terminalOutputCap,
           inflight: undefined,
         })
@@ -526,7 +526,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
             sessionId,
             agent,
             dispose: () => handle.dispose(),
-            presenter: makePresenter(),
+            presenter: makePresenter(agent),
             terminalEnabled,
             inflight: undefined,
           }
@@ -544,7 +544,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
           // future live events for this session. The throwaway pairs call→result
           // as the log replays in order (same as live) and is discarded after,
           // so the record's presenter starts clean for the post-load live stream.
-          const replayPresenter = makePresenter()
+          const replayPresenter = makePresenter(agent)
           const replayTerminal: TerminalRendering = {
             enabled: terminalEnabled,
             cwd: agent.session.header.cwd,
@@ -897,6 +897,13 @@ export class ToolPresenter {
   constructor(
     private readonly tools: Pick<ToolRegistry, 'get'>,
     private readonly onError: (message: string) => void = () => {},
+    /**
+     * The agent whose view resolves tool presentations: a scoped/shadowed
+     * tool presents with ITS OWN presentCall/presentResult — the same
+     * definition that executed — not a same-named global's. Absent (a replay
+     * with no live agent) the global view presents.
+     */
+    private readonly agent?: Agent,
   ) {}
 
   /**
@@ -913,7 +920,7 @@ export class ToolPresenter {
     const args = parseToolArguments(argsJson)
     let present: ToolCallView | undefined
     try {
-      present = this.tools.get(name)?.presentCall?.(args)
+      present = this.tools.get(name, this.agent)?.presentCall?.(args)
     } catch (error: unknown) {
       // A throwing presentCall must not break streaming: log and fall back.
       this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
@@ -947,7 +954,8 @@ export class ToolPresenter {
     if (call === undefined) return { card: 'generic', content }
     let present: ToolResultView | undefined
     try {
-      present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
+      present = this.tools.get(call.name, this.agent)
+        ?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
     } catch (error: unknown) {
       // A throwing presentResult must not break streaming/replay: log + fall back.
       this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`)

+ 1 - 0
scripts/run-gates.ts

@@ -262,6 +262,7 @@ function docSyncLeafGates(): Gate[] {
     pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
     pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
     pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
+    pnpmScript('scoped-dispatch', 'verify-scoped-dispatch', { label: 'scoped dispatch' }),
     pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
     pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
     pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),

+ 78 - 0
scripts/verify-scoped-dispatch.ts

@@ -0,0 +1,78 @@
+/**
+ * Scoped-dispatch drift gate: the set of scope-filtered events is declared in
+ * TWO places that must never diverge — the dev-invariants runtime table (the
+ * `scopedSubject` map in `packages/support/invariants/src/index.ts`, which
+ * enforces carriers at dispatch time) and the event declarations' JSDoc (the
+ * "Scope-filtered dispatch" sentence rendered into the events catalog, which
+ * tells plugin authors what a scoped listener will and won't hear). An event
+ * added to one side without the other either silently escapes runtime
+ * enforcement or documents filtering that never happens; this gate fails the
+ * build instead.
+ *
+ * Sources of truth: the invariant table is parsed from the invariants source;
+ * the documented set is parsed from every `declare module 'cordis'` Events
+ * JSDoc in packages/*\/*\/src carrying the marker sentence. Registry-subject
+ * notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`)
+ * are deliberately unfiltered and must appear in NEITHER set.
+ */
+
+import { globSync, readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+const root = resolve(import.meta.dirname, '..')
+
+/** The marker sentence every scope-filtered event's JSDoc carries. */
+const MARKER = 'Scope-filtered dispatch'
+
+/** Events that are deliberately UNFILTERED registry-subject notifications. */
+const REGISTRY_SUBJECT = new Set(['tools/change', 'system-prompt/change', 'subagent/provider-added', 'subagent/provider-removed'])
+
+function invariantTable(): Set<string> {
+  const source = readFileSync(resolve(root, 'packages/support/invariants/src/index.ts'), 'utf8')
+  const start = source.indexOf('const scopedSubject')
+  if (start < 0) throw new Error('verify-scoped-dispatch: cannot find the scopedSubject table in dsh-invariants')
+  const block = source.slice(start, source.indexOf('}', start))
+  return new Set([...block.matchAll(/'([a-z-]+\/[a-z-]+)':/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]))
+}
+
+function documentedSet(): Set<string> {
+  const documented = new Set<string>()
+  for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root })) {
+    const source = readFileSync(resolve(root, rel), 'utf8')
+    if (!source.includes(MARKER)) continue
+    // Each event declaration: a JSDoc block followed by the quoted event name.
+    // Tolerate `//` comment lines between the JSDoc and the declaration
+    // (e.g. an inline TODO under the doc block).
+    for (const match of source.matchAll(/\/\*\*([\s\S]*?)\*\/\s*\n(?:\s*\/\/[^\n]*\n)*\s*'([a-z-]+\/[a-z-]+)'\(/g)) {
+      const [, doc, event] = match
+      if (doc === undefined || event === undefined) continue
+      if (doc.includes(MARKER)) documented.add(event)
+    }
+  }
+  return documented
+}
+
+const table = invariantTable()
+const documented = documentedSet()
+
+const problems: string[] = []
+for (const event of table) {
+  if (!documented.has(event)) {
+    problems.push(`"${event}" is enforced by the dev-invariants carrier table but its declaration JSDoc carries no "${MARKER}" sentence — document the filtering plugin authors will observe.`)
+  }
+  if (REGISTRY_SUBJECT.has(event)) {
+    problems.push(`"${event}" is a registry-subject notification (deliberately unfiltered) but appears in the dev-invariants carrier table.`)
+  }
+}
+for (const event of documented) {
+  if (!table.has(event)) {
+    problems.push(`"${event}" documents scope-filtered dispatch but is missing from the dev-invariants carrier table (packages/support/invariants) — a bare dispatch of it would silently revert to global delivery.`)
+  }
+}
+
+if (problems.length > 0) {
+  console.error(`verify-scoped-dispatch: ${problems.length} drift(s) between the invariant table and the documented scoped-event set:`)
+  for (const problem of problems) console.error(`  - ${problem}`)
+  process.exit(1)
+}
+console.log(`verify-scoped-dispatch: ${table.size} scope-filtered event(s) consistent between the invariant table and the declaration docs.`)