dsh-ntr 0.3.1 → 0.3.3

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 (57) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +2436 -82
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/activity-pill.js +156 -0
  8. package/lib/client/branch-action.js +149 -0
  9. package/lib/client/composer-mode.js +48 -13
  10. package/lib/client/context-meter.js +86 -0
  11. package/lib/client/index.js +279 -16
  12. package/lib/client/model-seat.js +8 -2
  13. package/lib/client/panel.js +96 -29
  14. package/lib/client/released-banner.js +66 -0
  15. package/lib/client/session-controls.js +38 -13
  16. package/lib/client/slash-isolation.js +2 -2
  17. package/lib/client/store.js +380 -13
  18. package/lib/client.js +1364 -70
  19. package/lib/client.js.map +1 -1
  20. package/lib/impl.js +1229 -8
  21. package/lib/index.js +34 -0
  22. package/lib/protocol.js +19 -0
  23. package/lib/tps.js +32 -0
  24. package/lib/types/bridge.d.ts +850 -18
  25. package/lib/types/client/acp-ui.d.ts +34 -2
  26. package/lib/types/client/acp-view.d.ts +13 -2
  27. package/lib/types/client/activity-pill.d.ts +81 -0
  28. package/lib/types/client/branch-action.d.ts +47 -0
  29. package/lib/types/client/composer-mode.d.ts +28 -0
  30. package/lib/types/client/context-meter.d.ts +51 -0
  31. package/lib/types/client/model-seat.d.ts +21 -0
  32. package/lib/types/client/released-banner.d.ts +34 -0
  33. package/lib/types/client/slash-isolation.d.ts +1 -1
  34. package/lib/types/client/store.d.ts +108 -1
  35. package/lib/types/impl.d.ts +94 -7
  36. package/lib/types/protocol.d.ts +145 -0
  37. package/lib/types/tps.d.ts +17 -0
  38. package/package.json +2 -1
  39. package/src/bridge.ts +2754 -86
  40. package/src/client/acp-ui.tsx +123 -2
  41. package/src/client/acp-view.tsx +30 -5
  42. package/src/client/activity-pill.tsx +201 -0
  43. package/src/client/branch-action.tsx +178 -0
  44. package/src/client/composer-mode.tsx +59 -13
  45. package/src/client/context-meter.tsx +128 -0
  46. package/src/client/index.tsx +298 -15
  47. package/src/client/model-seat.tsx +28 -3
  48. package/src/client/panel.tsx +157 -40
  49. package/src/client/primitives.d.ts +27 -0
  50. package/src/client/released-banner.tsx +97 -0
  51. package/src/client/session-controls.tsx +34 -11
  52. package/src/client/slash-isolation.ts +2 -1
  53. package/src/client/store.ts +412 -14
  54. package/src/impl.ts +1251 -13
  55. package/src/index.ts +33 -0
  56. package/src/protocol.ts +145 -0
  57. package/src/tps.ts +40 -0
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Composer context-occupancy ring at `conversation.composer.dock` — the bottom
4
+ * info row below the input card, where the host also renders its ContextMeter.
5
+ *
6
+ * WHY A PLUGIN-OWNED RING: the host's meter reads its `contextPressure`
7
+ * projection, folded from DSH provider-usage samples plus a route capacity. An
8
+ * ACP conversation produces neither — the agent owns the request and the host
9
+ * never sees its prompt — so the native meter renders nothing there. This ring
10
+ * reports the agent's OWN `usage_update` used/size instead: occupancy only. ACP
11
+ * declares no system/tools/messages split, so no breakdown is invented.
12
+ *
13
+ * Registered for every conversation and gating itself: it renders only for an
14
+ * ACP conversation whose session row carries usage. The row comes from
15
+ * `session/chat-list`; the store live-patches it from the coalesced
16
+ * `usage_update` frames (see `AcpStore.flushUpdates`).
17
+ */
18
+ import { useEffect } from 'react';
19
+ import { ensureAcpStyle, useUi } from "./acp-ui.js";
20
+ /**
21
+ * The sample this conversation's ring should show, or `undefined` when the
22
+ * entry must render nothing. An explicit `acpMode === false` outranks a stale
23
+ * `chatSessions` row: the conversation's preset left NTR while its ACP session
24
+ * was still alive, and the ring must leave with it (same precedence as the
25
+ * agent tag's `agentTagVisible`). While the verdict is unknown, a LIVE row is
26
+ * proof; a PARKED one is not — it can outlive the preset switch, and the ring
27
+ * must not appear on an ordinary conversation during the answer window.
28
+ */
29
+ export function acpMeterUsage(ui, dshSessionId) {
30
+ if (ui.acpMode[dshSessionId] === false)
31
+ return undefined;
32
+ if (ui.chatSessionsFor !== dshSessionId)
33
+ return undefined;
34
+ const live = ui.chatSessions.find(row => !row.released);
35
+ // A PARKED row alone is not proof this conversation is still in NTR mode:
36
+ // switching off the preset leaves the ACP session record alive, and while the
37
+ // host has not answered (`undefined`) the ring must not appear on an
38
+ // ordinary conversation. A live row is proof; a parked one needs the
39
+ // affirmative verdict.
40
+ if (live === undefined && ui.acpMode[dshSessionId] !== true)
41
+ return undefined;
42
+ return (live ?? ui.chatSessions[0])?.usage;
43
+ }
44
+ /**
45
+ * Bounded display occupancy from one agent sample. `undefined` (no meter) for
46
+ * an absent / malformed / zero-capacity sample rather than a made-up 0%.
47
+ */
48
+ export function contextOccupancy(usage) {
49
+ if (usage === undefined)
50
+ return undefined;
51
+ const { used, size } = usage;
52
+ if (!Number.isFinite(used) || !Number.isFinite(size) || used < 0 || size <= 0)
53
+ return undefined;
54
+ return { percent: Math.min(100, Math.round(used / size * 100)), used, size };
55
+ }
56
+ /** Compact token count for the tooltip, in the host meter's K/M shape. */
57
+ export function compactTokenCount(value) {
58
+ if (value < 1e3)
59
+ return String(Math.round(value));
60
+ const thousands = value / 1e3;
61
+ if (value < 1e6) {
62
+ return `${thousands >= 100 ? String(Math.round(thousands)) : String(Math.round(thousands * 10) / 10)}K`;
63
+ }
64
+ const millions = value / 1e6;
65
+ return `${millions >= 100 ? String(Math.round(millions)) : String(Math.round(millions * 10) / 10)}M`;
66
+ }
67
+ /** Ring geometry matches the host ContextMeter: 14px viewBox, 2px stroke. */
68
+ const RADIUS = 5.5;
69
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
70
+ export function AcpContextMeter({ acp, sessionId, useSession }) {
71
+ ensureAcpStyle();
72
+ const ui = useUi(acp);
73
+ const dshSessionId = String(sessionId);
74
+ // A fresh conversation gets its ACP session binding during its first turn,
75
+ // and no SSE frame announces that; the running flip is the re-read trigger
76
+ // every composer entry shares.
77
+ const running = useSession(snapshot => snapshot.running);
78
+ useEffect(() => {
79
+ void acp.loadChatSessions(dshSessionId);
80
+ }, [acp, dshSessionId, running]);
81
+ const context = contextOccupancy(acpMeterUsage(ui, dshSessionId));
82
+ if (context === undefined)
83
+ return null;
84
+ const title = acp.label(`上下文已用 ${String(context.percent)}% · ${compactTokenCount(context.used)} / ${compactTokenCount(context.size)}`, `${String(context.percent)}% of context used · ${compactTokenCount(context.used)} / ${compactTokenCount(context.size)}`);
85
+ return (_jsxs("span", { className: "ntr-acp-meter", role: "img", "aria-label": title, title: title, children: [_jsxs("svg", { viewBox: "0 0 14 14", width: "14", height: "14", "aria-hidden": "true", children: [_jsx("circle", { className: "ntr-acp-meter-track", cx: "7", cy: "7", r: RADIUS }), _jsx("circle", { className: "ntr-acp-meter-fill", cx: "7", cy: "7", r: RADIUS, strokeDasharray: `${String(CIRCUMFERENCE * context.percent / 100)} ${String(CIRCUMFERENCE)}`, transform: "rotate(-90 7 7)" })] }), _jsxs("span", { className: "ntr-acp-meter-percent", children: [context.percent, "%"] })] }));
86
+ }
@@ -8,16 +8,59 @@
8
8
  import { AcpStore } from "./store.js";
