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.
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +2436 -82
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/activity-pill.js +156 -0
- package/lib/client/branch-action.js +149 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +279 -16
- package/lib/client/model-seat.js +8 -2
- package/lib/client/panel.js +96 -29
- package/lib/client/released-banner.js +66 -0
- package/lib/client/session-controls.js +38 -13
- package/lib/client/slash-isolation.js +2 -2
- package/lib/client/store.js +380 -13
- package/lib/client.js +1364 -70
- package/lib/client.js.map +1 -1
- package/lib/impl.js +1229 -8
- package/lib/index.js +34 -0
- package/lib/protocol.js +19 -0
- package/lib/tps.js +32 -0
- package/lib/types/bridge.d.ts +850 -18
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/activity-pill.d.ts +81 -0
- package/lib/types/client/branch-action.d.ts +47 -0
- package/lib/types/client/composer-mode.d.ts +28 -0
- package/lib/types/client/context-meter.d.ts +51 -0
- package/lib/types/client/model-seat.d.ts +21 -0
- package/lib/types/client/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +108 -1
- package/lib/types/impl.d.ts +94 -7
- package/lib/types/protocol.d.ts +145 -0
- package/lib/types/tps.d.ts +17 -0
- package/package.json +2 -1
- package/src/bridge.ts +2754 -86
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/activity-pill.tsx +201 -0
- package/src/client/branch-action.tsx +178 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +298 -15
- package/src/client/model-seat.tsx +28 -3
- package/src/client/panel.tsx +157 -40
- package/src/client/primitives.d.ts +27 -0
- package/src/client/released-banner.tsx +97 -0
- package/src/client/session-controls.tsx +34 -11
- package/src/client/slash-isolation.ts +2 -1
- package/src/client/store.ts +412 -14
- package/src/impl.ts +1251 -13
- package/src/index.ts +33 -0
- package/src/protocol.ts +145 -0
- 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
|
+
}
|
package/lib/client/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
}
|
package/lib/client/model-seat.js
CHANGED
|
@@ -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
|
-
|
|
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)));
|