dsh-ntr 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +607 -18
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/branch-action.js +133 -0
  8. package/lib/client/composer-mode.js +48 -13
  9. package/lib/client/context-meter.js +86 -0
  10. package/lib/client/index.js +238 -15
  11. package/lib/client/panel.js +96 -29
  12. package/lib/client/released-banner.js +66 -0
  13. package/lib/client/session-controls.js +38 -13
  14. package/lib/client/slash-isolation.js +2 -2
  15. package/lib/client/store.js +348 -13
  16. package/lib/client.js +1138 -67
  17. package/lib/client.js.map +1 -1
  18. package/lib/impl.js +54 -1
  19. package/lib/index.js +7 -0
  20. package/lib/protocol.js +19 -0
  21. package/lib/types/bridge.d.ts +229 -6
  22. package/lib/types/client/acp-ui.d.ts +34 -2
  23. package/lib/types/client/acp-view.d.ts +13 -2
  24. package/lib/types/client/branch-action.d.ts +40 -0
  25. package/lib/types/client/composer-mode.d.ts +28 -0
  26. package/lib/types/client/context-meter.d.ts +51 -0
  27. package/lib/types/client/released-banner.d.ts +34 -0
  28. package/lib/types/client/slash-isolation.d.ts +1 -1
  29. package/lib/types/client/store.d.ts +98 -1
  30. package/lib/types/impl.d.ts +41 -1
  31. package/lib/types/protocol.d.ts +123 -0
  32. package/package.json +2 -1
  33. package/src/bridge.ts +638 -20
  34. package/src/client/acp-ui.tsx +123 -2
  35. package/src/client/acp-view.tsx +30 -5
  36. package/src/client/branch-action.tsx +160 -0
  37. package/src/client/composer-mode.tsx +59 -13
  38. package/src/client/context-meter.tsx +128 -0
  39. package/src/client/index.tsx +265 -14
  40. package/src/client/panel.tsx +157 -40
  41. package/src/client/primitives.d.ts +27 -0
  42. package/src/client/released-banner.tsx +97 -0
  43. package/src/client/session-controls.tsx +34 -11
  44. package/src/client/slash-isolation.ts +2 -1
  45. package/src/client/store.ts +375 -14
  46. package/src/impl.ts +84 -2
  47. package/src/index.ts +8 -0
  48. package/src/protocol.ts +121 -0
@@ -13,13 +13,20 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
13
13
  import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
14
14
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
15
15
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
16
+ // The chat package's client types carry the SlotMap augmentation for
17
+ // `conversation.chat.assistant-actions` (the branch entry's seat). Type-only:
18
+ // erased at build, and the seat is owned by the host's chat module at runtime.
19
+ import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
16
20
  import { AcpStore, type RpcCall } from './store.ts'
17
21
  import { AcpSettingsSection } from './panel.tsx'
18
22
  import { AcpComposerMode } from './composer-mode.tsx'
23
+ import { AcpContextMeter } from './context-meter.tsx'
19
24
  import { AcpComposerAgentTag, AcpSessionControls, AcpSessionMenu } from './session-controls.tsx'
20
25
  import { AcpConversationView } from './acp-view.tsx'
21
26
  import { AcpSettingsDialog } from './settings-dialog.tsx'
22
27
  import { PermissionDock } from './permission-dock.tsx'