9
9
  import { AcpSettingsSection } from "./panel.js";
10
10
  import { AcpComposerMode } from "./composer-mode.js";
11
+ import { AcpContextMeter } from "./context-meter.js";
11
12
  import { AcpComposerAgentTag, AcpSessionControls, AcpSessionMenu } from "./session-controls.js";
12
13
  import { AcpConversationView } from "./acp-view.js";
13
14
  import { AcpSettingsDialog } from "./settings-dialog.js";
14
15
  import { PermissionDock } from "./permission-dock.js";
16
+ import { ReleasedBanner } from "./released-banner.js";
17
+ import { AcpBranchAction, resolveHostFork } from "./branch-action.js";
18
+ import { createActivityPillClaim, DOCK_SLOT } from "./activity-pill.js";
15
19
  import { createModelSeatClaim, createPermissionSeatClaim, MODEL_SEAT_KEY, PERMISSION_SEAT_KEY, } from "./model-seat.js";
16
- import { acpModeFromPresetEvent, rowAgentPreset, stagedIsAcpMode, stagedSessionOf, } from "./acp-ui.js";
20
+ import { acpModeFromPresetEvent, ACP_PRESET_ID, rowAgentPreset, stagedIsAcpMode, stagedSessionOf, } from "./acp-ui.js";
17
21
  import { createSlashIsolation, slashAcpVerdict } from "./slash-isolation.js";
