Przeglądaj źródła

style: fix lint across client packages

eslint --fix autofixes plus manual repairs: max-len line splits
(fake-api handlers, notifier/slots JSDoc, spec signatures), charAt over
non-null-asserted indexing in slash detect/menu cores, Array.from for
code-point capping, typeof assertions for unbound-method in specs,
generic getByRole for the send-button cast, effect disposer void-wrap in
command register, and dropped unused type imports.
imccyu 2 miesięcy temu
rodzic
commit
f6396f2573
35 zmienionych plików z 122 dodań i 89 usunięć
  1. 2 2
      apps/web/tests/slash-flow.snapshot.ts
  2. 3 3
      apps/web/tests/workspace-flow.snapshot.ts
  3. 6 3
      packages/client/connection/tests/fake-api.ts
  4. 4 1
      packages/client/runtime/src/client/sessions/notifier.ts
  5. 1 1
      packages/client/runtime/src/client/sessions/session.ts
  6. 6 3
      packages/client/runtime/tests/fake-api.ts
  7. 3 1
      packages/client/runtime/tests/manager.spec.ts
  8. 1 1
      packages/client/runtime/tests/queue-store.spec.ts
  9. 3 1
      packages/client/runtime/tests/sessions-service.spec.ts
  10. 4 4
      packages/client/runtime/tests/slots-service.spec.ts
  11. 3 4
      packages/client/ui-command/src/client/service.ts
  12. 2 2
      packages/client/ui-command/tests/browser-plugin.spec.ts
  13. 3 3
      packages/client/ui-command/tests/service.spec.ts
  14. 3 3
      packages/client/ui-conversation/src/client/apply.ts
  15. 4 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  16. 1 1
      packages/client/ui-conversation/src/client/input/machine.ts
  17. 2 2
      packages/client/ui-conversation/src/client/service.ts
  18. 1 0
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  19. 7 4
      packages/client/ui-conversation/tests/chat-apply.spec.tsx
  20. 5 2
      packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
  21. 5 1
      packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
  22. 13 9
      packages/client/ui-conversation/tests/selection-survival.spec.ts
  23. 7 7
      packages/client/ui-skill/src/client/index.ts
  24. 2 2
      packages/client/ui-skill/tests/browser-plugin.spec.ts
  25. 4 1
      packages/client/ui-slash/src/client/controller.ts
  26. 1 1
      packages/client/ui-slash/src/client/index.ts
  27. 3 3
      packages/client/ui-slash/src/core/detect.ts
  28. 7 9
      packages/client/ui-slash/src/core/menu.ts
  29. 1 1
      packages/client/ui-slash/tests/service.spec.ts
  30. 2 2
      packages/client/ui-slots/src/index.ts
  31. 5 5
      packages/client/ui-subagent/src/client/index.ts
  32. 3 3
      packages/client/ui-subagent/tests/browser-plugin.spec.ts
  33. 3 1
      packages/host/apiproxy/src/fetch/handler.ts
  34. 1 1
      packages/host/apiproxy/tests/api-proxy-cold.spec.ts
  35. 1 1
      scripts/gen-doc-graphs.ts

+ 2 - 2
apps/web/tests/slash-flow.snapshot.ts

@@ -122,7 +122,7 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-
   // workspace picker is live.
   const locked = await screen.findByPlaceholderText(
     'Choose a workspace to start', {}, { timeout: 10_000 },
-  ) as HTMLTextAreaElement
+  )
   expect(locked.disabled).toBe(true)
 
   // Pick (create) a Workspace: connectWorkspace materializes the full
@@ -139,7 +139,7 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-
 
   const composer = await screen.findByPlaceholderText(
     'Describe what you want to build', {}, { timeout: 10_000 },
-  ) as HTMLTextAreaElement
+  )
   expect(composer.disabled).toBe(false)
 
   // '/' opens the menu with the session's wire command catalog (the session

+ 3 - 3
apps/web/tests/workspace-flow.snapshot.ts

