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
@@ -8,16 +8,58 @@
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 } from "./branch-action.js";
15
18
  import { createModelSeatClaim, createPermissionSeatClaim, MODEL_SEAT_KEY, PERMISSION_SEAT_KEY, } from "./model-seat.js";
16
- import { acpModeFromPresetEvent, rowAgentPreset, stagedIsAcpMode, stagedSessionOf, } from "./acp-ui.js";
19
+ import { acpModeFromPresetEvent, ACP_PRESET_ID, rowAgentPreset, stagedIsAcpMode, stagedSessionOf, } from "./acp-ui.js";
17
20
  import { createSlashIsolation, slashAcpVerdict } from "./slash-isolation.js";
18
21
  export const name = 'dsh-ntr-client';
19
22
  export const inject = ['slots', 'connection'];
20
23
  const browserPrefersZh = typeof navigator !== 'undefined' && /^zh\b/i.test(navigator.language);
24
+ /**
25
+ * The NTR scope marker (`html[data-ntr-acp]`): the ONLY hook the message-action
26
+ * hiding CSS keys on, so the host's like / dislike / branch icons disappear in
27
+ * exactly the conversations this gate has confirmed to run the ACP preset —
28
+ * and reappear the moment the evidence stops answering (every `standDown`
29
+ * clears it; non-ACP sessions never see it). Idempotent per call.
30
+ */
31
+ const setScopeMarker = (active) => {
32
+ if (typeof document === 'undefined')
33
+ return;
34
+ document.documentElement.toggleAttribute('data-ntr-acp', active);
35
+ };
36
+ /**
37
+ * One-shot sanity probe for the aria-label hiding: the host's action buttons
38
+ * carry LOCALIZED labels and no data-* hook, so a copy change (or a third
39
+ * language) silently un-hides them while everything else keeps working. Once
40
+ * the marker is on and a turn tail is actually on screen, check that at least
41
+ * one label the rules key on still exists and leave one console line for
42
+ * whoever debugs "the icons came back" — dev-visible, never user UI.
43
+ */
44
+ let probedHostLabels = false;
45
+ const probeHostActionLabels = () => {
46
+ if (probedHostLabels || typeof document === 'undefined')
47
+ return;
48
+ // No turn tail rendered yet: the check would answer from nothing, so leave
49
+ // the probe armed for the next activation instead of burning it once.
50
+ if (document.querySelector('[data-turn-tail]') === null)
51
+ return;
52
+ probedHostLabels = true;
53
+ const found = document.querySelector([
54
+ 'button:not([data-ntr-branch])[aria-label="在新对话中分支"]',
55
+ 'button:not([data-ntr-branch])[aria-label="Branch into a new conversation"]',
56
+ 'button[aria-label="好的回答"]',
57
+ 'button[aria-label="Good response"]',
58
+ ].join(', '));
59
+ if (found === null) {
60
+ console.warn('[dsh-ntr] none of the host message-action labels matched; the like/dislike/branch icons may not be hidden in NTR conversations');
61
+ }
62
+ };
21
63
  /**
22
64
  * How long a host verdict may stand as the gate's ONLY fact source before the
23
65
  * gate asks the host again.
@@ -97,11 +139,37 @@ export function apply(ctx) {
97
139
  return undefined;
98
140
  }
99
141
  };
142
+ /**
143
+ * The runtime's `sessions.fork`, resolved per call: `undefined` renders the
144
+ * branch button disabled (`hostUnavailable`) instead of hiding it, so a
145
+ * host that gains the primitive later is picked up on the next render.
146
+ */
147
+ const hostFork = () => {
148
+ try {
149
+ const fork = service('sessions')?.fork;
150
+ return typeof fork === 'function' ? { fork } : undefined;
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,60 @@ export function apply(ctx) {
249
375
  * available yet.
250
376
  */
251
377
  let permissionClaim;
378
+ /**
379
+ * ACP-only auxiliary entries: the NTR view tab and the composer's context
380
+ * ring. Unlike the two seats these are `list` slots with no visibility
381
+ * predicate of their own, so "self-hide in the component" cannot remove a
382
+ * tab from the header or free the native bottom row. They are installed by
383
+ * this gate for exactly the same lifetime as the claims and disposed on
384
+ * every stand-down — a standard conversation then has no NTR tab at all,
385
+ * and the host's native context meter sits alone in its dock row.
386
+ */
387
+ let viewEntry;
388
+ let meterEntry;
389
+ const releaseAuxEntries = () => {
390
+ try {
391
+ viewEntry?.();
392
+ }
393
+ catch { }
394
+ viewEntry = undefined;
395
+ try {
396
+ meterEntry?.();
397
+ }
398
+ catch { }
399
+ meterEntry = undefined;
400
+ };
401
+ const holdAuxEntries = () => {
402
+ // A refused registration leaves the native surface in place (fail-open);
403
+ // the next evaluation retries when the slot is declared.
404
+ if (viewEntry === undefined) {
405
+ try {
406
+ viewEntry = ctx.slots.inject('conversation.view', () => ctx.slots.register({
407
+ name: 'conversation.view',
408
+ id: 'acp',
409
+ order: 20,
410
+ label: () => label('NTR', 'NTR'),
411
+ inject: (sessionId) => chatFace(sessionId),
412
+ }, AcpConversationView));
413
+ }
414
+ catch {
415
+ viewEntry = undefined;
416
+ }
417
+ }
418
+ if (meterEntry === undefined) {
419
+ try {
420
+ meterEntry = ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
421
+ name: 'conversation.composer.dock',
422
+ id: 'acp-context-meter',
423
+ order: 10,
424
+ inject: (sessionId) => chatFace(sessionId),
425
+ }, AcpContextMeter));
426
+ }
427
+ catch {
428
+ meterEntry = undefined;
429
+ }
430
+ }
431
+ };
252
432
  /**
253
433
  * Last diagnostic line from {@link syncAcpMode}. Every way this gate can
254
434
  * end with the native seat still on screen used to be silent — an inner
@@ -261,7 +441,8 @@ export function apply(ctx) {
261
441
  let seatDiag = 'idle';
262
442
  let permissionSeatDiag = 'idle';
263
443
  /**
264
- * Hand BOTH cells back to their shipped occupants.
444
+ * Hand every ACP-only surface back: both composer cells, the NTR view tab
445
+ * and the context ring.
265
446
  *
266
447
  * Every path that is not "this staged conversation is CONFIRMED to run the
267
448
  * ACP preset" ends here — including the paths that carry no verdict at all
@@ -283,6 +464,8 @@ export function apply(ctx) {
283
464
  // read identically in the one place either is observable.
284
465
  permissionSeatDiag = `${reason} (released)`;
285
466
  disarmVerdictReask();
467
+ setScopeMarker(false);
468
+ releaseAuxEntries();
286
469
  if (claim !== undefined) {
287
470
  claim.release();
288
471
  claim = undefined;
@@ -324,7 +507,13 @@ export function apply(ctx) {
324
507
  standDown('no session list service');
325
508
  return;
326
509
  }
327
- const staged = stagedSessionOf(snapshot);
510
+ // Prefer the renderer's own current selection; the list's retained rows
511
+ // are the fallback for hosts without the scope adapter. A multi-view
512
+ // layout can leave several rows retained, and judging the first one (or
513
+ // the one the previous switch left behind) would hold the ACP chrome
514
+ // while an ordinary conversation is on screen — the reported drift.
515
+ const current = currentScopeSessionId();
516
+ const staged = stagedSessionOf(snapshot, current);
328
517
  if (staged === undefined) {
329
518
  standDown('no staged session (nothing retains the main view)');
330
519
  return;
@@ -381,14 +570,24 @@ export function apply(ctx) {
381
570
  // nothing. Both cells follow the same evidence — the staged
382
571
  // conversation's preset — through one {@link standDown}, so neither can
383
572
  // stay claimed after the evidence for it stops answering.
384
- if (!stagedIsAcpMode(snapshot, acpModeVerdict)) {
573
+ if (!stagedIsAcpMode(snapshot, acpModeVerdict, current)) {
574
+ // The row's own verdict is authoritative even when it says no: fold it
575
+ // into the store so every per-conversation surface (the branch
576
+ // button's ACP gate included) stops rendering without waiting for a
577
+ // host read. A silent row folds nothing — that case is the ask path's.
578
+ if (rowAgentPreset(staged.row) !== undefined)
579
+ store.noteAcpMode(staged.sessionId, false);
385
580
  standDown(`staged ${staged.sessionId} is not in ACP mode`);
386
581
  return;
387
582
  }
388
583
  // 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)
584
+ // source and no re-ask timer is needed. The same fold in the affirmative
585
+ // direction: the runtime row IS the preset truth, and the branch
586
+ // button's gate reads the folded map, not the snapshot.
587
+ if (rowAgentPreset(staged.row) !== undefined) {
588
+ store.noteAcpMode(staged.sessionId, rowAgentPreset(staged.row) === ACP_PRESET_ID);
391
589
  disarmVerdictReask();
590
+ }
392
591
  // Stays re-runnable on purpose: the seat is not declared before the
393
592
  // composer bar mounts, a registration can be refused, and the cell can be
394
593
  // taken from us later. The claim reports each of those through the
@@ -396,6 +595,8 @@ export function apply(ctx) {
396
595
  // next evaluation (store mutation, list notification, preset frame, or a
397
596
  // change to the cell itself) simply tries again.
398
597
  seatDiag = `staged ${staged.sessionId} is ACP mode`;
598
+ setScopeMarker(true);
599
+ probeHostActionLabels();
399
600
  claim ??= createModelSeatClaim({
400
601
  slots: ctx.slots,
401
602
  occupant: () => AcpSessionControls,
@@ -415,6 +616,11 @@ export function apply(ctx) {
415
616
  onDiag: (line) => { permissionSeatDiag = `staged ${staged.sessionId} · ${line}`; },
416
617
  });
417
618
  permissionClaim.sync();
619
+ // The tab and the occupancy ring have the same lifetime as the claims:
620
+ // they exist only while this staged conversation is an ACP one, and a
621
+ // stand-down disposes them (a standard conversation must not see an NTR
622
+ // tab, and the host's native bottom row must stand alone there).
623
+ holdAuxEntries();
418
624
  // Confirmed ACP on a silent row: the verdict is all the evidence there
419
625
  // is, so it gets a re-ask deadline (see {@link ACP_MODE_VERDICT_TTL_MS}).
420
626
  if (rowAgentPreset(staged.row) === undefined)
@@ -472,6 +678,19 @@ export function apply(ctx) {
472
678
  const list = sessionsList();
473
679
  if (list !== undefined && stopWatch === undefined)
474
680
  stopWatch = list.subscribe(runGate);
681
+ // The selection can move without the list changing (a tab or sidebar
682
+ // click), and `uiSession.current` is the host's own answer to "which
683
+ // conversation is on screen". Re-run the gate on that transition so the
684
+ // ACP chrome leaves before the newly staged ordinary conversation paints.
685
+ const current = currentScopeSource();
686
+ if (current?.subscribe !== undefined && stopCurrent === undefined) {
687
+ try {
688
+ stopCurrent = current.subscribe(runGate);
689
+ }
690
+ catch {
691
+ stopCurrent = undefined;
692
+ }
693
+ }
475
694
  attachPresetFrames();
476
695
  runGate();
477
696
  };
@@ -538,7 +757,12 @@ export function apply(ctx) {
538
757
  stopCell();
539
758
  stopPreset?.();
540
759
  stopWatch?.();
760
+ stopCurrent?.();
541
761
  disarmVerdictReask();
762
+ // The scope marker dies with the gate: a reload (HMR) must not leave the
763
+ // hiding CSS pointed at conversations nothing confirms anymore.
764
+ setScopeMarker(false);
765
+ releaseAuxEntries();
542
766
  if (claim !== undefined) {
543
767
  claim.release();
544
768
  claim = undefined;
@@ -582,9 +806,9 @@ export function apply(ctx) {
582
806
  const sources = triggers.live?.sources;
583
807
  return Array.isArray(sources) ? sources : undefined;
584
808
  },
585
- verdict: (sessionId) => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, id => store.getSnapshot().acpMode[id]),
809
+ verdict: (sessionId) => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, id => store.getSnapshot().acpMode[id], currentScopeSessionId()),
586
810
  ensureVerdict: async (sessionId) => {
587
- const held = () => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, id => store.getSnapshot().acpMode[id]);
811
+ const held = () => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, id => store.getSnapshot().acpMode[id], currentScopeSessionId());
588
812
  const known = held();
589
813
  if (known !== undefined)
590
814
  return known;
@@ -674,11 +898,10 @@ export function apply(ctx) {
674
898
  // diffs). Session-scope list entry; the view ring renders it one tab at a
675
899
  // time. Meanwhile the agent's prose and thinking ride the NATIVE chat plane
676
900
  // 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');
901
+ //
902
+ // It is installed by the ACP-mode gate above (`holdAuxEntries`) and NOT at
903
+ // plugin load: the host projects every registered `conversation.view` entry
904
+ // into EVERY session's tab strip, so an unconditional registration showed
905
+ // the NTR tab on ordinary conversations too. The gate's lifetime is the
906
+ // only per-session seam the host offers.
684
907
  }