18
22
  export const name = 'dsh-ntr-client';
19
23
  export const inject = ['slots', 'connection'];
20
24
  const browserPrefersZh = typeof navigator !== 'undefined' && /^zh\b/i.test(navigator.language);
25
+ /**
26
+ * The NTR scope marker (`html[data-ntr-acp]`): the ONLY hook the message-action
27
+ * hiding CSS keys on, so the host's like / dislike / branch icons disappear in
28
+ * exactly the conversations this gate has confirmed to run the ACP preset —
29
+ * and reappear the moment the evidence stops answering (every `standDown`
30
+ * clears it; non-ACP sessions never see it). Idempotent per call.
31
+ */
32
+ const setScopeMarker = (active) => {
33
+ if (typeof document === 'undefined')
34
+ return;
35
+ document.documentElement.toggleAttribute('data-ntr-acp', active);
36
+ };
37
+ /**
38
+ * One-shot sanity probe for the aria-label hiding: the host's action buttons
39
+ * carry LOCALIZED labels and no data-* hook, so a copy change (or a third
40
+ * language) silently un-hides them while everything else keeps working. Once
41
+ * the marker is on and a turn tail is actually on screen, check that at least
42
+ * one label the rules key on still exists and leave one console line for
43
+ * whoever debugs "the icons came back" — dev-visible, never user UI.
44
+ */
45
+ let probedHostLabels = false;
46
+ const probeHostActionLabels = () => {
47
+ if (probedHostLabels || typeof document === 'undefined')
48
+ return;
49
+ // No turn tail rendered yet: the check would answer from nothing, so leave
50
+ // the probe armed for the next activation instead of burning it once.
51
+ if (document.querySelector('[data-turn-tail]') === null)
52
+ return;
53
+ probedHostLabels = true;
54
+ const found = document.querySelector([
55
+ 'button:not([data-ntr-branch])[aria-label="在新对话中分支"]',
56
+ 'button:not([data-ntr-branch])[aria-label="Branch into a new conversation"]',
57
+ 'button[aria-label="好的回答"]',
58
+ 'button[aria-label="Good response"]',
59
+ ].join(', '));
60
+ if (found === null) {
61
+ console.warn('[dsh-ntr] none of the host message-action labels matched; the like/dislike/branch icons may not be hidden in NTR conversations');
62
+ }
63
+ };
21
64
  /**
22
65
  * How long a host verdict may stand as the gate's ONLY fact source before the
23
66
  * gate asks the host again.
@@ -97,11 +140,36 @@ export function apply(ctx) {
97
140
  return undefined;
98
141
  }
99
142
  };
143
+ /**
144
+ * The runtime's `sessions.fork`, resolved per call: `undefined` renders the
145
+ * branch button disabled (`hostUnavailable`) instead of hiding it, so a
146
+ * host that gains the primitive later is picked up on the next render.
147
+ */
148
+ const hostFork = () => {
149
+ try {
150
+ return resolveHostFork(service('sessions'));
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ };
156
+ /** Focus a session in the workspace (the branch click's last step). */
157
+ const openSession = (sessionId) => {
158
+ try {
159
+ service('uiWorkspace')?.openSession?.(sessionId);
160
+ }
161
+ catch {
162
+ // Opening is presentation; a host without the workspace service leaves
163
+ // the branched session in the sidebar list, reachable by hand.
164
+ }
165
+ };
100
166
  /** The injected face every ACP conversation entry receives. */
101
167
  const chatFace = (sessionId) => ({
102
168
  acp: store,
103
169
  sessionId: String(sessionId),
104
170
  faceFor,
171
+ hostFork,
172
+ openSession,
105
173
  });
106
174
  ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
107
175
  name: 'settings.section',
@@ -140,6 +208,12 @@ export function apply(ctx) {
140
208
  // composer-mode.tsx, so the composed-props constraint cannot be met by type
141
209
  // (see the note there). Runtime passes the real shares either way.
142
210
  }, AcpComposerMode)), 'dsh-ntr: composer agent-mode control');