@@ -117,14 +117,14 @@ function workspaceChip(): HTMLElement {
 async function findLockedComposer(): Promise<HTMLTextAreaElement> {
   return await screen.findByPlaceholderText(
     'Choose a workspace to start', {}, { timeout: 10_000 },
-  ) as HTMLTextAreaElement
+  )
 }
 
 /** The live blank-session hero composer (session materialized). */
 async function findHeroComposer(): Promise<HTMLTextAreaElement> {
   return await screen.findByPlaceholderText(
     'Describe what you want to build', {}, { timeout: 10_000 },
-  ) as HTMLTextAreaElement
+  )
 }
 
 /** Edit the machine-owned controlled input and assert the same-tick echo. */
@@ -161,7 +161,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen
     headline: visibleText(screen.getByText("Let's start building")),
     chip: visibleText(workspaceChip()),
     composerDisabled: composer.disabled,
-    sendDisabled: (screen.getByRole('button', { name: 'Send message' }) as HTMLButtonElement).disabled,
+    sendDisabled: screen.getByRole<HTMLButtonElement>('button', { name: 'Send message' }).disabled,
     sidebar: visibleText(tree),
   }).toMatchInlineSnapshot(`
     {

+ 6 - 3
packages/client/connection/tests/fake-api.ts

@@ -88,9 +88,12 @@ export class FakeApiClient implements IApiClient {
 
   // Payloads stay `unknown` (lint-lane note above); response rows are the real
   // wire shapes so cases can program catalogs and skill lists without casts.
-  onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
-  onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
-  onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
+  onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
+    = () => Promise.resolve(ok({ commands: [] }))
+  onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
+    = () => Promise.resolve(ok({ matched: false }))
+  onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
+    = () => Promise.resolve(ok({ skills: [] }))
 
   readonly commands: IApiClient['commands'] = {
     list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),

+ 4 - 1
packages/client/runtime/src/client/sessions/notifier.ts

@@ -64,7 +64,10 @@ export class Notifier {
     for (const listener of this.listeners) listener()
   }
 
-  /** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). Notification stays pending. */
+  /**
+   * Pre-getSnapshot check: rebuild synchronously when dirty (read path
+   * before first subscribe / while unobserved). Notification stays pending.
+   */
   ensureFresh(): void {
     if (!this.dirty) return
     this.dirty = false

+ 1 - 1
packages/client/runtime/src/client/sessions/session.ts

@@ -53,7 +53,7 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
   const flat = content
     .map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
     .join(' ').replace(/\s+/g, ' ').trim()
-  const chars = [...flat]
+  const chars = Array.from(flat)
   return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
 }
 

+ 6 - 3
packages/client/runtime/tests/fake-api.ts

@@ -110,9 +110,12 @@ export class FakeApiClient implements IApiClient {
   // Payloads stay `unknown` (lint-lane note above); response rows are the real
   // wire shapes so cases can program requires-bearing catalogs and dual-address
   // skill lists without casts.
-  onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
-  onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
-  onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
+  onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
+    = () => Promise.resolve(ok({ commands: [] }))
+  onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
+    = () => Promise.resolve(ok({ matched: false }))
+  onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
+    = () => Promise.resolve(ok({ skills: [] }))
 
   readonly commands: IApiClient['commands'] = {
     list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),

+ 3 - 1
packages/client/runtime/tests/manager.spec.ts

@@ -12,7 +12,9 @@ import { entries, plainTurn } from './event-script.ts'
 const S1 = 'fk-m1' as SessionId
 const S2 = 'fk-m2' as SessionId
 
-function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }> = {}) {
+type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
+
+function summary(sessionId: SessionId, over: SummaryOver = {}) {
   return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
 }
 

+ 1 - 1
packages/client/runtime/tests/queue-store.spec.ts

@@ -50,7 +50,7 @@ describe('queue intake', () => {
     const session = makeSession()
     session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
     const preview = session.getSnapshot().queue[0]?.preview ?? ''
-    expect([...preview]).toHaveLength(201) // 200 + …
+    expect(Array.from(preview)).toHaveLength(201) // 200 + …
     expect(preview.endsWith('…')).toBe(true)
   })
 

+ 3 - 1
packages/client/runtime/tests/sessions-service.spec.ts

@@ -28,7 +28,9 @@ function bench(): Bench {
 }
 
 /** Refresh the manager list from programmable rows and flush the microtask batch. */
-async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }[]): Promise<void> {
+type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
+
+async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
   b.api.onList = () => Promise.resolve(ok({
     items: rows.map(r => ({
       sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,

+ 4 - 4
packages/client/runtime/tests/slots-service.spec.ts

@@ -104,10 +104,10 @@ function fakeSessions() {
     list: { getSnapshot: () => state, subscribe: () => () => undefined },
     provideInfo: (id: string) => (id === 'known'
       ? {
-          sessionId: id,
-          hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
-          props: {},
-        }
+        sessionId: id,
+        hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
+        props: {},
+      }
       : undefined),
   }
 }

+ 3 - 4
packages/client/ui-command/src/client/service.ts

@@ -10,8 +10,6 @@ import { Service } from 'cordis'
 import type { Context } from 'cordis'
 import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
 import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
-// Type-only: the notice route reads ctx.conversation.input — no runtime edge.
-import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
   SlashServiceContract, SubmitOutcome,
@@ -70,7 +68,7 @@ export class CommandService extends Service implements CommandServiceContract {
    * @returns the disposer removing the registration.
    */
   register(contribution: CommandContribution): () => void {
-    return this.ctx.effect(() => {
+    const dispose = this.ctx.effect(() => {
       const { contributions } = this.live
       if (contributions.has(contribution.name)) {
         throw new Error(`ui-command: duplicate contribution for /${contribution.name}`)
@@ -78,6 +76,7 @@ export class CommandService extends Service implements CommandServiceContract {
       contributions.set(contribution.name, contribution)
       return () => { contributions.delete(contribution.name) }
     }, 'command.register()')
+    return () => { void dispose() }
   }
 
   /**
@@ -275,7 +274,7 @@ export class CommandService extends Service implements CommandServiceContract {
   private noticeFor(id: SessionId, _name: string, level: 'info' | 'error', text: string): void {
     const actx = this.scopeFor(id)
     if (actx === undefined) return
-    const conversation = actx.get('conversation') as ConversationService | undefined
+    const conversation = actx.get('conversation')
     if (conversation === undefined) return
     conversation.input.for(actx).notify(level, text)
   }

+ 2 - 2
packages/client/ui-command/tests/browser-plugin.spec.ts

@@ -62,8 +62,8 @@ describe('apply', () => {
     expect(command).toBeInstanceOf(CommandService)
     // Frozen-contract conformance (compile-time check rides the assignment).
     const contract: CommandServiceContract = command as CommandService
-    expect(contract.register).toBeTypeOf('function')
-    expect(contract.popupFor).toBeTypeOf('function')
+    expect(typeof contract.register).toBe('function')
+    expect(typeof contract.popupFor).toBe('function')
     expect([...sources.keys()]).toEqual(['/ command'])
     expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup'])
     await fiber.dispose()

+ 3 - 3
packages/client/ui-command/tests/service.spec.ts

@@ -134,9 +134,9 @@ const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
 describe('registration', () => {
   it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
     const { registered, source, fiber } = await bench()
-    expect(source.matchSpace).toBeTypeOf('function')
-    expect(source.matchEnter).toBeTypeOf('function')
-    expect(source.warm).toBeTypeOf('function')
+    expect(typeof source.matchSpace).toBe('function')
+    expect(typeof source.matchEnter).toBe('function')
+    expect(typeof source.warm).toBe('function')
     expect([...registered.keys()]).toEqual(['/ command'])
     await fiber.dispose()
     expect(registered.size).toBe(0)

+ 3 - 3
packages/client/ui-conversation/src/client/apply.ts

@@ -1,7 +1,7 @@
 /** Registers the conversation components, shared store, and service callbacks. */
 import type { Context } from 'cordis'
 import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ClientContext, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { ViewTab } from './contract/views.ts'
 import type {
@@ -54,7 +54,7 @@ export function apply(ctx: Context): void {
 
   // The per-session input machine registry (InputService face; published as
   // ctx.conversation.input by the service below sharing this one instance).
-  const inputHub = new InputHub(ctx as ClientContext)
+  const inputHub = new InputHub(ctx)
 
   // Decision 19/20: the input machine feeds every session-scope slot
   // component through the standard provide channel — the 'input' hook plus
@@ -119,7 +119,7 @@ export function apply(ctx: Context): void {
         version: () => slots.getVersion('conversation.view'),
       },
       bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
-      open: id => { sessions.open(id) },
+      open: (id) => { sessions.open(id) },
     }),
   }, ConversationSession)
 

+ 4 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -248,7 +248,10 @@ export interface ComposerChainProps {
   interactions: readonly PendingInteraction[]
 }
 
-/** Full conversation-slot component props: runtime & child-render (view ring + composer chain/bar + input-region + hero picker slots) & store & injected shares. */
+/**
+ * Full conversation-slot component props: runtime & child-render (view ring
+ * + composer chain/bar + input-region + hero picker slots) & store & injected shares.
+ */
 export type ConversationSlotProps =
   PropsRuntime<'conversation'> & PropsRenderSlots<
     | 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar'

+ 1 - 1
packages/client/ui-conversation/src/client/input/machine.ts

@@ -342,7 +342,7 @@ export class InputMachine {
   private onSetInvalid(invalidIds: readonly number[]): InputEffect[] {
     const ids = new Set(invalidIds)
     if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return []
-    this.occurrences = this.occurrences.map(o => {
+    this.occurrences = this.occurrences.map((o) => {
       const invalid = ids.has(o.occurrenceId)
       if ((o.invalid === true) === invalid) return o
       const { invalid: _drop, ...rest } = o

+ 2 - 2
packages/client/ui-conversation/src/client/service.ts

@@ -12,7 +12,7 @@ import type { Context } from 'cordis'
 // Type-only imports: a plugin-to-plugin value import is a bundle purity
 // error, so scope resolution goes through the sessions service (scopeOf
 // method) instead of the standalone helper.
-import type { ClientContext, Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
 import { InputHub } from './input/hub.ts'
 
 /** Scope-addressed conversation service (root singleton, provided as `conversation`). */
@@ -29,7 +29,7 @@ export class ConversationService extends Service {
    */
   constructor(ctx: Context, config?: { input?: InputHub }) {
     super(ctx, 'conversation')
-    this.input = config?.input ?? new InputHub(ctx as ClientContext)
+    this.input = config?.input ?? new InputHub(ctx)
   }
 
   /**

+ 1 - 0
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -89,6 +89,7 @@ async function bench() {
     binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
     scope: (id: SessionId) => mint(id),
     provideInfo: () => undefined,
+    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
     provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
     scopeOf,
     sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),

+ 7 - 4
packages/client/ui-conversation/tests/chat-apply.spec.tsx

@@ -37,6 +37,7 @@ async function bench() {
     binding: vi.fn(),
     scope: () => undefined,
     provideInfo: () => undefined,
+    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
     provide: vi.fn(() => () => {}),
     create: vi.fn(),
     open: vi.fn(),
@@ -94,15 +95,17 @@ describe('apply wiring', () => {
     const b = await bench()
     await b.fiber.await()
     const conversation = renderEntryOf(b.slots, 'conversation')
+    const conversationSession = renderEntryOf(b.slots, 'conversation.session')
     const chatView = renderEntryOf(b.slots, 'conversation.view')
     const details = renderEntryOf(b.slots, 'details')
     expect(conversation?.inject).toBeTypeOf('function')
     expect(chatView?.inject).toBeTypeOf('function')
     expect(details?.inject).toBeTypeOf('function')
-    // The shared handle: one apply-built store value on ALL session entries.
-    expect(conversation?.store).toBeDefined()
-    expect(details?.store).toBe(conversation?.store)
-    expect(chatView?.store).toBe(conversation?.store)
+    // The shared handle: one apply-built store value on ALL session entries
+    // (the session-maybe 'conversation' shell carries no store by design).
+    expect(conversationSession?.store).toBeDefined()
+    expect(details?.store).toBe(conversationSession?.store)
+    expect(chatView?.store).toBe(conversationSession?.store)
     // The hero workspace picker hole rides the conversation entry's children
     // declaration (the empty-state occupant is gone).
     expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })

+ 5 - 2
packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx

@@ -94,8 +94,8 @@ async function bench(snapshot: ConversationSnapshot) {
       : undefined),
     scope: () => ({ get: () => scoped }),
     scopeOf: () => SID,
-    provide: (provider: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> }) => {
-      const contribution = provider(sessionsFake.binding(SID))
+    provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> } }) => {
+      const contribution = descriptor.resolve(sessionsFake.binding(SID))
       Object.assign(provided.hooks, contribution.hooks ?? {})
       Object.assign(provided.props, contribution.props ?? {})
       return () => {}
@@ -103,6 +103,9 @@ async function bench(snapshot: ConversationSnapshot) {
     provideInfo: (id: string) => (id === SID
       ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
       : undefined),
+    maybeProvideInfo: (id: string | undefined) => (id === SID
+      ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
+      : { hooks: provided.hooks, props: provided.props }),
     create: vi.fn(),
     open: vi.fn(),
   }

+ 5 - 1
packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx

@@ -107,7 +107,10 @@ async function bench(nodes: ToolResultNode[]) {
       }
       return info
     },
-    provide: (fn: (typeof providers)[number]) => { providers.push(fn); return () => {} },
+    maybeProvideInfo(id: string | undefined) {
+      return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
+    },
+    provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
     scopeOf: () => SID,
     create: vi.fn(),
     open: vi.fn(),
@@ -227,6 +230,7 @@ describe('registrant load-order seam', () => {
       binding: () => undefined,
       scope: () => undefined,
       provideInfo: () => undefined,
+      maybeProvideInfo: () => ({ hooks: {}, props: {} }),
       provide: () => () => {},
       create: vi.fn(),
       open: vi.fn(),

+ 13 - 9
packages/client/ui-conversation/tests/selection-survival.spec.ts

@@ -23,6 +23,7 @@ function bench(): Bench {
       ids: [], byId: {}, current: undefined, phase: 'ready',
     }),
     provideInfo: () => undefined,
+    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
     provide: () => () => {},
   })
   ctx.provide('workspaces', {
@@ -42,16 +43,19 @@ function bench(): Bench {
     name: 'root',
     children: {
       'conversation': { kind: 'single', scope: 'session-maybe' },
+      'conversation.session': { kind: 'single', scope: 'session' },
       'details': { kind: 'single', scope: 'session' },
     },
   }, (_p: { renderSlot?: unknown }) => null)
-  slots.register({ name: 'conversation', store: chat }, () => null)
+  // apply.ts mounts the shared chat handle only under session-scope slots
+  // (the session-maybe 'conversation' shell carries no store).
+  slots.register({ name: 'conversation.session', store: chat }, () => null)
   slots.register({ name: 'details', store: chat }, () => null)
   return { slots, chat }
 }
 
 /** Resolve the store instance the renderer would hand a slot's component for a session. */
-function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) {
+function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) {
   const host = renderHost(b)
   const entry = host.entriesOf(slot)[0]!
   return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
@@ -80,7 +84,7 @@ describe('selection survives on the store seat', () => {
   it('one session, two slots: conversation writes, details reads the SAME instance', () => {
     const b = bench()
 
-    const conv = storeFor(b, 'conversation', sid('s1'))
+    const conv = storeFor(b, 'conversation.session', sid('s1'))
     const details = storeFor(b, 'details', sid('s1'))
     conv.actions.select({ turnSeq: 3, callId: 'c1' })
     expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
@@ -91,8 +95,8 @@ describe('selection survives on the store seat', () => {
   it('sessions are isolated: s2 selection never bleeds into s1', () => {
     const b = bench()
 
-    const one = storeFor(b, 'conversation', sid('s1'))
-    const two = storeFor(b, 'conversation', sid('s2'))
+    const one = storeFor(b, 'conversation.session', sid('s1'))
+    const two = storeFor(b, 'conversation.session', sid('s2'))
     expect(two).not.toBe(one)
     one.actions.select({ turnSeq: 1, callId: 'a' })
     two.actions.select({ turnSeq: 9, callId: 'z' })
@@ -105,14 +109,14 @@ describe('selection survives on the store seat', () => {
     const id = sid('s1')
     const projection = createSnapshotStore({ displayTitle: 's1' })
 
-    const store = storeFor(b, 'conversation', id)
+    const store = storeFor(b, 'conversation.session', id)
     store.actions.select({ turnSeq: 3, callId: 'c1' })
     store.actions.setDraft('half-typed')
 
     projection.set({ displayTitle: 'proj-a' })
     expect(projection.getSnapshot().displayTitle).toBe('proj-a')
 
-    const after = storeFor(b, 'conversation', id)
+    const after = storeFor(b, 'conversation.session', id)
     expect(after).toBe(store)
     expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
     expect(after.store.getSnapshot().draft).toBe('half-typed')
@@ -121,7 +125,7 @@ describe('selection survives on the store seat', () => {
   it('session death buries the instance and its persisted draft', () => {
     const b = bench()
 
-    const doomed = storeFor(b, 'conversation', sid('s1'))
+    const doomed = storeFor(b, 'conversation.session', sid('s1'))
     doomed.actions.setDraft('to be buried')
     doomed.actions.select({ turnSeq: 1 })
     expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
@@ -132,7 +136,7 @@ describe('selection survives on the store seat', () => {
     // Persisted residue is gone with the session...
     expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
     // ...and a re-created same-id session starts from a FRESH instance.
-    const reborn = storeFor(b, 'conversation', sid('s1'))
+    const reborn = storeFor(b, 'conversation.session', sid('s1'))
     expect(reborn).not.toBe(doomed)
     expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
   })

+ 7 - 7
packages/client/ui-skill/src/client/index.ts

@@ -40,7 +40,7 @@ export const inject = ['slash', 'connection']
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
-  const { list } = (ctx.get('connection') as ConnectionHandle).api.skills
+  const skills = (ctx.get('connection') as ConnectionHandle).api.skills
   // Session-keyed catalog cache; single-flight per key. Plugin-closure state:
   // the fiber effect below is its teardown boundary.
   const fetches = new Map<SessionId, CatalogFetch>()
@@ -50,7 +50,7 @@ export function apply(ctx: ClientContext): void {
     if (existing !== undefined) return existing.promise
     const abort = new AbortController()
     const promise = (async () => {
-      const { result } = await list({ sessionId }, abort.signal)
+      const { result } = await skills.list({ sessionId }, abort.signal)
       if (!result.ok) throw new Error(`skill.list failed: ${result.error.code}: ${result.error.message}`)
       return result.value.skills
     })()
@@ -86,8 +86,8 @@ export function apply(ctx: ClientContext): void {
       // Superseded keystroke: the shared fetch stays warm, this caller yields.
       if (signal.aborted) return []
       return skills
-        .filter((skill) => skill.name.startsWith(query))
-        .map((skill) => ({ name: skill.name, description: skill.description }))
+        .filter(skill => skill.name.startsWith(query))
+        .map(skill => ({ name: skill.name, description: skill.description }))
     },
     warm(session) {
       // Fire-and-forget scope-birth prewarm; the shared fetch reports
@@ -95,7 +95,7 @@ export function apply(ctx: ClientContext): void {
       fetchCatalog(session.sessionId).catch(() => {})
     },
     lexicon(session) {
-      return fetches.get(session.sessionId)?.settled?.map((skill) => skill.name)
+      return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
     },
     onPick({ candidate }) {
       // Decision 21: plain-text reference — the literal lands in the draft
@@ -105,8 +105,8 @@ export function apply(ctx: ClientContext): void {
       return { text: `/${candidate.name} ` }
     },
     codec: {
-      clipboardText: (ref) => `/${ref}`,
-      serialize: (ref) => Promise.resolve(`<skill>${ref}</skill>`),
+      clipboardText: ref => `/${ref}`,
+      serialize: ref => Promise.resolve(`<skill>${ref}</skill>`),
     },
   }
   const slash = ctx.get('slash') as SlashServiceContract

+ 2 - 2
packages/client/ui-skill/tests/browser-plugin.spec.ts

@@ -234,7 +234,7 @@ describe('pick and codec', () => {
 describe('adjudication', () => {
   it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => {
     const { source } = await bench(listOk(CATALOG))
-    expect(source.matchSpace).toBeUndefined()
-    expect(source.matchEnter).toBeUndefined()
+    expect(typeof source.matchSpace).toBe('undefined')
+    expect(typeof source.matchEnter).toBe('undefined')
   })
 })

+ 4 - 1
packages/client/ui-slash/src/client/controller.ts

@@ -211,7 +211,10 @@ export class SlashController {
     return undefined
   }
 
-  /** Drop the menu group of a disposed source (root registry change notification). */
+  /**
+   * Drop the menu group of a disposed source (root registry change notification).
+   * @param source - the source whose registration was disposed.
+   */
   sourceRemoved(source: SlashSource): void {
     const state = this.menu.getSnapshot()
     if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {

+ 1 - 1
packages/client/ui-slash/src/client/index.ts

@@ -52,7 +52,7 @@ export function apply(ctx: ClientContext): void {
       inject: (sessionId): MenuViewInjected => {
         // Session-scoped slot: resolve this session's controller (the slot
         // frame hands ids, not ctx — the registered id→ctx interchange).
-        const actx = sessions.scope(sessionId as Parameters<typeof sessions.scope>[0])
+        const actx = sessions.scope(sessionId)
         if (actx === undefined) throw new Error(`ui-slash: session "${String(sessionId)}" resolved no scope`)
         const controller = slash.sessionOf(actx)
         return {

+ 3 - 3
packages/client/ui-slash/src/core/detect.ts

@@ -18,12 +18,12 @@ const WHITESPACE = /\s/u
  */
 function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
   if (index === 0) return true
-  const prev = draft[index - 1]!
+  const prev = draft.charAt(index - 1)
   if (WHITESPACE.test(prev)) return true
   if (WORD_CHAR.test(prev)) return false
   if (char === '/') {
     if (prev === '/') return false
-    if (prev === ':' && index >= 2 && !WHITESPACE.test(draft[index - 2]!)) return false
+    if (prev === ':' && index >= 2 && !WHITESPACE.test(draft.charAt(index - 2))) return false
   }
   return true
 }
@@ -47,7 +47,7 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
 export const detectTrigger: DetectTrigger = (draft, caret, guard) => {
   if (guard.tier === 'frozen') return null
   for (let i = caret - 1; i >= 0; i--) {
-    const ch = draft[i]!
+    const ch = draft.charAt(i)
     if (WHITESPACE.test(ch)) return null
     if (ch !== '/' && ch !== '@') continue
     if (guard.tier === 'claimed' && ch === '/') continue

+ 7 - 9
packages/client/ui-slash/src/core/menu.ts

@@ -110,15 +110,13 @@ export const menuReduce: MenuReduce = (state, ev) => {
       if (!state.open) return state
       const pos = positions(state.groups)
       if (pos.length === 0) return state
-      const at = state.highlight
-        ? pos.findIndex(p => p.source === state.highlight!.source && p.index === state.highlight!.index)
-        : -1
-      const next = at < 0
-        ? (ev.dir === 1 ? pos[0]! : pos[pos.length - 1]!)
-        : pos[(at + ev.dir + pos.length) % pos.length]!
-      if (state.highlight && next.source === state.highlight.source && next.index === state.highlight.index) {
-        return state
-      }
+      const hl = state.highlight
+      const at = hl ? pos.findIndex(p => p.source === hl.source && p.index === hl.index) : -1
+      const next = pos[at < 0
+        ? (ev.dir === 1 ? 0 : pos.length - 1)
+        : (at + ev.dir + pos.length) % pos.length]
+      if (next === undefined) return state
+      if (hl && next.source === hl.source && next.index === hl.index) return state
       return { ...state, highlight: next }
     }
     case 'close':

+ 1 - 1
packages/client/ui-slash/tests/service.spec.ts

@@ -486,7 +486,7 @@ describe('pick / scoped input events', () => {
 })
 
 describe('lexicon', () => {
-  function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] | undefined, hasHook = true): SlashSource {
+  function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[]  , hasHook = true): SlashSource {
     return {
       trigger,
       name,

+ 2 - 2
packages/client/ui-slots/src/index.ts

@@ -241,8 +241,8 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
     ? ([H] extends [StoreDecl] ? [sessionId: SessionIdOf, actions: BoundActions<HandleOf<H>>] : [sessionId: SessionIdOf])
     : ScopeOf<K> extends 'session-maybe'
       ? ([H] extends [StoreDecl]
-          ? [sessionId: SessionIdOf | undefined, actions: BoundActions<HandleOf<H>> | undefined]
-          : [sessionId: SessionIdOf | undefined])
+        ? [sessionId: SessionIdOf | undefined, actions: BoundActions<HandleOf<H>> | undefined]
+        : [sessionId: SessionIdOf | undefined])
       : ([H] extends [StoreDecl] ? [actions: BoundActions<HandleOf<H>>] : [])
 
 /** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */

+ 5 - 5
packages/client/ui-subagent/src/client/index.ts

@@ -26,14 +26,14 @@ export function apply(ctx: ClientContext): void {
   const childLabels = (session: ClientSessionContext, query: string): string[] => {
     const { byId } = sessions.list.getSnapshot()
     return Object.values(byId)
-      .filter((child) => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query))
-      .map((child) => child.displayTitle)
+      .filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query))
+      .map(child => child.displayTitle)
   }
   const source: SlashSource = {
     trigger: '@',
     name: 'subagent',
     candidates(session, { query }) {
-      return Promise.resolve(childLabels(session, query).map((name) => ({ name })))
+      return Promise.resolve(childLabels(session, query).map(name => ({ name })))
     },
     lexicon(session) {
       // The list snapshot is always warm — the full running-children roster.
@@ -47,10 +47,10 @@ export function apply(ctx: ClientContext): void {
       return { text: `@${candidate.name} ` }
     },
     codec: {
-      clipboardText: (ref) => `@${ref}`,
+      clipboardText: ref => `@${ref}`,
       // TODO: serialize returns the raw label until the '@' consumption
       // feature defines a model representation (design ledger).
-      serialize: (ref) => Promise.resolve(`@${ref}`),
+      serialize: ref => Promise.resolve(`@${ref}`),
     },
   }
   const slash = ctx.get('slash') as SlashServiceContract

+ 3 - 3
packages/client/ui-subagent/tests/browser-plugin.spec.ts

@@ -31,7 +31,7 @@ const sid = (id: string) => id as SessionId
 function sessionsWith(sessions: SessionSummary[]) {
   const byId: Record<string, SessionSummary> = {}
   for (const s of sessions) byId[s.id] = s
-  const snapshot = { ids: sessions.map((s) => s.id), byId, current: undefined } as unknown as SessionListState
+  const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
   return { list: { getSnapshot: () => snapshot } }
 }
 
@@ -139,7 +139,7 @@ describe('pick and codec', () => {
 describe('adjudication', () => {
   it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => {
     const source = await bench(FAMILY)
-    expect(source.matchSpace).toBeUndefined()
-    expect(source.matchEnter).toBeUndefined()
+    expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false)
+    expect('matchEnter' in source && source.matchEnter !== undefined).toBe(false)
   })
 })

+ 3 - 1
packages/host/apiproxy/src/fetch/handler.ts

@@ -96,7 +96,9 @@ function fullResponse(narrow: RpcResponse<unknown>): Response {
 // K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
 // schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
-async function handleUnary<K extends keyof RpcMethodMap>(api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal): Promise<Response> {
+async function handleUnary<K extends keyof RpcMethodMap>(
+  api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
+): Promise<Response> {
   const route = UNARY_ROUTES[method]
   const payload = route.schema.safeParse(message.payload)
   if (!payload.success) {

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -67,7 +67,7 @@ describe('sessions.list cold merge', () => {
     expect(a?.running).toBe(false)
     // Cold summaries are never blank: lazy persistence keeps never-appended
     // sessions out of list(), so a listed session necessarily has events.
-    expect(items.every(item => item.blank === false)).toBe(true)
+    expect(items.every(item => !item.blank)).toBe(true)
     expect(a?.cwd).toBe('/proj')
     expect(a?.parentSessionId).toBeUndefined()
     expect(b?.updatedAt).toBe(2000)

+ 1 - 1
scripts/gen-doc-graphs.ts

@@ -703,7 +703,7 @@ class EventRelationCollector {
           const eventNames = this.eventNamesFromCall(node, receiverKind)
           if (method === 'on' || method === 'once') {
             for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
-          } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
+          } else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall' || method === 'bail') {
             for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
           }
         }