28
+ import { ReleasedBanner } from './released-banner.tsx'
29
+ import { AcpBranchAction } from './branch-action.tsx'
23
30
  import {
24
31
  createModelSeatClaim,
25
32
  createPermissionSeatClaim,
@@ -28,9 +35,9 @@ import {
28
35
  } from './model-seat.tsx'
29
36
  import type { ModelSeatClaim, SeatSlots } from './model-seat.tsx'
30
37
  import {
31
- acpModeFromPresetEvent, rowAgentPreset, stagedIsAcpMode, stagedSessionOf,
38
+ acpModeFromPresetEvent, ACP_PRESET_ID, rowAgentPreset, stagedIsAcpMode, stagedSessionOf,
32
39
  } from './acp-ui.tsx'
33
- import type { AcpChatInjected, AcpSessionFace, AcpSessionsSnapshot, SessionListSource } from './acp-ui.tsx'
40
+ import type { AcpChatInjected, AcpHostForkFace, AcpSessionFace, AcpSessionsSnapshot, SessionListSource } from './acp-ui.tsx'
34
41
  import { createSlashIsolation, slashAcpVerdict } from './slash-isolation.ts'
35
42
  import type { SlashSource } from './slash-isolation.ts'
36
43
 
@@ -58,11 +65,78 @@ interface StructuralServices {
58
65
  binding(sessionId: string): { session?: unknown } | undefined
59
66
  /** The client runtime's session list store (see `AcpSessionsSnapshot`). */
60
67
  list?: SessionListSource
68
+ /**
69
+ * The runtime's own branching primitive (`forkAt`'s engine): forks the DSH
70
+ * session tree at the latest completed turn and resolves the child id.
71
+ * Read structurally and used under try/catch — an absent `fork` is the
72
+ * branch button's `hostUnavailable` state, never a load failure.
73
+ */
74
+ fork?: (options: { sessionId: string; increaseTitle?: boolean }) => Promise<unknown>
75
+ }
76
+ /**
77
+ * The renderer's Session scope adapter (`uiSession.adapter`). Its `current`
78
+ * observable is the host's own selection — the session the composer is
79
+ * actually staged on — while the list can retain more than one row in a
80
+ * multi-view layout. Read structurally and used under try/catch: a host
81
+ * without it falls back to the list's main-view scan.
82
+ */
83
+ uiSession: {
84
+ adapter?: {
85
+ current?: {
86
+ getSnapshot(): { key?: unknown } | undefined
87
+ subscribe?(listener: () => void): () => void
88
+ }
89
+ }
90
+ }
91
+ /**
92
+ * The shell workspace service (`ctx.get("uiWorkspace")`): `openSession`
93
+ * focuses a session in the main view — the branch click's final step.
94
+ */
95
+ uiWorkspace: {
96
+ openSession?(sessionId: string): void
61
97
  }
62
98
  }
63
99
 
64
100
  const browserPrefersZh = typeof navigator !== 'undefined' && /^zh\b/i.test(navigator.language)
65
101
 
102
+ /**
103
+ * The NTR scope marker (`html[data-ntr-acp]`): the ONLY hook the message-action
104
+ * hiding CSS keys on, so the host's like / dislike / branch icons disappear in
105
+ * exactly the conversations this gate has confirmed to run the ACP preset —
106
+ * and reappear the moment the evidence stops answering (every `standDown`
107
+ * clears it; non-ACP sessions never see it). Idempotent per call.
108
+ */
109
+ const setScopeMarker = (active: boolean): void => {
110
+ if (typeof document === 'undefined') return
111
+ document.documentElement.toggleAttribute('data-ntr-acp', active)
112
+ }
113
+
114
+ /**
115
+ * One-shot sanity probe for the aria-label hiding: the host's action buttons
116
+ * carry LOCALIZED labels and no data-* hook, so a copy change (or a third
117
+ * language) silently un-hides them while everything else keeps working. Once
118
+ * the marker is on and a turn tail is actually on screen, check that at least
119
+ * one label the rules key on still exists and leave one console line for
120
+ * whoever debugs "the icons came back" — dev-visible, never user UI.
121
+ */
122
+ let probedHostLabels = false
123
+ const probeHostActionLabels = (): void => {
124
+ if (probedHostLabels || typeof document === 'undefined') return
125
+ // No turn tail rendered yet: the check would answer from nothing, so leave
126
+ // the probe armed for the next activation instead of burning it once.
127
+ if (document.querySelector('[data-turn-tail]') === null) return
128
+ probedHostLabels = true
129
+ const found = document.querySelector([
130
+ 'button:not([data-ntr-branch])[aria-label="在新对话中分支"]',
131
+ 'button:not([data-ntr-branch])[aria-label="Branch into a new conversation"]',
132
+ 'button[aria-label="好的回答"]',
133
+ 'button[aria-label="Good response"]',
134
+ ].join(', '))
135
+ if (found === null) {
136
+ console.warn('[dsh-ntr] none of the host message-action labels matched; the like/dislike/branch icons may not be hidden in NTR conversations')
137
+ }
138
+ }
139
+
66
140
  /**
67
141
  * Structural view of the shell's locale service (`@deepseek-ai/dsh-client-locale`).
68
142
  *
@@ -160,11 +234,37 @@ export function apply(ctx: Context): void {
160
234
  }
161
235
  }
162
236
 
237
+ /**
238
+ * The runtime's `sessions.fork`, resolved per call: `undefined` renders the
239
+ * branch button disabled (`hostUnavailable`) instead of hiding it, so a
240
+ * host that gains the primitive later is picked up on the next render.
241
+ */
242
+ const hostFork = (): AcpHostForkFace | undefined => {
243
+ try {
244
+ const fork = service<StructuralServices['sessions']>('sessions')?.fork
245
+ return typeof fork === 'function' ? { fork } : undefined
246
+ } catch {
247
+ return undefined
248
+ }
249
+ }
250
+
251
+ /** Focus a session in the workspace (the branch click's last step). */
252
+ const openSession = (sessionId: string): void => {
253
+ try {
254
+ service<StructuralServices['uiWorkspace']>('uiWorkspace')?.openSession?.(sessionId)
255
+ } catch {
256
+ // Opening is presentation; a host without the workspace service leaves
257
+ // the branched session in the sidebar list, reachable by hand.
258
+ }
259
+ }
260
+
163
261
  /** The injected face every ACP conversation entry receives. */