211
+ // The agent's OWN context occupancy in the composer's bottom info row
212
+ // (`conversation.composer.dock` — the dock below the input card where the
213
+ // host renders its native ContextMeter) is installed by the ACP-mode gate
214
+ // below, with the same lifetime as the NTR view tab and the seats. It is
215
+ // NOT registered unconditionally: in an ordinary conversation this row must
216
+ // hold the host's native meter and nothing of ours (see `holdAuxEntries`).
143
217
  // Its picker rides the composer's overlay seat — the platform's home for
144
218
  // menus, so the popup floats above the card instead of being clipped by it.
145
219
  ctx.effect(() => ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
@@ -178,6 +252,32 @@ export function apply(ctx) {
178
252
  // session id this dock belongs to (same contract as every other entry).
179
253
  inject: (sessionId) => chatFace(sessionId),
180
254
  }, PermissionDock)), 'dsh-ntr: conversation permission dock');
255
+ // The released-session banner in the same dock seat: when this
256
+ // conversation's agent process is parked (idle release or crash), one row
257
+ // says so and offers the explicit resume — the gesture form of the
258
+ // "next message resumes it" promise. Gated inside the component on the
259
+ // chat-list rows (released banner renders iff nothing live is left).
260
+ ctx.effect(() => ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
261
+ name: 'conversation.input.dock',
262
+ id: 'acp-released-banner',
263
+ order: 20,
264
+ inject: (sessionId) => chatFace(sessionId),
265
+ }, ReleasedBanner)), 'dsh-ntr: released-session banner');
266
+ // The NTR branch entry riding the host's own assistant-actions row
267
+ // (`conversation.chat.assistant-actions` — list, session scope; the
268
+ // positional inject argument is the chat session id, like every other
269
+ // session slot). Registered for EVERY conversation; the component gates
270
+ // itself on the confirmed ACP verdict (non-ACP renders nothing), and the
271
+ // `data-ntr-branch` attribute it stamps is the CSS pass that keeps the
272
+ // hiding rules from swallowing the plugin's own button (same aria-label as
273
+ // the host's). Order 20 puts it after the (hidden) feedback entry's 10 —
274
+ // visually the spot the host's own branch button occupied.
275
+ ctx.effect(() => ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
276
+ name: 'conversation.chat.assistant-actions',
277
+ id: 'ntr-branch',
278
+ order: 20,
279
+ inject: (sessionId) => chatFace(sessionId),
280
+ }, AcpBranchAction)), 'dsh-ntr: message branch action');
181
281
  /**
182
282
  * The client runtime's session list store, read structurally. Used to find
183
283
  * the session the composer is staged on and whether it runs the ACP preset —
@@ -189,6 +289,30 @@ export function apply(ctx) {
189
289
  return undefined;
190
290
  return list;
191
291
  };
292
+ /**
293
+ * The renderer's current Session scope observable, when this host exposes it.
294
+ * This is the source the host's own main-view picker prefers; the list's
295
+ * retained rows are only the fallback for hosts without the adapter.
296
+ */
297
+ const currentScopeSource = () => {
298
+ try {
299
+ const current = service('uiSession')?.adapter?.current;
300
+ return current !== undefined && typeof current.getSnapshot === 'function' ? current : undefined;
301
+ }
302
+ catch {
303
+ return undefined;
304
+ }
305
+ };
306
+ /** The session the composer is actually staged on, if the runtime can say. */
307
+ const currentScopeSessionId = () => {
308
+ try {
309
+ const key = currentScopeSource()?.getSnapshot?.()?.key;
310
+ return typeof key === 'string' && key !== '' ? key : undefined;
311
+ }
312
+ catch {
313
+ return undefined;
314
+ }
315
+ };
192
316
  /**
193
317
  * Chat session ids the ACP-mode tracker has already asked the host about (the
194
318
  * `session/chat-list` fallback), mapped to WHEN the last ask went out. The
@@ -214,6 +338,8 @@ export function apply(ctx) {
214
338
  const acpModeVerdict = (sessionId) => store.getSnapshot().acpMode[sessionId];
215
339
  ctx.effect(() => {
216
340
  let stopWatch;
341
+ /** Subscription to the renderer's current Session binding (see `currentScopeSource`). */
342
+ let stopCurrent;
217
343
  /**
218
344
  * The claim on the host's model seat, held while the staged conversation
219
345
  * runs the ACP preset. That cell is where the host's own base sits (the
@@ -249,6 +375,82 @@ export function apply(ctx) {
249
375
  * available yet.
250
376
  */
251
377
  let permissionClaim;
378
+ /**
379
+ * Claim on the dock's `activity` cell (`conversation.composer.dock`, id
380
+ * `activity`), held for the same ACP-mode spell as the two seats. The
381
+ * occupant renders the HOST's ActivityPill with the plugin's burst speed
382
+ * patched into its `sessionStats`; it is a list cell, so the claim is
383
+ * identified by id rather than by the slot's heads[0]. If the host shape is
384
+ * not one this bundle knows, no claim is created and the native pill stays.
385
+ */
386
+ let activityClaim;
387
+ let activityDiag = 'idle';
388
+ const noteActivityDiag = (line) => {
389
+ if (line === activityDiag)
390
+ return;
391
+ activityDiag = line;
392
+ // Success and the ordinary older-host "no activity entry" are silent; a
393
+ // shape mismatch or a lost registration must leave a breadcrumb. The ACP
394
+ // view has no line for this surface, and a missing speed fix is otherwise
395
+ // invisible.
396
+ if (!/failed|missing|taken|gave up|still held/.test(line))
397
+ return;
398
+ console.warn(`[dsh-ntr] ${line}`);
399
+ };
400
+ /**
401
+ * ACP-only auxiliary entries: the NTR view tab and the composer's context
402
+ * ring. Unlike the two seats these are `list` slots with no visibility
403
+ * predicate of their own, so "self-hide in the component" cannot remove a
404
+ * tab from the header or free the native bottom row. They are installed by
405
+ * this gate for exactly the same lifetime as the claims and disposed on
406
+ * every stand-down — a standard conversation then has no NTR tab at all,
407
+ * and the host's native context meter sits alone in its dock row.
408
+ */
409
+ let viewEntry;
410
+ let meterEntry;
411
+ const releaseAuxEntries = () => {
412
+ try {
413
+ viewEntry?.();
414
+ }
415
+ catch { }
416
+ viewEntry = undefined;
417
+ try {
418
+ meterEntry?.();
419
+ }
420
+ catch { }
421
+ meterEntry = undefined;
422
+ };
423
+ const holdAuxEntries = () => {
424
+ // A refused registration leaves the native surface in place (fail-open);
425
+ // the next evaluation retries when the slot is declared.
426
+ if (viewEntry === undefined) {
427
+ try {
428
+ viewEntry = ctx.slots.inject('conversation.view', () => ctx.slots.register({
429
+ name: 'conversation.view',
430
+ id: 'acp',
431
+ order: 20,
432
+ label: () => label('NTR', 'NTR'),
433
+ inject: (sessionId) => chatFace(sessionId),
434
+ }, AcpConversationView));
435
+ }
436
+ catch {
437
+ viewEntry = undefined;
438
+ }
439
+ }
440
+ if (meterEntry === undefined) {
441
+ try {
442
+ meterEntry = ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
443
+ name: 'conversation.composer.dock',
444
+ id: 'acp-context-meter',
445
+ order: 10,
446
+ inject: (sessionId) => chatFace(sessionId),
447
+ }, AcpContextMeter));
448
+ }
449
+ catch {
450
+ meterEntry = undefined;
451
+ }
452
+ }
453
+ };
252
454
  /**
253
455
  * Last diagnostic line from {@link syncAcpMode}. Every way this gate can
254
456
  * end with the native seat still on screen used to be silent — an inner
@@ -261,7 +463,8 @@ export function apply(ctx) {
261
463
  let seatDiag = 'idle';
262
464
  let permissionSeatDiag = 'idle';
263
465
  /**
264
- * Hand BOTH cells back to their shipped occupants.
466
+ * Hand every ACP-only surface back: both composer cells, the NTR view tab
467
+ * and the context ring.
265
468
  *
266
469
  * Every path that is not "this staged conversation is CONFIRMED to run the
267
470
  * ACP preset" ends here — including the paths that carry no verdict at all
@@ -283,6 +486,8 @@ export function apply(ctx) {
283
486
  // read identically in the one place either is observable.
284
487
  permissionSeatDiag = `${reason} (released)`;
285
488
  disarmVerdictReask();
489
+ setScopeMarker(false);
490
+ releaseAuxEntries();
286
491
  if (claim !== undefined) {
287
492
  claim.release();
288
493
  claim = undefined;
@@ -291,6 +496,10 @@ export function apply(ctx) {
291
496
  permissionClaim.release();
292
497
  permissionClaim = undefined;
293
498
  }
499
+ if (activityClaim !== undefined) {
500
+ activityClaim.release();
501
+ activityClaim = undefined;
502
+ }
294
503
  };
295
504
  /**
296
505
  * Re-ask timer for the staged session, armed while the row is silent and a
@@ -324,7 +533,13 @@ export function apply(ctx) {
324
533
  standDown('no session list service');
325
534
  return;
326
535
  }
327
- const staged = stagedSessionOf(snapshot);
536
+ // Prefer the renderer's own current selection; the list's retained rows
537
+ // are the fallback for hosts without the scope adapter. A multi-view
538
+ // layout can leave several rows retained, and judging the first one (or
539
+ // the one the previous switch left behind) would hold the ACP chrome
540
+ // while an ordinary conversation is on screen — the reported drift.
541
+ const current = currentScopeSessionId();
542
+ const staged = stagedSessionOf(snapshot, current);
328
543
  if (staged === undefined) {
329
544
  standDown('no staged session (nothing retains the main view)');
330
545
  return;
@@ -381,14 +596,24 @@ export function apply(ctx) {
381
596
  // nothing. Both cells follow the same evidence — the staged
382
597
  // conversation's preset — through one {@link standDown}, so neither can
383
598
  // stay claimed after the evidence for it stops answering.
384
- if (!stagedIsAcpMode(snapshot, acpModeVerdict)) {
599
+ if (!stagedIsAcpMode(snapshot, acpModeVerdict, current)) {
600
+ // The row's own verdict is authoritative even when it says no: fold it
601
+ // into the store so every per-conversation surface (the branch
602
+ // button's ACP gate included) stops rendering without waiting for a
603
+ // host read. A silent row folds nothing — that case is the ask path's.
604
+ if (rowAgentPreset(staged.row) !== undefined)
605
+ store.noteAcpMode(staged.sessionId, false);
385
606
  standDown(`staged ${staged.sessionId} is not in ACP mode`);
386
607
  return;
387
608
  }
388
609
  // The row speaks, so the host verdict is not this gate's only fact
389
- // source and no re-ask timer is needed.
390
- if (rowAgentPreset(staged.row) !== undefined)
610
+ // source and no re-ask timer is needed. The same fold in the affirmative
611
+ // direction: the runtime row IS the preset truth, and the branch
612
+ // button's gate reads the folded map, not the snapshot.
613
+ if (rowAgentPreset(staged.row) !== undefined) {
614
+ store.noteAcpMode(staged.sessionId, rowAgentPreset(staged.row) === ACP_PRESET_ID);
391
615
  disarmVerdictReask();
616
+ }
392
617
  // Stays re-runnable on purpose: the seat is not declared before the
393
618
  // composer bar mounts, a registration can be refused, and the cell can be
394
619
  // taken from us later. The claim reports each of those through the
@@ -396,6 +621,8 @@ export function apply(ctx) {
396
621
  // next evaluation (store mutation, list notification, preset frame, or a
397
622
  // change to the cell itself) simply tries again.
398
623
  seatDiag = `staged ${staged.sessionId} is ACP mode`;
624
+ setScopeMarker(true);
625
+ probeHostActionLabels();
399
626
  claim ??= createModelSeatClaim({
400
627
  slots: ctx.slots,
401
628
  occupant: () => AcpSessionControls,
@@ -415,6 +642,21 @@ export function apply(ctx) {
415
642
  onDiag: (line) => { permissionSeatDiag = `staged ${staged.sessionId} · ${line}`; },
416
643
  });
417
644
  permissionClaim.sync();
645
+ // The dock's activity pill is a list CELL, not a whole slot: this claim
646
+ // shadows the shipped `activity` entry by id and renders it back with the
647
+ // plugin's burst speed. No host entry / inject / locale -> undefined and
648
+ // the native pill is untouched; the next evaluation retries.
649
+ activityClaim ??= createActivityPillClaim({
650
+ slots: ctx.slots,
651
+ inject: (sessionId) => chatFace(sessionId),
652
+ onDiag: noteActivityDiag,
653
+ });
654
+ activityClaim?.sync();
655
+ // The tab and the occupancy ring have the same lifetime as the claims:
656
+ // they exist only while this staged conversation is an ACP one, and a
657
+ // stand-down disposes them (a standard conversation must not see an NTR
658
+ // tab, and the host's native bottom row must stand alone there).
659
+ holdAuxEntries();
418
660
  // Confirmed ACP on a silent row: the verdict is all the evidence there
419
661
  // is, so it gets a re-ask deadline (see {@link ACP_MODE_VERDICT_TTL_MS}).
420
662
  if (rowAgentPreset(staged.row) === undefined)
@@ -472,6 +714,19 @@ export function apply(ctx) {
472
714
  const list = sessionsList();
473
715
  if (list !== undefined && stopWatch === undefined)
474
716
  stopWatch = list.subscribe(runGate);
717
+ // The selection can move without the list changing (a tab or sidebar
718
+ // click), and `uiSession.current` is the host's own answer to "which
719
+ // conversation is on screen". Re-run the gate on that transition so the
720
+ // ACP chrome leaves before the newly staged ordinary conversation paints.
721
+ const current = currentScopeSource();
722
+ if (current?.subscribe !== undefined && stopCurrent === undefined) {
723
+ try {
724
+ stopCurrent = current.subscribe(runGate);
725
+ }
726
+ catch {
727
+ stopCurrent = undefined;
728
+ }
729
+ }
475
730
  attachPresetFrames();
476
731
  runGate();
477
732
  };
@@ -524,7 +779,7 @@ export function apply(ctx) {
524
779
  // retakes the cell only when it is actually holding something that is not
525
780
  // ours — so this is evidence-driven, not a poll.
526
781
  const stopCell = ctx.on('slots/changed', (...args) => {
527
- if (args[0] !== MODEL_SEAT_KEY && args[0] !== PERMISSION_SEAT_KEY)
782
+ if (args[0] !== MODEL_SEAT_KEY && args[0] !== PERMISSION_SEAT_KEY && args[0] !== DOCK_SLOT)
528
783
  return;
529
784
  runGate();
530
785
  });
@@ -538,7 +793,12 @@ export function apply(ctx) {
538
793
  stopCell();
539
794
  stopPreset?.();
540
795
  stopWatch?.();
796
+ stopCurrent?.();
541
797
  disarmVerdictReask();
798
+ // The scope marker dies with the gate: a reload (HMR) must not leave the
799
+ // hiding CSS pointed at conversations nothing confirms anymore.
800
+ setScopeMarker(false);
801
+ releaseAuxEntries();
542
802
  if (claim !== undefined) {
543
803
  claim.release();
544
804
  claim = undefined;
@@ -547,6 +807,10 @@ export function apply(ctx) {
547
807
  permissionClaim.release();
548
808
  permissionClaim = undefined;
549
809
  }
810
+ if (activityClaim !== undefined) {
811
+ activityClaim.release();
812
+ activityClaim = undefined;
813
+ }
550
814
  store.noteSeatDiag('gate disposed');
551
815
  store.notePermissionSeatDiag('gate disposed');
552
816
  };
@@ -582,9 +846,9 @@ export function apply(ctx) {
582
846
  const sources = triggers.live?.sources;
583
847
  return Array.isArray(sources) ? sources : undefined;
584
848
  },
585
- verdict: (sessionId) => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, id => store.getSnapshot().acpMode[id]),
849
+ verdict: (sessionId) => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, id => store.getSnapshot().acpMode[id], currentScopeSessionId()),
586
850
  ensureVerdict: async (sessionId) => {
587
- const held = () => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, id => store.getSnapshot().acpMode[id]);
851
+ const held = () => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, id => store.getSnapshot().acpMode[id], currentScopeSessionId());
588
852
  const known = held();
589
853
  if (known !== undefined)
590
854
  return known;
@@ -674,11 +938,10 @@ export function apply(ctx) {
674
938
  // diffs). Session-scope list entry; the view ring renders it one tab at a
675
939
  // time. Meanwhile the agent's prose and thinking ride the NATIVE chat plane
676
940
  // through the LLM adapter — see docs/acp-convergence.md §2.1.
677
- ctx.effect(() => ctx.slots.inject('conversation.view', () => ctx.slots.register({
678
- name: 'conversation.view',
679
- id: 'acp',
680
- order: 20,
681
- label: () => 'NTR',
682
- inject: (sessionId) => chatFace(sessionId),
683
- }, AcpConversationView)), 'dsh-ntr: ACP view tab');
941
+ //
942
+ // It is installed by the ACP-mode gate above (`holdAuxEntries`) and NOT at
943
+ // plugin load: the host projects every registered `conversation.view` entry
944
+ // into EVERY session's tab strip, so an unconditional registration showed
945
+ // the NTR tab on ordinary conversations too. The gate's lifetime is the
946
+ // only per-session seam the host offers.
684
947
  }
@@ -123,7 +123,7 @@ export const MAX_SEAT_CLAIMS = 4;
123
123
  * `entriesOfSlot` and performing bounded retakes if displaced by later registrations.
124
124
  */