164
262
  const chatFace = (sessionId: unknown): AcpChatInjected & { sessionId: string } => ({
165
263
  acp: store,
166
264
  sessionId: String(sessionId),
167
265
  faceFor,
266
+ hostFork,
267
+ openSession,
168
268
  })
169
269
 
170
270
  ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -207,6 +307,13 @@ export function apply(ctx: Context): void {
207
307
  // (see the note there). Runtime passes the real shares either way.
208
308
  }, AcpComposerMode as unknown as SlotComponent<any>)), 'dsh-ntr: composer agent-mode control')
209
309
 
310
+ // The agent's OWN context occupancy in the composer's bottom info row
311
+ // (`conversation.composer.dock` — the dock below the input card where the
312
+ // host renders its native ContextMeter) is installed by the ACP-mode gate
313
+ // below, with the same lifetime as the NTR view tab and the seats. It is
314
+ // NOT registered unconditionally: in an ordinary conversation this row must
315
+ // hold the host's native meter and nothing of ours (see `holdAuxEntries`).
316
+
210
317
  // Its picker rides the composer's overlay seat — the platform's home for
211
318
  // menus, so the popup floats above the card instead of being clipped by it.
212
319
  ctx.effect(() => ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
@@ -248,6 +355,34 @@ export function apply(ctx: Context): void {
248
355
  inject: (sessionId: unknown) => chatFace(sessionId),
249
356
  }, PermissionDock as unknown as SlotComponent<any>)), 'dsh-ntr: conversation permission dock')
250
357
 