125
125
  export function createSeatClaim(options) {
126
- const { slots, slotKey, basePriority, occupant, inject } = options;
126
+ const { slots, slotKey, basePriority, occupant, inject, entryId } = options;
127
127
  const label = options.label ?? slotKey;
128
128
  const onDiag = options.onDiag;
129
129
  const maxClaims = options.maxClaims ?? MAX_SEAT_CLAIMS;
@@ -156,7 +156,10 @@ export function createSeatClaim(options) {
156
156
  return { declared: isDeclared, verified: false };
157
157
  try {
158
158
  const heads = slots.entriesOfSlot(slotKey) ?? [];
159
- return { declared: isDeclared, verified: true, ...heads[0] === undefined ? {} : { entry: heads[0] } };
159
+ // A list slot projects one winner per id, so the cell this claim owns is
160
+ // the head WITH that id — not heads[0], which may be a sibling cell.
161
+ const entry = entryId === undefined ? heads[0] : heads.find(head => head.options?.id === entryId);
162
+ return { declared: isDeclared, verified: true, ...entry === undefined ? {} : { entry } };
160
163
  }
161
164
  catch {
162
165
  return { declared: isDeclared, verified: false };
@@ -176,6 +179,9 @@ export function createSeatClaim(options) {
176
179
  try {
177
180
  installed.push(slots.inject(slotKey, () => slots.register({
178
181
  name: slotKey,
182
+ ...(entryId === undefined ? {} : { id: entryId }),
183
+ ...(options.order === undefined ? {} : { order: options.order }),
184
+ ...(options.locale === undefined ? {} : { locale: options.locale }),
179
185
  priority: rank,
180
186
  ...(inject ? { inject } : {}),
181
187
  }, component)));