358
+ // The released-session banner in the same dock seat: when this
359
+ // conversation's agent process is parked (idle release or crash), one row
360
+ // says so and offers the explicit resume — the gesture form of the
361
+ // "next message resumes it" promise. Gated inside the component on the
362
+ // chat-list rows (released banner renders iff nothing live is left).
363
+ ctx.effect(() => ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
364
+ name: 'conversation.input.dock',
365
+ id: 'acp-released-banner',
366
+ order: 20,
367
+ inject: (sessionId: unknown) => chatFace(sessionId),
368
+ }, ReleasedBanner as unknown as SlotComponent<any>)), 'dsh-ntr: released-session banner')
369
+
370
+ // The NTR branch entry riding the host's own assistant-actions row
371
+ // (`conversation.chat.assistant-actions` — list, session scope; the
372
+ // positional inject argument is the chat session id, like every other
373
+ // session slot). Registered for EVERY conversation; the component gates
374
+ // itself on the confirmed ACP verdict (non-ACP renders nothing), and the
375
+ // `data-ntr-branch` attribute it stamps is the CSS pass that keeps the
376
+ // hiding rules from swallowing the plugin's own button (same aria-label as
377
+ // the host's). Order 20 puts it after the (hidden) feedback entry's 10 —
378
+ // visually the spot the host's own branch button occupied.
379
+ ctx.effect(() => ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
380
+ name: 'conversation.chat.assistant-actions',
381
+ id: 'ntr-branch',
382
+ order: 20,
383
+ inject: (sessionId: unknown) => chatFace(sessionId),
384
+ }, AcpBranchAction as unknown as SlotComponent<any>)), 'dsh-ntr: message branch action')
385
+
251
386
  /**
252
387
  * The client runtime's session list store, read structurally. Used to find
253
388
  * the session the composer is staged on and whether it runs the ACP preset —
@@ -259,6 +394,30 @@ export function apply(ctx: Context): void {
259
394
  return list
260
395
  }
261
396
 
397
+ /**
398
+ * The renderer's current Session scope observable, when this host exposes it.
399
+ * This is the source the host's own main-view picker prefers; the list's
400
+ * retained rows are only the fallback for hosts without the adapter.
401
+ */
402
+ const currentScopeSource = (): NonNullable<StructuralServices['uiSession']['adapter']>['current'] => {
403
+ try {
404
+ const current = service<StructuralServices['uiSession']>('uiSession')?.adapter?.current
405
+ return current !== undefined && typeof current.getSnapshot === 'function' ? current : undefined
406
+ } catch {
407
+ return undefined
408
+ }
409
+ }
410
+
411
+ /** The session the composer is actually staged on, if the runtime can say. */
412
+ const currentScopeSessionId = (): string | undefined => {
413
+ try {
414
+ const key = currentScopeSource()?.getSnapshot?.()?.key
415
+ return typeof key === 'string' && key !== '' ? key : undefined
416
+ } catch {
417
+ return undefined
418
+ }
419
+ }
420
+
262
421
  /**
263
422
  * Chat session ids the ACP-mode tracker has already asked the host about (the
264
423
  * `session/chat-list` fallback), mapped to WHEN the last ask went out. The
@@ -286,6 +445,8 @@ export function apply(ctx: Context): void {
286
445
 
287
446
  ctx.effect(() => {
288
447
  let stopWatch: (() => void) | undefined
448
+ /** Subscription to the renderer's current Session binding (see `currentScopeSource`). */
449
+ let stopCurrent: (() => void) | undefined
289
450
  /**
290
451
  * The claim on the host's model seat, held while the staged conversation
291
452
  * runs the ACP preset. That cell is where the host's own base sits (the
@@ -321,6 +482,52 @@ export function apply(ctx: Context): void {
321
482
  * available yet.
322
483
  */
323
484
  let permissionClaim: ModelSeatClaim | undefined
485
+ /**
486
+ * ACP-only auxiliary entries: the NTR view tab and the composer's context
487
+ * ring. Unlike the two seats these are `list` slots with no visibility
488
+ * predicate of their own, so "self-hide in the component" cannot remove a
489
+ * tab from the header or free the native bottom row. They are installed by
490
+ * this gate for exactly the same lifetime as the claims and disposed on
491
+ * every stand-down — a standard conversation then has no NTR tab at all,
492
+ * and the host's native context meter sits alone in its dock row.
493
+ */
494
+ let viewEntry: (() => void) | undefined
495
+ let meterEntry: (() => void) | undefined
496
+ const releaseAuxEntries = (): void => {
497
+ try { viewEntry?.() } catch {}
498
+ viewEntry = undefined
499
+ try { meterEntry?.() } catch {}
500
+ meterEntry = undefined
501
+ }
502
+ const holdAuxEntries = (): void => {
503
+ // A refused registration leaves the native surface in place (fail-open);
504
+ // the next evaluation retries when the slot is declared.
505
+ if (viewEntry === undefined) {
506
+ try {
507
+ viewEntry = ctx.slots.inject('conversation.view', () => ctx.slots.register({
508
+ name: 'conversation.view',
509
+ id: 'acp',
510
+ order: 20,
511
+ label: () => label('NTR', 'NTR'),
512
+ inject: (sessionId: unknown) => chatFace(sessionId),
513
+ }, AcpConversationView as unknown as SlotComponent<any>))
514
+ } catch {
515
+ viewEntry = undefined
516
+ }
517
+ }
518
+ if (meterEntry === undefined) {
519
+ try {
520
+ meterEntry = ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
521
+ name: 'conversation.composer.dock',
522
+ id: 'acp-context-meter',
523
+ order: 10,
524
+ inject: (sessionId: unknown) => chatFace(sessionId),
525
+ }, AcpContextMeter as unknown as SlotComponent<any>))
526
+ } catch {
527
+ meterEntry = undefined
528
+ }
529
+ }
530
+ }
324
531
  /**
325
532
  * Last diagnostic line from {@link syncAcpMode}. Every way this gate can
326
533
  * end with the native seat still on screen used to be silent — an inner
@@ -333,7 +540,8 @@ export function apply(ctx: Context): void {
333
540
  let seatDiag = 'idle'
334
541
  let permissionSeatDiag = 'idle'
335
542
  /**
336
- * Hand BOTH cells back to their shipped occupants.
543
+ * Hand every ACP-only surface back: both composer cells, the NTR view tab
544
+ * and the context ring.
337
545
  *
338
546
  * Every path that is not "this staged conversation is CONFIRMED to run the
339
547
  * ACP preset" ends here — including the paths that carry no verdict at all
@@ -355,6 +563,8 @@ export function apply(ctx: Context): void {
355
563
  // read identically in the one place either is observable.
356
564
  permissionSeatDiag = `${reason} (released)`
357
565
  disarmVerdictReask()
566
+ setScopeMarker(false)
567
+ releaseAuxEntries()
358
568
  if (claim !== undefined) { claim.release(); claim = undefined }
359
569
  if (permissionClaim !== undefined) { permissionClaim.release(); permissionClaim = undefined }
360
570
  }
@@ -388,7 +598,13 @@ export function apply(ctx: Context): void {
388
598
  standDown('no session list service')
389
599
  return
390
600
  }
391
- const staged = stagedSessionOf(snapshot)
601
+ // Prefer the renderer's own current selection; the list's retained rows
602
+ // are the fallback for hosts without the scope adapter. A multi-view
603
+ // layout can leave several rows retained, and judging the first one (or
604
+ // the one the previous switch left behind) would hold the ACP chrome
605
+ // while an ordinary conversation is on screen — the reported drift.
606
+ const current = currentScopeSessionId()
607
+ const staged = stagedSessionOf(snapshot, current)
392
608
  if (staged === undefined) {
393
609
  standDown('no staged session (nothing retains the main view)')
394
610
  return
@@ -444,13 +660,23 @@ export function apply(ctx: Context): void {
444
660
  // nothing. Both cells follow the same evidence — the staged
445
661
  // conversation's preset — through one {@link standDown}, so neither can
446
662
  // stay claimed after the evidence for it stops answering.
447
- if (!stagedIsAcpMode(snapshot, acpModeVerdict)) {
663
+ if (!stagedIsAcpMode(snapshot, acpModeVerdict, current)) {
664
+ // The row's own verdict is authoritative even when it says no: fold it
665
+ // into the store so every per-conversation surface (the branch
666
+ // button's ACP gate included) stops rendering without waiting for a
667
+ // host read. A silent row folds nothing — that case is the ask path's.
668
+ if (rowAgentPreset(staged.row) !== undefined) store.noteAcpMode(staged.sessionId, false)
448
669
  standDown(`staged ${staged.sessionId} is not in ACP mode`)
449
670
  return
450
671
  }
451
672
  // The row speaks, so the host verdict is not this gate's only fact
452
- // source and no re-ask timer is needed.
453
- if (rowAgentPreset(staged.row) !== undefined) disarmVerdictReask()
673
+ // source and no re-ask timer is needed. The same fold in the affirmative
674
+ // direction: the runtime row IS the preset truth, and the branch
675
+ // button's gate reads the folded map, not the snapshot.
676
+ if (rowAgentPreset(staged.row) !== undefined) {
677
+ store.noteAcpMode(staged.sessionId, rowAgentPreset(staged.row) === ACP_PRESET_ID)
678
+ disarmVerdictReask()
679
+ }
454
680
  // Stays re-runnable on purpose: the seat is not declared before the
455
681
  // composer bar mounts, a registration can be refused, and the cell can be
456
682
  // taken from us later. The claim reports each of those through the
@@ -458,6 +684,8 @@ export function apply(ctx: Context): void {
458
684
  // next evaluation (store mutation, list notification, preset frame, or a
459
685
  // change to the cell itself) simply tries again.
460
686
  seatDiag = `staged ${staged.sessionId} is ACP mode`
687
+ setScopeMarker(true)
688
+ probeHostActionLabels()
461
689
  claim ??= createModelSeatClaim({
462
690
  slots: ctx.slots as unknown as SeatSlots,
463
691
  occupant: () => AcpSessionControls,
@@ -478,6 +706,11 @@ export function apply(ctx: Context): void {
478
706
  onDiag: (line) => { permissionSeatDiag = `staged ${staged.sessionId} · ${line}` },
479
707
  })
480
708
  permissionClaim.sync()
709
+ // The tab and the occupancy ring have the same lifetime as the claims:
710
+ // they exist only while this staged conversation is an ACP one, and a
711
+ // stand-down disposes them (a standard conversation must not see an NTR
712
+ // tab, and the host's native bottom row must stand alone there).
713
+ holdAuxEntries()
481
714
  // Confirmed ACP on a silent row: the verdict is all the evidence there
482
715
  // is, so it gets a re-ask deadline (see {@link ACP_MODE_VERDICT_TTL_MS}).
483
716
  if (rowAgentPreset(staged.row) === undefined) armVerdictReask()
@@ -531,6 +764,18 @@ export function apply(ctx: Context): void {
531
764
  const attach = (): void => {
532
765
  const list = sessionsList()
533
766
  if (list !== undefined && stopWatch === undefined) stopWatch = list.subscribe(runGate)
767
+ // The selection can move without the list changing (a tab or sidebar
768
+ // click), and `uiSession.current` is the host's own answer to "which
769
+ // conversation is on screen". Re-run the gate on that transition so the
770
+ // ACP chrome leaves before the newly staged ordinary conversation paints.
771
+ const current = currentScopeSource()
772
+ if (current?.subscribe !== undefined && stopCurrent === undefined) {
773
+ try {
774
+ stopCurrent = current.subscribe(runGate)
775
+ } catch {
776
+ stopCurrent = undefined
777
+ }
778
+ }
534
779
  attachPresetFrames()
535
780
  runGate()
536
781
  }
@@ -594,7 +839,12 @@ export function apply(ctx: Context): void {
594
839
  stopCell()
595
840
  stopPreset?.()
596
841
  stopWatch?.()
842
+ stopCurrent?.()
597
843
  disarmVerdictReask()
844
+ // The scope marker dies with the gate: a reload (HMR) must not leave the
845
+ // hiding CSS pointed at conversations nothing confirms anymore.
846
+ setScopeMarker(false)
847
+ releaseAuxEntries()
598
848
  if (claim !== undefined) { claim.release(); claim = undefined }
599
849
  if (permissionClaim !== undefined) { permissionClaim.release(); permissionClaim = undefined }
600
850
  store.noteSeatDiag('gate disposed')
@@ -636,12 +886,14 @@ export function apply(ctx: Context): void {
636
886
  sessionsList()?.getSnapshot(),
637
887
  sessionId,
638
888
  id => store.getSnapshot().acpMode[id],
889
+ currentScopeSessionId(),
639
890
  ),
640
891
  ensureVerdict: async (sessionId) => {
641
892
  const held = (): boolean | undefined => slashAcpVerdict(
642
893
  sessionsList()?.getSnapshot(),
643
894
  sessionId,
644
895
  id => store.getSnapshot().acpMode[id],
896
+ currentScopeSessionId(),
645
897
  )
646
898
  const known = held()
647
899
  if (known !== undefined) return known
@@ -724,11 +976,10 @@ export function apply(ctx: Context): void {
724
976
  // diffs). Session-scope list entry; the view ring renders it one tab at a
725
977
  // time. Meanwhile the agent's prose and thinking ride the NATIVE chat plane
726
978
  // through the LLM adapter — see docs/acp-convergence.md §2.1.
727
- ctx.effect(() => ctx.slots.inject('conversation.view', () => ctx.slots.register({
728
- name: 'conversation.view',
729
- id: 'acp',
730
- order: 20,
731
- label: () => 'NTR',
732
- inject: (sessionId: unknown) => chatFace(sessionId),
733
- }, AcpConversationView as unknown as SlotComponent<any>)), 'dsh-ntr: ACP view tab')
979
+ //
980
+ // It is installed by the ACP-mode gate above (`holdAuxEntries`) and NOT at
981
+ // plugin load: the host projects every registered `conversation.view` entry
982
+ // into EVERY session's tab strip, so an unconditional registration showed
983
+ // the NTR tab on ordinary conversations too. The gate's lifetime is the
984
+ // only per-session seam the host offers.
734
985
  }