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
package/lib/client.js CHANGED
@@ -4,14 +4,61 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region \0rolldown/runtime.js
8
+ var __create = Object.create;
9
+ var __defProp = Object.defineProperty;
10
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
+ var __getOwnPropNames = Object.getOwnPropertyNames;
12
+ var __getProtoOf = Object.getPrototypeOf;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __copyProps = (to, from, except, desc) => {
15
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
16
+ key = keys[i];
17
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
18
+ get: ((k) => from[k]).bind(null, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ return to;
23
+ };
24
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
25
+ value: mod,
26
+ enumerable: true
27
+ }) : target, mod));
28
+ //#endregion
7
29
  let react = require("react");
8
30
  let react_jsx_runtime = require("react/jsx-runtime");
31
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
32
+ _deepseek_ai_dsh_client_ui_primitives = __toESM(_deepseek_ai_dsh_client_ui_primitives, 1);
9
33
  //#region src/protocol.ts
10
34
  /** Default when nothing was ever saved: the breathing halo at normal speed. */
11
35
  const DEFAULT_TAG_HALO = {
12
36
  mode: "breath",
13
37
  speed: "normal"
14
38
  };
39
+ /**
40
+ * The idle-release windows the settings page offers, in MILLISECONDS.
41
+ *
42
+ * ONE ladder, shared by both halves: the browser snaps its slider to these
43
+ * values and the host accepts only these values, so a window the slider can
44
+ * produce is always a window the host runs. The first rung is 1 minute (an
45
+ * aggressive release), the rest are 5-minute steps up to an hour.
46
+ */
47
+ const IDLE_RELEASE_MS = [
48
+ 6e4,
49
+ 3e5,
50
+ 6e5,
51
+ 9e5,
52
+ 12e5,
53
+ 15e5,
54
+ 18e5,
55
+ 21e5,
56
+ 24e5,
57
+ 27e5,
58
+ 3e6,
59
+ 33e5,
60
+ 36e5
61
+ ];
15
62
  //#endregion
16
63
  //#region src/client/store.ts
17
64
  /** Returned by {@link deadlineRemainingLabel} once the deadline has passed. */
@@ -26,6 +73,29 @@ window.__ModuleLoader__.load({
26
73
  return permissions.filter((permission) => permission.dshSessionId === dshSessionId);
27
74
  }
28
75
  /**
76
+ * The branch button's state for one conversation, in the precedence it renders:
77
+ * the host service missing beats everything (nothing can work), then the
78
+ * agent's fork capability, then the session's own state (a parked session
79
+ * before a running one), then the DOM's tail verdict, and only then ready.
80
+ *
81
+ * Every input is a fact someone else owns — the row's `fork` verdict is the
82
+ * host's, `hostFork` is the structural probe of the runtime's `sessions.fork`,
83
+ * `isTail` is read from the host's own `[data-turn-tail]` marker, and
84
+ * `liveRunning` is the SSE-driven running flag (the row's own `running` only
85
+ * refreshes on a chat-list read, so it would answer last turn). `undefined`
86
+ * facts degrade CLOSED: no row and no `fork` verdict both read as not-ready,
87
+ * never as a click-through.
88
+ */
89
+ function forkStateOf(row, hostFork, isTail, liveRunning) {
90
+ if (!hostFork) return "hostUnavailable";
91
+ if (row === void 0) return "released";
92
+ if (row.fork === void 0) return "unsupported";
93
+ if (row.fork.supported !== true) return row.fork.reason ?? "unsupported";
94
+ if (liveRunning === true) return "busy";
95
+ if (!isTail) return "notTail";
96
+ return "ready";
97
+ }
98
+ /**
29
99
  * Compact countdown for one ask, or `undefined` when there is nothing to count
30
100
  * down (`wait` mode, or a frame without a deadline). Pure so the formatting is
31
101
  * testable without a clock: the dock passes `Date.now()` on its tick.
@@ -333,6 +403,7 @@ window.__ModuleLoader__.load({
333
403
  chatSessions: [],
334
404
  tagHalo: { ...DEFAULT_TAG_HALO },
335
405
  approvalDeadlines: void 0,
406
+ idleRelease: void 0,
336
407
  chatTranscripts: {},
337
408
  acpMode: {},
338
409
  chatBoundAgent: {},
@@ -353,6 +424,13 @@ window.__ModuleLoader__.load({
353
424
  /** In-flight `session/chat-list` reads, keyed by chat session id. */
354
425
  chatSessionReads = /* @__PURE__ */ new Map();
355
426
  /**
427
+ * ACP session ids whose chat-list row was requested mid-turn (one read each).
428
+ * A conversation's first ACP session does not exist when the composer's
429
+ * mount/running read fires, so its first `usage_update` would find no row to
430
+ * patch; `turn/start` asks again once the host has materialized the session.
431
+ */
432
+ chatSessionRowReads = /* @__PURE__ */ new Set();
433
+ /**
356
434
  * Chat sessions whose list has answered at least once in this page. The
357
435
  * FIRST answer only seeds the lock; a lock appearing on a later answer is a
358
436
  * transition this page actually witnessed, and only that one is announced via
@@ -471,17 +549,25 @@ window.__ModuleLoader__.load({
471
549
  else list.push(item);
472
550
  }
473
551
  let chatTranscripts = this.state.chatTranscripts;
552
+ let chatSessions = this.state.chatSessions;
474
553
  let blocksChanged = false;
475
554
  for (const [sessionId, items] of bySession) {
476
555
  const last = items[items.length - 1];
556
+ const usage = usageOfBatch(items);
557
+ if (usage !== void 0) chatSessions = withSessionUsage(chatSessions, sessionId, usage);
477
558
  const record = chatTranscripts[sessionId];
478
559
  if (record !== void 0) {
479
560
  let changed = false;
480
561
  for (const item of items) if (foldUpdate(record.blocks, item.update)) changed = true;
562
+ const session = usage !== void 0 && !sameUsage(record.session.usage, usage) ? {
563
+ ...record.session,
564
+ usage
565
+ } : record.session;
481
566
  chatTranscripts = {
482
567
  ...chatTranscripts,
483
568
  [sessionId]: {
484
569
  ...record,
570
+ session,
485
571
  seq: last.seq,
486
572
  ...changed ? { blocks: [...record.blocks] } : {}
487
573
  }
@@ -502,6 +588,7 @@ window.__ModuleLoader__.load({
502
588
  }
503
589
  const patch = {};
504
590
  if (chatTranscripts !== this.state.chatTranscripts) patch.chatTranscripts = chatTranscripts;
591
+ if (chatSessions !== this.state.chatSessions) patch.chatSessions = chatSessions;
505
592
  if (blocksChanged) patch.blocks = [...this.state.blocks];
506
593
  if (Object.keys(patch).length > 0) this.mutate(patch);
507
594
  }
@@ -550,7 +637,7 @@ window.__ModuleLoader__.load({
550
637
  /** Snapshot refresh used on panel open and after a stream outage. */
551
638
  async resync() {
552
639
  try {
553
- const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
640
+ const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, idleReleaseResult, versionResult] = await Promise.all([
554
641
  this.call("agents/list"),
555
642
  this.call("sessions/list"),
556
643
  this.call("default/agent").catch(() => ({})),
@@ -558,6 +645,7 @@ window.__ModuleLoader__.load({
558
645
  this.call("preset/status").catch(() => null),
559
646
  this.call("ui/tag-halo").catch(() => ({ halo: { ...DEFAULT_TAG_HALO } })),
560
647
  this.call("ui/approval-deadlines").catch(() => ({ deadlines: void 0 })),
648
+ this.call("ui/idle-release").catch(() => ({ idleRelease: void 0 })),
561
649
  this.call("plugin/version").catch(() => null)
562
650
  ]);
563
651
  const detectedAgents = detectResult && detectResult.agents.length > 0 ? detectResult.agents : this.state.detectedAgents.length > 0 ? this.state.detectedAgents : [...DEFAULT_KNOWN_PRESETS];
@@ -570,8 +658,10 @@ window.__ModuleLoader__.load({
570
658
  preset: presetResult?.preset ?? void 0,
571
659
  tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
572
660
  approvalDeadlines: deadlinesResult.deadlines,
661
+ idleRelease: idleReleaseResult.idleRelease,
573
662
  pluginVersion: versionResult?.version !== void 0 && versionResult.version !== "" ? versionResult.version : void 0
574
663
  });
664
+ if (this.state.chatSessionsFor !== void 0) this.loadChatSessions(this.state.chatSessionsFor);
575
665
  const active = this.state.activeSessionId ?? sessions[0]?.sessionId;
576
666
  if (active !== void 0) await this.loadTranscript(active);
577
667
  } catch (error) {
@@ -995,7 +1085,7 @@ window.__ModuleLoader__.load({
995
1085
  async readChatSessionList(dshSessionId, options, read) {
996
1086
  const settle = options?.settleVerdict === true || !this.acpModeLive.has(dshSessionId);
997
1087
  try {
998
- const { sessions, acpMode, boundAgentId, lockedAgentId, choice } = await this.call("session/chat-list", {
1088
+ const { sessions = [], acpMode, boundAgentId, lockedAgentId, choice } = await this.call("session/chat-list", {
999
1089
  dshSessionId,
1000
1090
  refresh: true
1001
1091
  });
@@ -1103,6 +1193,13 @@ window.__ModuleLoader__.load({
1103
1193
  * being the one authorized exception. Same no-op-when-unchanged discipline as
1104
1194
  * the other accessors, so a frame restating the held verdict costs no
1105
1195
  * listener churn.
1196
+ *
1197
+ * The seat gate (`syncAcpMode`) is the SECOND caller: it folds the runtime
1198
+ * row's own `agentPreset` — the same session-log source the frames announce —
1199
+ * through here, in both directions. That hands the row the frame's authority
1200
+ * on purpose (one provenance, one rank): a stale projection is corrected by
1201
+ * the gate's next evaluation with fresher row data, and only HOST READS are
1202
+ * barred from regressing what the runtime itself already said.
1106
1203
  */
1107
1204
  noteAcpMode(dshSessionId, acpMode) {
1108
1205
  this.acpModeLive.add(dshSessionId);
@@ -1186,12 +1283,99 @@ window.__ModuleLoader__.load({
1186
1283
  modeId,
1187
1284
  ...agentId === void 0 ? {} : { agentId }
1188
1285
  });
1286
+ const safeAgent = agentId && agentId.trim() || void 0;
1287
+ this.writeChoice(dshSessionId, (choice) => ({
1288
+ ...choice,
1289
+ mode: modeId
1290
+ }), safeAgent);
1291
+ if (this.state.chatSessionsFor === dshSessionId) {
1292
+ const owner = safeAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
1293
+ let changed = false;
1294
+ const chatSessions = this.state.chatSessions.map((s) => {
1295
+ if (s.agentId !== owner || !s.modes || s.modes.currentModeId === modeId) return s;
1296
+ changed = true;
1297
+ return {
1298
+ ...s,
1299
+ modes: {
1300
+ ...s.modes,
1301
+ currentModeId: modeId
1302
+ }
1303
+ };
1304
+ });
1305
+ if (changed) this.mutate({ chatSessions });
1306
+ }
1189
1307
  } catch (error) {
1190
1308
  this.flashError(error);
1191
1309
  }
1192
1310
  await this.loadChatSessions(dshSessionId);
1193
1311
  }
1194
1312
  /**
1313
+ * Bring the conversation's released ACP session back on a fresh process —
1314
+ * the released banner's explicit form of the "next message resumes it"
1315
+ * promise (`session/chat-resume`). A failure is reported through the notice
1316
+ * AND rethrown (the `respondPermission` contract), so the banner can pin
1317
+ * the message beside the button the user just pressed; the binding is
1318
+ * re-read either way so the row follows the host's real answer.
1319
+ */
1320
+ async resumeChatSession(dshSessionId) {
1321
+ let failure;
1322
+ try {
1323
+ await this.call("session/chat-resume", { dshSessionId });
1324
+ } catch (error) {
1325
+ this.flashError(error);
1326
+ failure = error;
1327
+ }
1328
+ await this.loadChatSessions(dshSessionId);
1329
+ if (failure !== void 0) throw failure;
1330
+ }
1331
+ /**
1332
+ * Branch one conversation (the NTR branch button's click): the fixed
1333
+ * two-stage order — the HOST forks the DSH session tree first (copied
1334
+ * history, `(1)` title, workspace), then the plugin adopts the ACP side via
1335
+ * `session/chat-fork-adopt`, which runs the agent's own `session/fork` so
1336
+ * the child actually remembers the parent context. Host first is structural:
1337
+ * ACP fork has no cut-point parameter and nothing to return to if the host
1338
+ * half failed second (see the plan's §D7).
1339
+ *
1340
+ * Resolves `{ childId }` on success, or `{ failure }` with one actionable
1341
+ * line — the reason is ALSO flashed into the notice banner (a user-action
1342
+ * failure, so the banner is its surface), and the local line is what the
1343
+ * conversation renders beside the button (the user is not on the settings
1344
+ * page). A failed adopt leaves the child an ordinary copied-history
1345
+ * conversation (its first message starts a fresh `session/new` — today's
1346
+ * behavior), never a half-bound one, and NOTHING is opened.
1347
+ *
1348
+ * `forkHost` is the runtime's `sessions.fork` face, resolved structurally by
1349
+ * the entry and injected here: the store stays transport-free.
1350
+ */
1351
+ async forkChatSession(dshSessionId, forkHost) {
1352
+ let childId;
1353
+ try {
1354
+ childId = await forkHost(dshSessionId);
1355
+ } catch (error) {
1356
+ const failure = error instanceof Error ? error.message : String(error);
1357
+ this.flashError(failure);
1358
+ return { failure };
1359
+ }
1360
+ if (typeof childId !== "string" || childId === "") {
1361
+ const failure = this.locale("宿主没有返回分支会话,分支未完成。", "The host returned no branched session; the branch did not complete.");
1362
+ this.flashError(new Error(failure));
1363
+ return { failure };
1364
+ }
1365
+ try {
1366
+ await this.call("session/chat-fork-adopt", {
1367
+ dshSessionId: childId,
1368
+ parentDshSessionId: dshSessionId
1369
+ });
1370
+ } catch (error) {
1371
+ const reason = error instanceof Error ? error.message : String(error);
1372
+ const failure = this.locale(`分支会话已创建,但没有接上 agent 的上下文(${reason});在子会话里发送的第一条消息会开启全新会话。`, `The branched session was created but the agent context was not attached (${reason}); its first message will start a fresh session.`);
1373
+ this.flashError(new Error(failure));
1374
+ return { failure };
1375
+ }
1376
+ return { childId };
1377
+ }
1378
+ /**
1195
1379
  * Start (or refresh) observation of every ACP transcript behind one DSH chat
1196
1380
  * session. Blocks are rebuilt from scratch — the host buffer is the record,
1197
1381
  * and this view is the only consumer that needs the raw agent stream.
@@ -1358,6 +1542,33 @@ window.__ModuleLoader__.load({
1358
1542
  async resetApprovalDeadlines() {
1359
1543
  await this.writeApprovalDeadlines({ reset: true });
1360
1544
  }
1545
+ /**
1546
+ * Write the idle-release window (in ms).
1547
+ *
1548
+ * The host answer — the same route returns the window now in force — is
1549
+ * the authority, so an optimistic write that the host trims self-corrects
1550
+ * instead of drifting from what runs.
1551
+ */
1552
+ async setIdleRelease(idleTimeoutMs) {
1553
+ await this.writeIdleRelease({ idleTimeoutMs });
1554
+ }
1555
+ /**
1556
+ * Drop the persisted override: the config file (or the built-in default)
1557
+ * decides again.
1558
+ */
1559
+ async resetIdleRelease() {
1560
+ await this.writeIdleRelease({ reset: true });
1561
+ }
1562
+ async writeIdleRelease(payload) {
1563
+ const previous = this.state.idleRelease;
1564
+ try {
1565
+ const result = await this.call("ui/idle-release-set", payload);
1566
+ this.mutate({ idleRelease: result.idleRelease });
1567
+ } catch (error) {
1568
+ this.mutate({ idleRelease: previous });
1569
+ this.flashError(error);
1570
+ }
1571
+ }
1361
1572
  async writeApprovalDeadlines(payload) {
1362
1573
  const previous = this.state.approvalDeadlines;
1363
1574
  if (previous !== void 0) this.mutate({ approvalDeadlines: {
@@ -1404,10 +1615,24 @@ window.__ModuleLoader__.load({
1404
1615
  dshSessionId,
1405
1616
  modelId
1406
1617
  });
1618
+ const targetAgent = (agentId && agentId.trim() || void 0) ?? (modelId ? splitModelAgentId(modelId) : void 0);
1407
1619
  this.writeChoice(dshSessionId, (choice) => ({
1408
1620
  ...choice,
1409
1621
  modelId
1410
- }), agentId);
1622
+ }), targetAgent);
1623
+ if (this.state.chatSessionsFor === dshSessionId) {
1624
+ const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
1625
+ let changed = false;
1626
+ const chatSessions = this.state.chatSessions.map((s) => {
1627
+ if (s.agentId !== owner || s.modelId === modelId) return s;
1628
+ changed = true;
1629
+ return {
1630
+ ...s,
1631
+ modelId
1632
+ };
1633
+ });
1634
+ if (changed) this.mutate({ chatSessions });
1635
+ }
1411
1636
  } catch (error) {
1412
1637
  this.flashError(error);
1413
1638
  throw error;
@@ -1421,13 +1646,27 @@ window.__ModuleLoader__.load({
1421
1646
  ...effort === void 0 ? {} : { effort },
1422
1647
  ...agentId === void 0 ? {} : { agentId }
1423
1648
  });
1649
+ const targetAgent = agentId && agentId.trim() || void 0;
1424
1650
  this.writeChoice(dshSessionId, (choice) => {
1425
1651
  const { effort: _dropped, ...rest } = choice;
1426
1652
  return effort === void 0 ? rest : {
1427
1653
  ...rest,
1428
1654
  effort
1429
1655
  };
1430
- }, agentId);
1656
+ }, targetAgent);
1657
+ if (this.state.chatSessionsFor === dshSessionId) {
1658
+ const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
1659
+ let changed = false;
1660
+ const chatSessions = this.state.chatSessions.map((s) => {
1661
+ if (s.agentId !== owner || s.reasoningEffort === effort) return s;
1662
+ changed = true;
1663
+ return {
1664
+ ...s,
1665
+ reasoningEffort: effort
1666
+ };
1667
+ });
1668
+ if (changed) this.mutate({ chatSessions });
1669
+ }
1431
1670
  } catch (error) {
1432
1671
  this.flashError(error);
1433
1672
  throw error;
@@ -1536,8 +1775,11 @@ window.__ModuleLoader__.load({
1536
1775
  }
1537
1776
  case "session/meta": {
1538
1777
  const sessions = this.state.sessions.some((s) => s.sessionId === event.sessionId) ? this.state.sessions.map((s) => s.sessionId === event.sessionId ? event.session : s) : this.state.sessions;
1778
+ let chatSessions = withSessionUsage(this.state.chatSessions, event.sessionId, event.session.usage);
1779
+ chatSessions = withSessionModes(chatSessions, event.sessionId, event.session.modes);
1539
1780
  this.mutate({
1540
1781
  sessions,
1782
+ ...chatSessions === this.state.chatSessions ? {} : { chatSessions },
1541
1783
  ...this.watchedSessionPatch(event.sessionId, event.session)
1542
1784
  });
1543
1785
  return Promise.resolve();
@@ -1563,10 +1805,7 @@ window.__ModuleLoader__.load({
1563
1805
  case "session/released": {
1564
1806
  const sessions = this.state.sessions.some((s) => s.sessionId === event.sessionId) ? this.state.sessions.map((s) => s.sessionId === event.sessionId ? event.session : s) : [...this.state.sessions, event.session];
1565
1807
  this.openTurns.delete(event.sessionId);
1566
- const chatSessions = this.state.chatSessions.some((s) => s.sessionId === event.sessionId) ? this.state.chatSessions.map((s) => s.sessionId === event.sessionId ? {
1567
- ...s,
1568
- released: true
1569
- } : s) : this.state.chatSessions;
1808
+ const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, true);
1570
1809
  const cause = event.cause ?? "died";
1571
1810
  let log = {};
1572
1811
  if (cause === "workspace") log = this.logPatch("info", this.locale("agent 为切换工作区重启,会话已挂起;再次发送消息将自动恢复。", "agent restarted for its workspace; the session is paused and will resume on your next message."), event.session.agentId);
@@ -1588,8 +1827,10 @@ window.__ModuleLoader__.load({
1588
1827
  const isActive = event.sessionId === this.state.activeSessionId;
1589
1828
  const sessions = this.state.sessions.some((s) => s.sessionId === event.sessionId) ? this.state.sessions.map((s) => s.sessionId === event.sessionId ? event.session : s) : [...this.state.sessions, event.session];
1590
1829
  const watched = this.state.chatTranscripts[event.sessionId];
1830
+ const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, false);
1591
1831
  this.mutate({
1592
1832
  sessions,
1833
+ ...chatSessions === this.state.chatSessions ? {} : { chatSessions },
1593
1834
  ...isActive ? { blocks: [] } : {},
1594
1835
  ...watched === void 0 ? {} : { chatTranscripts: {
1595
1836
  ...this.state.chatTranscripts,
@@ -1677,9 +1918,14 @@ window.__ModuleLoader__.load({
1677
1918
  });
1678
1919
  return Promise.resolve();
1679
1920
  }
1680
- case "turn/start":
1921
+ case "turn/start": {
1681
1922
  this.openTurns.add(event.sessionId);
1682
1923
  this.markWatchedRunning(event.sessionId, true);
1924
+ const chatSessionId = this.state.chatSessionsFor;
1925
+ if (chatSessionId !== void 0 && !this.state.chatSessions.some((row) => row.sessionId === event.sessionId) && !this.chatSessionRowReads.has(event.sessionId)) {
1926
+ this.chatSessionRowReads.add(event.sessionId);
1927
+ this.loadChatSessions(chatSessionId);
1928
+ }
1683
1929
  if (event.sessionId === this.state.activeSessionId) {
1684
1930
  this.state.blocks.push({
1685
1931
  kind: "user",
@@ -1692,6 +1938,7 @@ window.__ModuleLoader__.load({
1692
1938
  } : s) });
1693
1939
  }
1694
1940
  return Promise.resolve();
1941
+ }
1695
1942
  case "turn/end":
1696
1943
  if (!this.openTurns.has(event.sessionId)) return Promise.resolve();
1697
1944
  this.openTurns.delete(event.sessionId);
@@ -1873,6 +2120,113 @@ window.__ModuleLoader__.load({
1873
2120
  "usage_update",
1874
2121
  "session_info_update"
1875
2122
  ]);
2123
+ /**
2124
+ * Context occupancy carried by one raw `usage_update` frame, in the shape the
2125
+ * session snapshots already use. A malformed frame is ignored (the last good
2126
+ * sample stays on screen) instead of zeroing the meter.
2127
+ */
2128
+ function usageFromUpdate(update) {
2129
+ const used = update.used;
2130
+ const size = update.size;
2131
+ if (typeof used !== "number" || !Number.isFinite(used)) return void 0;
2132
+ if (typeof size !== "number" || !Number.isFinite(size)) return void 0;
2133
+ const cost = update.cost;
2134
+ if (cost !== null && typeof cost === "object" && !Array.isArray(cost)) {
2135
+ const row = cost;
2136
+ if (typeof row.amount === "number" && Number.isFinite(row.amount) && typeof row.currency === "string" && row.currency !== "") return {
2137
+ used,
2138
+ size,
2139
+ cost: {
2140
+ amount: row.amount,
2141
+ currency: row.currency
2142
+ }
2143
+ };
2144
+ }
2145
+ return {
2146
+ used,
2147
+ size
2148
+ };
2149
+ }
2150
+ function sameUsage(left, right) {
2151
+ if (left === right) return true;
2152
+ if (left === void 0 || right === void 0) return false;
2153
+ if (left.used !== right.used || left.size !== right.size) return false;
2154
+ const leftCost = left.cost;
2155
+ const rightCost = right.cost;
2156
+ if (leftCost === void 0 || rightCost === void 0) return leftCost === rightCost;
2157
+ return leftCost.amount === rightCost.amount && leftCost.currency === rightCost.currency;
2158
+ }
2159
+ /** Patch one session row's usage; other rows and an unchanged value keep their identities. */
2160
+ function withSessionUsage(rows, sessionId, usage) {
2161
+ let changed = false;
2162
+ const next = rows.map((row) => {
2163
+ if (row.sessionId !== sessionId || sameUsage(row.usage, usage)) return row;
2164
+ changed = true;
2165
+ return {
2166
+ ...row,
2167
+ usage
2168
+ };
2169
+ });
2170
+ return changed ? next : rows;
2171
+ }
2172
+ /**
2173
+ * Flip one chat row's `released` flag (release/resume bookkeeping). The
2174
+ * composer's own list (`session/chat-list` rows) has no other live writer —
2175
+ * `session/released` must mark it, `session/reset` must un-mark it, or the
2176
+ * agent tag's halo and the released banner read yesterday's process state.
2177
+ */
2178
+ function withSessionReleased(rows, sessionId, released) {
2179
+ let changed = false;
2180
+ const next = rows.map((row) => {
2181
+ if (row.sessionId !== sessionId || row.released === true === released) return row;
2182
+ changed = true;
2183
+ return {
2184
+ ...row,
2185
+ released
2186
+ };
2187
+ });
2188
+ return changed ? next : rows;
2189
+ }
2190
+ function sameModes(a, b) {
2191
+ if (a === b) return true;
2192
+ if (!a || !b) return false;
2193
+ if (a.currentModeId !== b.currentModeId) return false;
2194
+ const aModes = a.availableModes ?? [];
2195
+ const bModes = b.availableModes ?? [];
2196
+ if (aModes.length !== bModes.length) return false;
2197
+ return aModes.every((m, i) => {
2198
+ const o = bModes[i];
2199
+ return m != null && o != null && m.id === o.id && m.name === o.name && m.description === o.description;
2200
+ });
2201
+ }
2202
+ function withSessionModes(rows, sessionId, modes) {
2203
+ if (!modes) return rows;
2204
+ let changed = false;
2205
+ const next = rows.map((row) => {
2206
+ if (row.sessionId !== sessionId || sameModes(row.modes, modes)) return row;
2207
+ changed = true;
2208
+ return {
2209
+ ...row,
2210
+ modes
2211
+ };
2212
+ });
2213
+ return changed ? next : rows;
2214
+ }
2215
+ function splitModelAgentId(modelId) {
2216
+ const at = modelId.indexOf("@");
2217
+ return (at === -1 ? modelId.trim() : at === 0 ? "" : modelId.slice(0, at).trim()) || void 0;
2218
+ }
2219
+ /** Last well-formed `usage_update` in one coalesced batch (later ticks win). */
2220
+ function usageOfBatch(items) {
2221
+ let usage;
2222
+ for (const item of items) {
2223
+ const update = item.update;
2224
+ if (update.sessionUpdate !== "usage_update") continue;
2225
+ const parsed = usageFromUpdate(update);
2226
+ if (parsed !== void 0) usage = parsed;
2227
+ }
2228
+ return usage;
2229
+ }
1876
2230
  function trimBlocks(blocks) {
1877
2231
  if (blocks.length > MAX_BLOCKS) blocks.splice(0, blocks.length - MAX_BLOCKS);
1878
2232
  }
@@ -1992,6 +2346,13 @@ window.__ModuleLoader__.load({
1992
2346
  /**
1993
2347
  * Which session the composer is currently staged on.
1994
2348
  *
2349
+ * The renderer's own current scope binding is the first answer when the caller
2350
+ * passes it (`currentSessionId`, read from `uiSession.adapter.current` in the
2351
+ * browser entry): that is the exact session the host has selected, while the
2352
+ * list can retain more than one row in a multi-view layout. Falling back to
2353
+ * the list is still required — a host without that scope adapter, and the old
2354
+ * snapshot shape below, have no better source.
2355
+ *
1995
2356
  * Old runtimes answer from `snapshot.current` (with `currentAddress` as the
1996
2357
  * subagent marker). rc.2 has no `current`: the staged session is the row the
1997
2358
  * main view retains (`retainedBy.mainView > 0`) — the exact rule
@@ -1999,8 +2360,13 @@ window.__ModuleLoader__.load({
1999
2360
  * row with `origin === "subagent"` is an addressed subagent route. `undefined`
2000
2361
  * when nothing is staged yet (the list has not loaded, or no row holds the main
2001
2362
  * view): callers must stand down rather than guess.
2363
+ *
2364
+ * A current id the list has no row for is ALSO `undefined`: the selection may
2365
+ * be a subagent address, and guessing "not a subagent" there would let the
2366
+ * gate install ACP chrome on a route the plugin must not drive. The next list
2367
+ * notification retries.
2002
2368
  */
2003
- function stagedSessionOf(snapshot) {
2369
+ function stagedSessionOf(snapshot, currentSessionId) {
2004
2370
  if (snapshot === void 0) return void 0;
2005
2371
  if (snapshot.current !== void 0) return {
2006
2372
  sessionId: snapshot.current,
@@ -2008,6 +2374,15 @@ window.__ModuleLoader__.load({
2008
2374
  subagent: snapshot.currentAddress !== void 0
2009
2375
  };
2010
2376
  const byId = snapshot.byId ?? {};
2377
+ if (currentSessionId !== void 0) {
2378
+ const row = byId[currentSessionId];
2379
+ if (row === void 0) return void 0;
2380
+ return {
2381
+ sessionId: currentSessionId,
2382
+ row,
2383
+ subagent: row.origin === "subagent"
2384
+ };
2385
+ }
2011
2386
  const order = /* @__PURE__ */ new Map();
2012
2387
  for (const [index, id] of (snapshot.ids ?? []).entries()) order.set(id, index);
2013
2388
  const rank = (id) => order.get(id) ?? Number.MAX_SAFE_INTEGER;
@@ -2021,8 +2396,8 @@ window.__ModuleLoader__.load({
2021
2396
  };
2022
2397
  }
2023
2398
  /** Whether the staged session runs the ACP agent preset. */
2024
- function stagedIsAcpMode(snapshot, acpMode) {
2025
- const staged = stagedSessionOf(snapshot);
2399
+ function stagedIsAcpMode(snapshot, acpMode, currentSessionId) {
2400
+ const staged = stagedSessionOf(snapshot, currentSessionId);
2026
2401
  if (staged === void 0 || staged.subagent) return false;
2027
2402
  const listed = rowAgentPreset(staged.row);
2028
2403
  if (listed !== void 0) return listed === "acp";
@@ -2514,6 +2889,15 @@ window.__ModuleLoader__.load({
2514
2889
  .ntr-acp-chip.is-static:hover { background: var(--dsw-alias-bg-layer-2, #f3f4f6); }
2515
2890
  .ntr-acp-chip.is-active { border-color: var(--dsw-alias-state-business-primary, #2563eb); color: var(--dsw-alias-state-business-primary, #2563eb); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #2563eb) 10%, transparent); }
2516
2891
  .ntr-acp-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2892
+ /* Composer context-occupancy ring (conversation.composer.dock,
2893
+ context-meter.tsx): the agent's own prompt-side used/size in the host
2894
+ ContextMeter's ring geometry, pill and tokens, so it reads as that meter's
2895
+ sibling in the bottom info row. Gated inside the component; absent until the
2896
+ agent reports a sample. */
2897
+ .ntr-acp-meter { display: inline-flex; align-items: center; gap: 6px; padding: 1px 8px; border-radius: 24px; color: var(--dsw-alias-label-tertiary, #9ca3af); font-family: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); font-variant-numeric: tabular-nums; line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); white-space: nowrap; flex: none; }
2898
+ .ntr-acp-meter-track { fill: none; stroke: var(--dsw-alias-border-l3, #e5e7eb); stroke-width: 2px; }
2899
+ .ntr-acp-meter-fill { fill: none; stroke: var(--dsw-alias-label-tertiary, #9ca3af); stroke-width: 2px; stroke-linecap: round; }
2900
+ .ntr-acp-meter-percent { color: inherit; }
2517
2901
  /* The lock hand-off (src/client/agent-lock-anim.ts): the outgoing agent base
2518
2902
  breaks into debris for 460ms, then the edge tag condenses from the same
2519
2903
  particles. Both halves read one shared clock in that module; these durations
@@ -2689,6 +3073,79 @@ window.__ModuleLoader__.load({
2689
3073
  .ntr-permission-option:focus-visible, .ntr-permission-cancel:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #2563eb); outline-offset: 2px; }
2690
3074
  .ntr-permission-cancel { margin-left: auto; color: var(--dsw-alias-label-secondary, #6b7280); }
2691
3075
  .ntr-permission-error { padding: 4px 8px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #b91c1c) 12%, transparent); color: var(--dsw-alias-state-error-primary, #b91c1c); font-size: 11px; word-break: break-word; }
3076
+ /* ---- Released-session banner (conversation.input.dock, released-banner.tsx) ---- */
3077
+ /* Same composer-width formula as the permission dock above: the slot spans
3078
+ the column, the card lines up with the input box. One row of state + one
3079
+ action; the error pins under it, beside the button that failed. */
3080
+ .ntr-released-banner {
3081
+ box-sizing: border-box;
3082
+ width: calc(100% - var(--dsh-composer-side-clearance, 16px) - var(--dsh-composer-side-clearance, 16px));
3083
+ max-width: var(--dsh-composer-card-max-width, 920px);
3084
+ margin: 0 auto;
3085
+ display: flex; flex-direction: column; gap: 6px;
3086
+ padding: 8px 12px;
3087
+ border: 1px solid var(--dsw-alias-border-l2, #d1d5db);
3088
+ border-left: 3px solid var(--dsw-alias-state-warn-primary, #d97706);
3089
+ border-radius: 10px;
3090
+ background: var(--dsw-alias-bg-layer-1, #fff);
3091
+ font-size: 12px; line-height: 1.5;
3092
+ }
3093
+ .ntr-released-banner-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
3094
+ .ntr-released-banner-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
3095
+ .ntr-released-banner-title { font-weight: 600; }
3096
+ .ntr-released-banner-body { color: var(--dsw-alias-label-secondary, #6b7280); }
3097
+ .ntr-released-banner .ntr-acp-more { flex: none; white-space: nowrap; }
3098
+ /* ---- NTR message actions (conversation.chat.assistant-actions) ---- */
3099
+ /* In an NTR conversation the host's like / dislike / branch icons are the
3100
+ plugin's business (the agent decides its own feedback surface, and the
3101
+ host's branch would fork a conversation the agent does not remember). The
3102
+ three icons hide under the html[data-ntr-acp] scope marker, by their
3103
+ LOCALIZED aria-labels — the only stable handles the host leaves (its button
3104
+ classes are CSS-module hashes and there is no data-* hook). Hiding the
3105
+ button is the WHOLE job: the host's Tooltip clones its child without a
3106
+ wrapper element, so there is no container left behind to plug the row gap
3107
+ — and any :has(> button…) rule would match the actions row itself (and the
3108
+ list slot's shared display:contents anchor), hiding the entire row. The
3109
+ :not([data-ntr-branch]) exemption is the pass our OWN button carries —
3110
+ same label, same row. Known fragility: a host copy change or a third
3111
+ language un-hides the icons; the entry probes the labels once per page and
3112
+ warns in the console. */
3113
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="在新对话中分支"]:not([data-ntr-branch]),
3114
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Branch into a new conversation"]:not([data-ntr-branch]),
3115
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="好的回答"],
3116
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="有问题的回答"],
3117
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="取消标记"],
3118
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Good response"],
3119
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Bad response"],
3120
+ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
3121
+ display: none !important;
3122
+ }
3123
+ /* The plugin's own branch button, dressed exactly like the host's .action
3124
+ class (the values are copied from the host's compiled CSS, token colors
3125
+ only). It rides the same actions row, so the host's row gap, font-delta
3126
+ sizing and the [data-actions-reveal] hover rules already govern it. */
3127
+ .ntr-branch-action {
3128
+ width: calc(28px + var(--dsh-content-font-delta, 0px));
3129
+ height: calc(28px + var(--dsh-content-font-delta, 0px));
3130
+ color: var(--dsw-alias-label-tertiary, #9ca3af);
3131
+ cursor: pointer;
3132
+ background: none;
3133
+ border: none;
3134
+ border-radius: 28px;
3135
+ justify-content: center;
3136
+ align-items: center;
3137
+ padding: 6px;
3138
+ display: inline-flex;
3139
+ }
3140
+ .ntr-branch-action svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); }
3141
+ .ntr-branch-action:hover { background: var(--dsw-alias-interactive-bg-hover, #0000000d); color: var(--dsw-alias-label-secondary, #6b7280); }
3142
+ .ntr-branch-action[data-unavailable] { cursor: default; opacity: 0.4; }
3143
+ .ntr-branch-action[data-unavailable]:hover { color: var(--dsw-alias-label-tertiary, #9ca3af); background: none; }
3144
+ .ntr-branch-action:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #2563eb); outline-offset: 2px; }
3145
+ /* A failed branch click, pinned in the row beside the button (the conversation
3146
+ surface of the settings banner the store also writes). One line, truncated;
3147
+ the full text is the title tooltip and the banner. */
3148
+ .ntr-branch-error { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; line-height: calc(28px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-state-error-primary, #b91c1c); }
2692
3149
  /* ---- NTR settings dialog (shell.overlay) ---- */
2693
3150
  /* Frame-wide surface opened from the agent base's empty-roster menu. The
2694
3151
  overlay layer itself is click-through, so the layer opts back in and blocks
@@ -3146,22 +3603,14 @@ window.__ModuleLoader__.load({
3146
3603
  * def (env / npmPackage / exposedModels included); the synthesized fallback
3147
3604
  * covers only the fields an `AgentView` carries, so anything else compares as
3148
3605
  * "changed" — the honest answer when the host never reported it.
3606
+ *
3607
+ * The idle-release field is compared AS STORED: an absent per-agent field
3608
+ * means "follow the global window", and the host's resolved value must not be
3609
+ * written back into it — that would freeze the global window into this agent's
3610
+ * definition and the global slider would then never reach it.
3149
3611
  */
3150
- /**
3151
- * The idle-release window in effect for an agent: an unset per-agent field is
3152
- * resolved by the HOST and reported back on `AgentView`, so filling it from
3153
- * there keeps "unset" and "explicitly the host default" the same state —
3154
- * merely opening the form must never read as an unsaved change.
3155
- */
3156
- function resolvedIdleTimeoutMs(def, activeAgent) {
3157
- if (def.idleTimeoutMs !== void 0 || activeAgent?.idleTimeoutMs === void 0) return def;
3158
- return {
3159
- ...def,
3160
- idleTimeoutMs: activeAgent.idleTimeoutMs
3161
- };
3162
- }
3163
3612
  function persistedDef(detected, activeAgent) {
3164
- if (detected.currentConfig !== void 0) return resolvedIdleTimeoutMs(detected.currentConfig, activeAgent);
3613
+ if (detected.currentConfig !== void 0) return detected.currentConfig;
3165
3614
  if (activeAgent === void 0) return void 0;
3166
3615
  return {
3167
3616
  id: activeAgent.id,
@@ -3170,8 +3619,7 @@ window.__ModuleLoader__.load({
3170
3619
  args: [...activeAgent.args],
3171
3620
  ...activeAgent.cwd === void 0 ? {} : { cwd: activeAgent.cwd },
3172
3621
  allowedCwdRoots: [...activeAgent.allowedCwdRoots],
3173
- env: {},
3174
- ...activeAgent.idleTimeoutMs === void 0 ? {} : { idleTimeoutMs: activeAgent.idleTimeoutMs }
3622
+ env: {}
3175
3623
  };
3176
3624
  }
3177
3625
  /**
@@ -3192,7 +3640,7 @@ window.__ModuleLoader__.load({
3192
3640
  if (exposureKey(payload.exposedModels) !== exposureKey(current.exposedModels)) return false;
3193
3641
  if (mcpKey(payload.mcpServers) !== mcpKey(current.mcpServers)) return false;
3194
3642
  if (payload.autoDiscoverModels !== false !== (current.autoDiscoverModels !== false)) return false;
3195
- if (payload.idleTimeoutMs !== void 0 && payload.idleTimeoutMs !== current.idleTimeoutMs) return false;
3643
+ if (payload.idleTimeoutMs !== current.idleTimeoutMs) return false;
3196
3644
  return true;
3197
3645
  }
3198
3646
  /**
@@ -3501,6 +3949,118 @@ window.__ModuleLoader__.load({
3501
3949
  });
3502
3950
  }
3503
3951
  /**
3952
+ * The idle-release window, as the settings page edits it.
3953
+ *
3954
+ * ONE control, because there is ONE window: how long a chat process may sit
3955
+ * idle before it is parked. The slider snaps to the shared ladder
3956
+ * ({@link IDLE_RELEASE_MS}), so every rung it offers is a window the host can
3957
+ * run.
3958
+ *
3959
+ * The card reports the deployment baseline as the fact being overridden, and
3960
+ * offers a reset ONLY when something is really overridden — the same rule the
3961
+ * approval deadline card follows.
3962
+ */
3963
+ function IdleReleaseCard(props) {
3964
+ const { acp, ui } = props;
3965
+ const release = ui.idleRelease;
3966
+ if (release === void 0) return null;
3967
+ const baseline = release.defaultIdleTimeoutMs;
3968
+ const overridden = release.idleTimeoutMs !== baseline;
3969
+ const index = IDLE_RELEASE_MS.indexOf(release.idleTimeoutMs);
3970
+ const exact = index >= 0;
3971
+ const shown = exact ? release.idleTimeoutMs : nearestIdleRung(release.idleTimeoutMs);
3972
+ const note = release.configuredIdleTimeoutMs === void 0 || release.configuredIdleTimeoutMs === release.idleTimeoutMs ? void 0 : acp.label(`配置文件(cordis.patch.yml)设为 ${idleWindowText(release.configuredIdleTimeoutMs, acp)},此处设置优先。`, `The config file (cordis.patch.yml) asks for ${idleWindowText(release.configuredIdleTimeoutMs, acp)}; the value set here wins.`);
3973
+ const resetLabel = release.configuredIdleTimeoutMs === void 0 ? acp.label("恢复默认值", "Restore default") : acp.label("恢复配置文件的值", "Use config file value");
3974
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
3975
+ className: "dwa-section",
3976
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3977
+ className: "dwa-section-heading",
3978
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
3979
+ className: "dwa-section-title",
3980
+ children: acp.label("空闲释放", "Idle release")
3981
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3982
+ className: "dwa-section-copy",
3983
+ children: acp.label("空闲多久后挂起聊天进程。", "How long an idle chat process is kept before it is parked.")
3984
+ })] })
3985
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3986
+ className: "dwa-preferences",
3987
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3988
+ className: "dwa-preference-block",
3989
+ children: [
3990
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3991
+ className: "dwa-preference-label",
3992
+ children: acp.label("释放窗口", "Release window")
3993
+ }),
3994
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3995
+ className: "dwa-deadline-value",
3996
+ children: idleWindowText(release.idleTimeoutMs, acp)
3997
+ }),
3998
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3999
+ className: "dwa-deadline-range",
4000
+ type: "range",
4001
+ min: 0,
4002
+ max: IDLE_RELEASE_MS.length - 1,
4003
+ step: 1,
4004
+ value: exact ? index : IDLE_RELEASE_MS.indexOf(shown),
4005
+ "aria-label": acp.label("释放窗口", "Release window"),
4006
+ onChange: (event) => {
4007
+ const next = IDLE_RELEASE_MS[Number(event.target.value)];
4008
+ if (next !== void 0 && next !== release.idleTimeoutMs) acp.setIdleRelease(next);
4009
+ }
4010
+ }),
4011
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4012
+ className: "dwa-deadline-ticks",
4013
+ children: IDLE_RELEASE_MS.filter((_, i) => i % 2 === 0).map((ms) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: idleWindowText(ms, acp) }, ms))
4014
+ }),
4015
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4016
+ className: "dwa-preference-copy",
4017
+ children: acp.label("挂起后会话仍保留,下一条消息自动恢复。", "A parked session is kept; the next message resumes it.")
4018
+ }),
4019
+ note !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4020
+ className: "dwa-deadline-flag",
4021
+ children: ["⚠ ", note]
4022
+ })
4023
+ ]
4024
+ }), overridden && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4025
+ className: "dwa-deadline-reset",
4026
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4027
+ type: "button",
4028
+ className: "dwa-btn dwa-btn-secondary",
4029
+ onClick: () => void acp.resetIdleRelease(),
4030
+ children: resetLabel
4031
+ })
4032
+ })]
4033
+ })]
4034
+ });
4035
+ }
4036
+ /**
4037
+ * The rung nearest a window that is not on the ladder — used only to park the
4038
+ * thumb; the readout always prints the exact value.
4039
+ */
4040
+ function nearestIdleRung(ms) {
4041
+ let best = IDLE_RELEASE_MS[0];
4042
+ let bestDistance = Number.POSITIVE_INFINITY;
4043
+ for (const rung of IDLE_RELEASE_MS) {
4044
+ const distance = Math.abs(rung - ms);
4045
+ if (distance < bestDistance) {
4046
+ bestDistance = distance;
4047
+ best = rung;
4048
+ }
4049
+ }
4050
+ return best;
4051
+ }
4052
+ /**
4053
+ * `30 分钟` — the readout and the tick label, one formatter.
4054
+ *
4055
+ * ALWAYS minutes, even at the top of the ladder: a scale that runs "1 分钟"
4056
+ * to "50 分钟" and then "1 小时" reads as two different scales.
4057
+ */
4058
+ function idleWindowText(ms, acp) {
4059
+ if (ms <= 0) return acp.label("永不释放", "Never");
4060
+ const minutes = Math.round(ms / 6e4);
4061
+ return acp.label(`${String(minutes)} 分钟`, `${String(minutes)}m`);
4062
+ }
4063
+ /**
3504
4064
  * Local wall-clock `HH:MM:SS` for one runtime-log line. Hand-built on purpose:
3505
4065
  * `toLocaleTimeString` may drop seconds or pick a 12-hour clock depending on
3506
4066
  * the browser locale, which makes the column ragged and ambiguous.
@@ -3826,6 +4386,12 @@ window.__ModuleLoader__.load({
3826
4386
  onClick: () => setPage("approvals"),
3827
4387
  children: acp.label("审批时限", "Approval deadlines")
3828
4388
  }),
4389
+ ui.idleRelease !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4390
+ type: "button",
4391
+ "aria-pressed": page === "processes",
4392
+ onClick: () => setPage("processes"),
4393
+ children: acp.label("进程管理", "Processes")
4394
+ }),
3829
4395
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3830
4396
  type: "button",
3831
4397
  "aria-pressed": page === "appearance",
@@ -4029,6 +4595,13 @@ window.__ModuleLoader__.load({
4029
4595
  ui
4030
4596
  })
4031
4597
  }),
4598
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4599
+ hidden: page !== "processes",
4600
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IdleReleaseCard, {
4601
+ acp,
4602
+ ui
4603
+ })
4604
+ }),
4032
4605
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
4033
4606
  className: "dwa-section",
4034
4607
  hidden: page !== "appearance",
@@ -4421,13 +4994,6 @@ window.__ModuleLoader__.load({
4421
4994
  });
4422
4995
  }
4423
4996
  /**
4424
- * Hand copy of `DEFAULT_TUNING.idleTimeoutMs` (src/bridge.ts): the browser
4425
- * cannot import the host plane, and this number is only ever placeholder text
4426
- * for a NOT-yet-configured agent (a configured one renders the host's resolved
4427
- * value from `AgentView.idleTimeoutMs`). Keep the two in sync.
4428
- */
4429
- const DEFAULT_IDLE_RELEASE_MS = 18e5;
4430
- /**
4431
4997
  * Hand copy of `DEFAULT_TUNING.probeIdleTimeoutMs` (src/bridge.ts). A process
4432
4998
  * that has never hosted a session — the model probe / post-save probe — lives
4433
4999
  * by THIS window whatever the agent's own field says, so the placeholder has to
@@ -4451,7 +5017,7 @@ window.__ModuleLoader__.load({
4451
5017
  })),
4452
5018
  exposed: config?.exposedModels === void 0 ? void 0 : [...config.exposedModels],
4453
5019
  autoDiscoverModels: config?.autoDiscoverModels !== false,
4454
- idleStr: idleText(activeAgent?.idleTimeoutMs ?? config?.idleTimeoutMs),
5020
+ idleStr: idleText(config?.idleTimeoutMs),
4455
5021
  mcpText: config?.mcpServers === void 0 ? "" : JSON.stringify(config.mcpServers, null, 2)
4456
5022
  };
4457
5023
  }
@@ -4652,18 +5218,18 @@ window.__ModuleLoader__.load({
4652
5218
  children: [
4653
5219
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
4654
5220
  className: "dwa-form-label",
4655
- children: acp.label("空闲释放(毫秒)", "Idle Release (ms)")
5221
+ children: acp.label("空闲释放覆盖(毫秒)", "Idle Release Override (ms)")
4656
5222
  }),
4657
5223
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4658
5224
  className: "dwa-input dwa-input-mono",
4659
- "aria-label": acp.label("空闲释放(毫秒)", "Idle Release (ms)"),
5225
+ "aria-label": acp.label("空闲释放覆盖(毫秒)", "Idle Release Override (ms)"),
4660
5226
  value: form.idleStr,
4661
5227
  onChange: (e) => patch({ idleStr: e.target.value }),
4662
- placeholder: acp.label(`留空 = 宿主默认(承载会话 ${String(DEFAULT_IDLE_RELEASE_MS / 6e4)} 分钟 / 探测进程 ${String(DEFAULT_PROBE_RELEASE_MS / 6e4)} 分钟)`, `Blank = host default (${String(DEFAULT_IDLE_RELEASE_MS / 6e4)} min for a chat process / ${String(DEFAULT_PROBE_RELEASE_MS / 6e4)} min for a probe)`)
5228
+ placeholder: acp.label(`留空 = 跟随全局设置(探测进程固定 ${String(DEFAULT_PROBE_RELEASE_MS / 6e4)} 分钟)`, `Blank = follow the global setting (probes always release after ${String(DEFAULT_PROBE_RELEASE_MS / 6e4)} min)`)
4663
5229
  }),
4664
5230
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4665
5231
  className: idleInvalid ? "dwa-error" : "dwa-hint",
4666
- children: idleInvalid ? acp.label("必须是 0(永不释放)或 [1000, 86400000] 之间的整数毫秒。", "Must be 0 (never release) or an integer in [1000, 86400000] ms.") : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: acp.label("承载过会话的进程空闲后挂起;0 不释放,探测进程固定 5 分钟。", "Park idle chat processes; 0 keeps them alive. Probe processes always release after 5 minutes.") })
5232
+ children: idleInvalid ? acp.label("必须是 0(永不释放)或 [1000, 86400000] 之间的整数毫秒。", "Must be 0 (never release) or an integer in [1000, 86400000] ms.") : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: acp.label("仅覆盖此智能体;留空即跟随「进程管理」的全局设置。", "Overrides this agent only; leave blank to follow the global setting in “Processes”.") })
4667
5233
  })
4668
5234
  ]
4669
5235
  })
@@ -5478,6 +6044,34 @@ window.__ModuleLoader__.load({
5478
6044
  function agentModeTitle(agentId, acp) {
5479
6045
  return acp.label(`agent 模式(${agentId ?? "agent"}):由 agent 自己定义。NTR 会话里 DSH 的 Access mode 不显示,模式就在此切换。`, `agent mode (${agentId ?? "agent"}): defined by the agent. In an NTR conversation the DSH access mode is not shown, so this is where the mode is switched.`);
5480
6046
  }
6047
+ /**
6048
+ * Whether this conversation is one the agent-mode control may serve.
6049
+ *
6050
+ * An EXPLICIT non-ACP verdict outranks a lingering session row: switching a
6051
+ * conversation off the NTR preset does not close its ACP session, so "a row
6052
+ * exists" alone would keep this control on an ordinary conversation — the same
6053
+ * precedence the agent tag, the context ring and the released banner already
6054
+ * apply. Only while the host has not answered (`undefined`) does a bound
6055
+ * session count as proof, which is what keeps the control available before the
6056
+ * host's first `session/chat-list` answer lands.
6057
+ */
6058
+ function agentModeVisible(acpMode, hasBoundSession) {
6059
+ if (acpMode === false) return false;
6060
+ return acpMode === true || hasBoundSession;
6061
+ }
6062
+ /**
6063
+ * The mode the select shows as in force.
6064
+ *
6065
+ * A LIVE session is the authority (it is what the agent runs right now). A
6066
+ * RELEASED row only describes the mode the agent was parked ON, so a newer
6067
+ * pick — which `applyChatChoice` will apply on the next resume — outranks it
6068
+ * (and the parked row still beats the catalog default). Same precedence as
6069
+ * the model and thought level in `describeRoute`: a pre-switch on a parked
6070
+ * session must be visible or the control reads as dead.
6071
+ */
6072
+ function modeInForce(target, persisted, fallback) {
6073
+ return (target !== void 0 && target.released !== true ? target.modes?.currentModeId : void 0) ?? persisted ?? target?.modes?.currentModeId ?? fallback ?? "";
6074
+ }
5481
6075
  function AcpComposerMode({ acp, sessionId, useSession }) {
5482
6076
  const ui = useUi(acp);
5483
6077
  const dshSessionId = String(sessionId);
@@ -5492,7 +6086,7 @@ window.__ModuleLoader__.load({
5492
6086
  ]);
5493
6087
  const bound = ui.chatSessionsFor === dshSessionId ? ui.chatSessions : [];
5494
6088
  const target = bound.find((session) => !session.released) ?? bound[0];
5495
- const isAcp = target !== void 0 || ui.acpMode[dshSessionId] === true;
6089
+ const isAcp = agentModeVisible(ui.acpMode[dshSessionId], target !== void 0);
5496
6090
  const agentId = target?.agentId ?? ui.chatLockedAgent[dshSessionId] ?? ui.chatBoundAgent[dshSessionId] ?? ui.chatChoice[dshSessionId]?.agentId ?? (isAcp ? ui.defaultAgent : void 0);
5497
6091
  const catalog = agentId === void 0 ? void 0 : ui.seatCatalogs[agentId];
5498
6092
  (0, react.useEffect)(() => {
@@ -5504,15 +6098,15 @@ window.__ModuleLoader__.load({
5504
6098
  isAcp,
5505
6099
  target
5506
6100
  ]);
6101
+ if (!isAcp) return null;
5507
6102
  const live = target?.modes?.availableModes ?? [];
5508
- const modes = live.length > 0 ? live : (isAcp ? catalog?.mode?.choices ?? [] : []).map((choice) => ({
6103
+ const modes = live.length > 0 ? live : (catalog?.mode?.choices ?? []).map((choice) => ({
5509
6104
  id: choice.id,
5510
6105
  name: choice.name,
5511
6106
  description: choice.description
5512
6107
  }));
5513
6108
  if (modes.length === 0) return null;
5514
- const persisted = ui.chatChoice[dshSessionId]?.agentId === agentId ? ui.chatChoice[dshSessionId]?.mode : void 0;
5515
- const current = target?.modes?.currentModeId ?? persisted ?? catalog?.mode?.currentValue ?? modes[0]?.id ?? "";
6109
+ const current = modeInForce(target, ui.chatChoice[dshSessionId]?.agentId === agentId ? ui.chatChoice[dshSessionId]?.mode : void 0, catalog?.mode?.currentValue ?? modes[0]?.id);
5516
6110
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
5517
6111
  style: {
5518
6112
  display: "inline-flex",
@@ -5548,6 +6142,109 @@ window.__ModuleLoader__.load({
5548
6142
  })
5549
6143
  });
5550
6144
  }
6145
+ //#endregion
6146
+ //#region src/client/context-meter.tsx
6147
+ /**
6148
+ * Composer context-occupancy ring at `conversation.composer.dock` — the bottom
6149
+ * info row below the input card, where the host also renders its ContextMeter.
6150
+ *
6151
+ * WHY A PLUGIN-OWNED RING: the host's meter reads its `contextPressure`
6152
+ * projection, folded from DSH provider-usage samples plus a route capacity. An
6153
+ * ACP conversation produces neither — the agent owns the request and the host
6154
+ * never sees its prompt — so the native meter renders nothing there. This ring
6155
+ * reports the agent's OWN `usage_update` used/size instead: occupancy only. ACP
6156
+ * declares no system/tools/messages split, so no breakdown is invented.
6157
+ *
6158
+ * Registered for every conversation and gating itself: it renders only for an
6159
+ * ACP conversation whose session row carries usage. The row comes from
6160
+ * `session/chat-list`; the store live-patches it from the coalesced
6161
+ * `usage_update` frames (see `AcpStore.flushUpdates`).
6162
+ */
6163
+ /**
6164
+ * The sample this conversation's ring should show, or `undefined` when the
6165
+ * entry must render nothing. An explicit `acpMode === false` outranks a stale
6166
+ * `chatSessions` row: the conversation's preset left NTR while its ACP session
6167
+ * was still alive, and the ring must leave with it (same precedence as the
6168
+ * agent tag's `agentTagVisible`). While the verdict is unknown, a LIVE row is
6169
+ * proof; a PARKED one is not — it can outlive the preset switch, and the ring
6170
+ * must not appear on an ordinary conversation during the answer window.
6171
+ */
6172
+ function acpMeterUsage(ui, dshSessionId) {
6173
+ if (ui.acpMode[dshSessionId] === false) return void 0;
6174
+ if (ui.chatSessionsFor !== dshSessionId) return void 0;
6175
+ const live = ui.chatSessions.find((row) => !row.released);
6176
+ if (live === void 0 && ui.acpMode[dshSessionId] !== true) return void 0;
6177
+ return (live ?? ui.chatSessions[0])?.usage;
6178
+ }
6179
+ /**
6180
+ * Bounded display occupancy from one agent sample. `undefined` (no meter) for
6181
+ * an absent / malformed / zero-capacity sample rather than a made-up 0%.
6182
+ */
6183
+ function contextOccupancy(usage) {
6184
+ if (usage === void 0) return void 0;
6185
+ const { used, size } = usage;
6186
+ if (!Number.isFinite(used) || !Number.isFinite(size) || used < 0 || size <= 0) return void 0;
6187
+ return {
6188
+ percent: Math.min(100, Math.round(used / size * 100)),
6189
+ used,
6190
+ size
6191
+ };
6192
+ }
6193
+ /** Compact token count for the tooltip, in the host meter's K/M shape. */
6194
+ function compactTokenCount(value) {
6195
+ if (value < 1e3) return String(Math.round(value));
6196
+ const thousands = value / 1e3;
6197
+ if (value < 1e6) return `${thousands >= 100 ? String(Math.round(thousands)) : String(Math.round(thousands * 10) / 10)}K`;
6198
+ const millions = value / 1e6;
6199
+ return `${millions >= 100 ? String(Math.round(millions)) : String(Math.round(millions * 10) / 10)}M`;
6200
+ }
6201
+ /** Ring geometry matches the host ContextMeter: 14px viewBox, 2px stroke. */
6202
+ const RADIUS = 5.5;
6203
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
6204
+ function AcpContextMeter({ acp, sessionId, useSession }) {
6205
+ ensureAcpStyle();
6206
+ const ui = useUi$2(acp);
6207
+ const dshSessionId = String(sessionId);
6208
+ const running = useSession((snapshot) => snapshot.running);
6209
+ (0, react.useEffect)(() => {
6210
+ acp.loadChatSessions(dshSessionId);
6211
+ }, [
6212
+ acp,
6213
+ dshSessionId,
6214
+ running
6215
+ ]);
6216
+ const context = contextOccupancy(acpMeterUsage(ui, dshSessionId));
6217
+ if (context === void 0) return null;
6218
+ 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)}`);
6219
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6220
+ className: "ntr-acp-meter",
6221
+ role: "img",
6222
+ "aria-label": title,
6223
+ title,
6224
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
6225
+ viewBox: "0 0 14 14",
6226
+ width: "14",
6227
+ height: "14",
6228
+ "aria-hidden": "true",
6229
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
6230
+ className: "ntr-acp-meter-track",
6231
+ cx: "7",
6232
+ cy: "7",
6233
+ r: RADIUS
6234
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
6235
+ className: "ntr-acp-meter-fill",
6236
+ cx: "7",
6237
+ cy: "7",
6238
+ r: RADIUS,
6239
+ strokeDasharray: `${String(CIRCUMFERENCE * context.percent / 100)} ${String(CIRCUMFERENCE)}`,
6240
+ transform: "rotate(-90 7 7)"
6241
+ })]
6242
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6243
+ className: "ntr-acp-meter-percent",
6244
+ children: [context.percent, "%"]
6245
+ })]
6246
+ });
6247
+ }
5551
6248
  const phases = /* @__PURE__ */ new Map();
5552
6249
  /** The reveal serial whose ceremony has already played (or was skipped). */
5553
6250
  const consumed = /* @__PURE__ */ new Map();
@@ -5840,8 +6537,8 @@ window.__ModuleLoader__.load({
5840
6537
  if (agentId === void 0) return { efforts: [] };
5841
6538
  const agentTitle = agents.find((agent) => agent.id === agentId)?.title ?? bound?.agentTitle ?? catalog?.title ?? agentId;
5842
6539
  const pick = choice !== void 0 && choice.agentId === agentId ? choice : void 0;
5843
- const modelId = bound?.modelId ?? pick?.modelId;
5844
- const effort = bound?.reasoningEffort ?? pick?.effort;
6540
+ const modelId = bound?.released === true ? pick?.modelId ?? bound?.modelId : bound?.modelId ?? pick?.modelId;
6541
+ const effort = bound?.released === true ? pick?.effort ?? bound?.reasoningEffort : bound?.reasoningEffort ?? pick?.effort;
5845
6542
  const reasoning = catalog?.reasoning;
5846
6543
  const agentModel = catalog?.currentModel;
5847
6544
  if (bound === void 0 && pick === void 0 && agentModel === void 0) return {
@@ -6020,9 +6717,6 @@ window.__ModuleLoader__.load({
6020
6717
  agentListed,
6021
6718
  agentId
6022
6719
  ]);
6023
- const route = describeRoute(agents, bound, lockedTo === void 0 ? ui.defaultAgent : void 0, catalog, lockedTo ?? ui.chatBoundAgent[sessionId], choice);
6024
- if (!agentBaseHasData(bound, agents, ui.agentsLoaded)) return null;
6025
- const routed = route.agentId !== void 0;
6026
6720
  (0, react.useEffect)(() => {
6027
6721
  if (lockedTo === void 0 || menu === null) return;
6028
6722
  if (menu.sessionId === sessionId && menu.kind === "agent") closeAcpMenu();
@@ -6032,6 +6726,10 @@ window.__ModuleLoader__.load({
6032
6726
  menu?.kind,
6033
6727
  sessionId
6034
6728
  ]);
6729
+ const route = describeRoute(agents, bound, lockedTo === void 0 ? ui.defaultAgent : void 0, catalog, lockedTo ?? ui.chatBoundAgent[sessionId], choice);
6730
+ if (!agentBaseHasData(bound, agents, ui.agentsLoaded)) return null;
6731
+ const routed = route.agentId !== void 0;
6732
+ if (ui.acpMode[sessionId] !== true) return null;
6035
6733
  const shattering = lockPhase === "shatter" && lockedTo !== void 0;
6036
6734
  const showAgentBase = !agentBaseHidden(lockedTo) || shattering;
6037
6735
  const modelLabel = routed ? route.modelLabel : void 0;
@@ -6337,6 +7035,7 @@ window.__ModuleLoader__.load({
6337
7035
  agentId
6338
7036
  ]);
6339
7037
  if (menu === null || menu.sessionId !== sessionId) return null;
7038
+ if (ui.acpMode[sessionId] !== true) return null;
6340
7039
  const route = describeRoute(agents, bound, lockedTo === void 0 ? ui.defaultAgent : void 0, catalog, lockedTo ?? ui.chatBoundAgent[sessionId], choice);
6341
7040
  let model;
6342
7041
  try {
@@ -6781,6 +7480,19 @@ window.__ModuleLoader__.load({
6781
7480
  return list.find((session) => !session.released) ?? list[0];
6782
7481
  }
6783
7482
  /**
7483
+ * Whether an empty stream means "history exists but is not shown yet": the
7484
+ * record was restored from disk after a host restart (the agent's replay has
7485
+ * not landed), or a resume cleared the buffer ahead of the replay. The view
7486
+ * then promises the replay instead of claiming the conversation never
7487
+ * happened — "还没有消息" to someone whose chat plane still shows yesterday's
7488
+ * turns is exactly the kind of contradiction this file's copy rules exist to
7489
+ * prevent. In-memory released sessions keep their buffer, so this only fires
7490
+ * when the buffer really is absent.
7491
+ */
7492
+ function historyAwaitsReplay(session) {
7493
+ return session.released === true && session.hasHistory === true;
7494
+ }
7495
+ /**
6784
7496
  * Everything the view can read that says "this conversation is set up on an
6785
7497
  * agent": a live or parked ACP session, the host's binding (`session/chat-list`'s
6786
7498
  * `boundAgentId`, which outlives a closed session), and the persisted pick.
@@ -6869,6 +7581,7 @@ window.__ModuleLoader__.load({
6869
7581
  if (node === null || !pinned.current) return;
6870
7582
  node.scrollTop = node.scrollHeight;
6871
7583
  }, [blockCount, tail]);
7584
+ if (ui.acpMode[sessionId] !== true) return null;
6872
7585
  if (transcript === void 0) {
6873
7586
  const empty = acpEmptyCopy({
6874
7587
  bound: boundAgent,
@@ -6980,7 +7693,7 @@ window.__ModuleLoader__.load({
6980
7693
  },
6981
7694
  children: transcript.blocks.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6982
7695
  className: "ntr-acp-empty-hint",
6983
- children: t("还没有消息:在下面的输入框里发给它。", "Nothing yet — send it a message below.")
7696
+ children: historyAwaitsReplay(session) ? t("进程已释放;历史将在恢复后回放显示。", "The process is released — history replays once it is restored.") : t("还没有消息:在下面的输入框里发给它。", "Nothing yet — send it a message below.")
6984
7697
  }) : transcript.blocks.map((block, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BlockView, {
6985
7698
  block,
6986
7699
  label: acp.label,
@@ -7184,6 +7897,219 @@ window.__ModuleLoader__.load({
7184
7897
  });
7185
7898
  }
7186
7899
  //#endregion
7900
+ //#region src/client/released-banner.tsx
7901
+ /**
7902
+ * The conversation's released-session banner: one row above the composer
7903
+ * (`conversation.input.dock`) saying the agent process behind this
7904
+ * conversation has been released, with the explicit resume button.
7905
+ *
7906
+ * WHY A BANNER: releasing a process is deliberately silent (constraint 19 —
7907
+ * an idle teardown is the expected lifecycle, no `agent/notice`), and the
7908
+ * only other trace was the NTR view tab's status badge — a surface most
7909
+ * users never open. The "your next message resumes it" promise is real but
7910
+ * unverifiable from the chat itself, so the banner states it here and the
7911
+ * button turns it into a gesture (`session/chat-resume`), the explicit form
7912
+ * of the same recovery the next prompt performs lazily.
7913
+ *
7914
+ * The gate is a pure function ({@link releasedBannerVisible}) — the seam the
7915
+ * composer's other visibility decisions use, so the rules are testable with
7916
+ * no DOM. Like the agent tag and the context ring, an explicit
7917
+ * `acpMode === false` outranks a lingering `chatSessions` row: a conversation
7918
+ * that left NTR mode must not keep claiming a parked NTR process.
7919
+ */
7920
+ /**
7921
+ * Whether the banner should render: this conversation's ACP session records
7922
+ * are all released — the process is parked (idle release or crash) and
7923
+ * nothing is live to serve the next message yet. An empty list (no agent has
7924
+ * run here) and a live row anywhere both mean "nothing to recover", so the
7925
+ * banner stays out of the way.
7926
+ */
7927
+ function releasedBannerVisible(ui, dshSessionId) {
7928
+ if (ui.acpMode[dshSessionId] === false) return false;
7929
+ if (ui.chatSessionsFor !== dshSessionId) return false;
7930
+ return ui.chatSessions.length > 0 && ui.chatSessions.every((row) => row.released === true);
7931
+ }
7932
+ function ReleasedBanner({ acp, sessionId }) {
7933
+ ensureAcpStyle();
7934
+ const ui = useUi$2(acp);
7935
+ const dshSessionId = String(sessionId);
7936
+ const [busy, setBusy] = (0, react.useState)(false);
7937
+ const [error, setError] = (0, react.useState)();
7938
+ (0, react.useEffect)(() => {
7939
+ acp.loadChatSessions(dshSessionId);
7940
+ }, [acp, dshSessionId]);
7941
+ if (!releasedBannerVisible(ui, dshSessionId)) return null;
7942
+ const resume = () => {
7943
+ if (busy) return;
7944
+ setBusy(true);
7945
+ setError(void 0);
7946
+ acp.resumeChatSession(dshSessionId).catch((failure) => {
7947
+ setError(failure instanceof Error ? failure.message : String(failure));
7948
+ }).finally(() => {
7949
+ setBusy(false);
7950
+ });
7951
+ };
7952
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7953
+ className: "ntr-released-banner",
7954
+ role: "region",
7955
+ "aria-label": acp.label("进程已释放", "Process released"),
7956
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7957
+ className: "ntr-released-banner-row",
7958
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7959
+ className: "ntr-released-banner-text",
7960
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7961
+ className: "ntr-released-banner-title",
7962
+ children: acp.label("进程已释放", "Process released")
7963
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7964
+ className: "ntr-released-banner-body",
7965
+ children: acp.label("上下文已保留;下一条消息将自动恢复,或点击【恢复进程】按钮。", "Context is kept — your next message resumes it, or click [Resume process].")
7966
+ })]
7967
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7968
+ type: "button",
7969
+ className: "ntr-acp-more is-primary",
7970
+ disabled: busy,
7971
+ title: acp.label("立即拉起 agent 进程并恢复这个会话", "Bring the agent process back and restore this session now"),
7972
+ onClick: resume,
7973
+ children: busy ? acp.label("正在恢复…", "Resuming…") : acp.label("恢复进程", "Resume process")
7974
+ })]
7975
+ }), error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7976
+ className: "ntr-permission-error",
7977
+ role: "alert",
7978
+ children: error
7979
+ })]
7980
+ });
7981
+ }
7982
+ //#endregion
7983
+ //#region src/client/branch-action.tsx
7984
+ /**
7985
+ * The NTR branch button (slot `conversation.chat.assistant-actions`): in an
7986
+ * ACP conversation this is the ONLY branch entry — the host's like / dislike /
7987
+
7988
+ * branch icons are hidden under `html[data-ntr-acp]` (see acp-ui.tsx), and
7989
+ * this button takes the host branch's place with the same icon, size and
7990
+ * row position (order 20, after the hidden feedback entry's order 10).
7991
+ *
7992
+ * Clicking runs the fixed two-stage branch: the HOST forks the DSH session
7993
+ * tree first (`sessions.fork`, structurally resolved), then the plugin adopts
7994
+ * the ACP side (`session/chat-fork-adopt` → the agent's own `session/fork`),
7995
+ * so the child conversation's agent actually remembers the parent context —
7996
+ * see the NTR message-actions plan, §D5/§D7.
7997
+ *
7998
+ * Availability is the store's `forkStateOf` six-state mapping over facts the
7999
+ * button never derives itself: the host's per-row `fork` verdict
8000
+ * (`session/chat-list`), the SSE running flag, the host-fork structural probe,
8001
+ * and the DOM tail verdict. The tail check reads the host's OWN
8002
+ * `[data-turn-tail]` marker (`data-actions-reveal === "always"` = the latest
8003
+ * completed turn) and fails CLOSED: a missing marker disables the button
8004
+ * rather than risking a fork cut at a point the user is not looking at.
8005
+ */
8006
+ /**
8007
+ * The host's own branch-button icon, picked per host generation. The icon
8008
+ * exports MOVED between shipped hosts — desktop 0.2.1-rc.2 has
8009
+ * `IconBranchOutlineRegular` / `…Medium` but NOT `…16`, newer packages export
8010
+ * `…16` — and a hard import of one name compiles to a plain property read
8011
+ * (`primitives.IconBranchOutline16`), so the missing export is `undefined`
8012
+ * and `<undefined />` throws "Element type is invalid". The slot's error
8013
+ * boundary then abdicates the whole entry: no button, no tooltip, nothing in
8014
+ * any log — the exact silent disappearance this picker exists to prevent.
8015
+ * Rendering the button never depends on the icon: with all three absent the
8016
+ * click still works, only the 28px glyph is missing.
8017
+ */
8018
+ function branchIconOf(source) {
8019
+ return source.IconBranchOutline16 ?? source.IconBranchOutlineRegular ?? source.IconBranchOutlineMedium;
8020
+ }
8021
+ const BranchIcon = branchIconOf(_deepseek_ai_dsh_client_ui_primitives);
8022
+ /** What the label reads for each state (zh / en pairs, per the plan's §D4). */
8023
+ function tooltipFor(state, label) {
8024
+ switch (state) {
8025
+ case "ready": return label("在新对话中分支", "Branch into a new conversation");
8026
+ case "unsupported": return label("该 agent 不支持原生分叉", "This agent cannot fork natively");
8027
+ case "released": return label("会话已挂起,先发一条消息恢复再分叉", "Resume this session before branching");
8028
+ case "busy": return label("当前轮次结束后才能分叉", "Wait for the current turn to finish");
8029
+ case "notTail": return label("仅可从已完成轮次的最后一条消息分支", "Available only on the last message of a completed turn");
8030
+ case "hostUnavailable": return label("当前宿主不提供分叉", "This host does not support branching");
8031
+ }
8032
+ }
8033
+ function AcpBranchAction(props) {
8034
+ const ui = useUi$2(props.acp);
8035
+ const buttonRef = (0, react.useRef)(null);
8036
+ const [pending, setPending] = (0, react.useState)(false);
8037
+ /**
8038
+ * The host's own availability verdict for the turn tail this button sits
8039
+ * in, re-read after EVERY render (no dep array): the marker flips without
8040
+ * any store event of our own when a new turn lands, and before the first
8041
+ * layout pass the ref is not attached yet. Two facts, both the HOST's:
8042
+ * `data-actions-reveal === "always"` (this tail is the latest turn and
8043
+ * completed) and the host branch button's own `data-unavailable` (a stricter
8044
+ * gate — a tail whose final node is not the transcript's last, or a turn
8045
+ * with later nodes, is one the HOST refuses to fork). Reading the host's
8046
+ * button keeps the plugin's readiness a SUBSET of the host's: wherever the
8047
+ * two would disagree about the cut point, the plugin button disables, and
8048
+ * the remaining clicks fork at the same latest-completed prefix the host
8049
+ * would (the tail marked `always` is the latest completed turn, which is
8050
+ * exactly where the host's fallback `atSeq` lands).
8051
+ */
8052
+ const [tailReady, setTailReady] = (0, react.useState)(false);
8053
+ (0, react.useLayoutEffect)(() => {
8054
+ const tail = buttonRef.current?.closest("[data-turn-tail]");
8055
+ const hostBranch = tail?.querySelector("button[aria-label=\"在新对话中分支\"]:not([data-ntr-branch]), button[aria-label=\"Branch into a new conversation\"]:not([data-ntr-branch])");
8056
+ setTailReady(tail?.getAttribute("data-actions-reveal") === "always" && hostBranch?.hasAttribute("data-unavailable") !== true);
8057
+ });
8058
+ /**
8059
+ * The click's local failure line. The store ALSO flashes the same reason
8060
+ * into the settings banner, but the user is HERE — without this row the
8061
+ * click would "do nothing" visibly, the exact invisibility the permission
8062
+ * dock's pinned-error pattern exists to prevent.
8063
+ */
8064
+ const [failure, setFailure] = (0, react.useState)(void 0);
8065
+ if (ui.acpMode[props.sessionId] !== true) return null;
8066
+ if (ui.chatSessionsFor !== props.sessionId) return null;
8067
+ const row = ui.chatSessionsFor === props.sessionId ? ui.chatSessions[0] : void 0;
8068
+ const forkFace = props.hostFork?.();
8069
+ const liveRunning = row !== void 0 && ui.sessions.some((s) => s.sessionId === row.sessionId && s.running);
8070
+ const state = forkStateOf(row, forkFace !== void 0, tailReady, liveRunning);
8071
+ const disabled = state !== "ready" || pending;
8072
+ const branchLabel = props.acp.label("在新对话中分支", "Branch into a new conversation");
8073
+ const onClick = () => {
8074
+ if (state !== "ready" || pending) return;
8075
+ setPending(true);
8076
+ setFailure(void 0);
8077
+ props.acp.forkChatSession(props.sessionId, (sessionId) => {
8078
+ const face = props.hostFork?.();
8079
+ if (face === void 0) throw new Error("acp: host fork service is gone");
8080
+ return face.fork({
8081
+ sessionId,
8082
+ increaseTitle: true
8083
+ });
8084
+ }).then((outcome) => {
8085
+ if ("childId" in outcome) props.openSession?.(outcome.childId);
8086
+ else setFailure(outcome.failure);
8087
+ }).finally(() => {
8088
+ setPending(false);
8089
+ });
8090
+ };
8091
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
8092
+ label: tooltipFor(state, props.acp.label),
8093
+ side: "bottom",
8094
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
8095
+ ref: buttonRef,
8096
+ type: "button",
8097
+ className: "ntr-branch-action",
8098
+ "aria-label": branchLabel,
8099
+ "aria-disabled": disabled || void 0,
8100
+ "data-ntr-branch": "",
8101
+ "data-unavailable": disabled || void 0,
8102
+ onClick: disabled ? void 0 : onClick,
8103
+ children: BranchIcon === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(BranchIcon, {})
8104
+ })
8105
+ }), failure !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
8106
+ className: "ntr-branch-error",
8107
+ role: "alert",
8108
+ title: failure,
8109
+ children: failure
8110
+ })] });
8111
+ }
8112
+ //#endregion
7187
8113
  //#region src/client/model-seat.tsx
7188
8114
  /** Exact key of the composer's model seat (a `single`, session-scoped slot). */
7189
8115
  const MODEL_SEAT_KEY = "conversation.input.model";
@@ -7386,8 +8312,8 @@ window.__ModuleLoader__.load({
7386
8312
  * caller reads as "not ACP" (fail-open: an ordinary conversation must never
7387
8313
  * lose its native commands to a guess).
7388
8314
  */
7389
- function slashAcpVerdict(snapshot, sessionId, verdict) {
7390
- const staged = stagedSessionOf(snapshot);
8315
+ function slashAcpVerdict(snapshot, sessionId, verdict, currentSessionId) {
8316
+ const staged = stagedSessionOf(snapshot, currentSessionId);
7391
8317
  if (staged !== void 0 && staged.sessionId === sessionId) {
7392
8318
  const listed = rowAgentPreset(staged.row);
7393
8319
  if (listed !== void 0) return listed === "acp";
@@ -7732,6 +8658,37 @@ window.__ModuleLoader__.load({
7732
8658
  const inject = ["slots", "connection"];
7733
8659
  const browserPrefersZh = typeof navigator !== "undefined" && /^zh\b/i.test(navigator.language);
7734
8660
  /**
8661
+ * The NTR scope marker (`html[data-ntr-acp]`): the ONLY hook the message-action
8662
+ * hiding CSS keys on, so the host's like / dislike / branch icons disappear in
8663
+ * exactly the conversations this gate has confirmed to run the ACP preset —
8664
+ * and reappear the moment the evidence stops answering (every `standDown`
8665
+ * clears it; non-ACP sessions never see it). Idempotent per call.
8666
+ */
8667
+ const setScopeMarker = (active) => {
8668
+ if (typeof document === "undefined") return;
8669
+ document.documentElement.toggleAttribute("data-ntr-acp", active);
8670
+ };
8671
+ /**
8672
+ * One-shot sanity probe for the aria-label hiding: the host's action buttons
8673
+ * carry LOCALIZED labels and no data-* hook, so a copy change (or a third
8674
+ * language) silently un-hides them while everything else keeps working. Once
8675
+ * the marker is on and a turn tail is actually on screen, check that at least
8676
+ * one label the rules key on still exists and leave one console line for
8677
+ * whoever debugs "the icons came back" — dev-visible, never user UI.
8678
+ */
8679
+ let probedHostLabels = false;
8680
+ const probeHostActionLabels = () => {
8681
+ if (probedHostLabels || typeof document === "undefined") return;
8682
+ if (document.querySelector("[data-turn-tail]") === null) return;
8683
+ probedHostLabels = true;
8684
+ if (document.querySelector([
8685
+ "button:not([data-ntr-branch])[aria-label=\"在新对话中分支\"]",
8686
+ "button:not([data-ntr-branch])[aria-label=\"Branch into a new conversation\"]",
8687
+ "button[aria-label=\"好的回答\"]",
8688
+ "button[aria-label=\"Good response\"]"
8689
+ ].join(", ")) === null) console.warn("[dsh-ntr] none of the host message-action labels matched; the like/dislike/branch icons may not be hidden in NTR conversations");
8690
+ };
8691
+ /**
7735
8692
  * How long a host verdict may stand as the gate's ONLY fact source before the
7736
8693
  * gate asks the host again.
7737
8694
  *
@@ -7807,11 +8764,32 @@ window.__ModuleLoader__.load({
7807
8764
  return;
7808
8765
  }
7809
8766
  };
8767
+ /**
8768
+ * The runtime's `sessions.fork`, resolved per call: `undefined` renders the
8769
+ * branch button disabled (`hostUnavailable`) instead of hiding it, so a
8770
+ * host that gains the primitive later is picked up on the next render.
8771
+ */
8772
+ const hostFork = () => {
8773
+ try {
8774
+ const fork = service("sessions")?.fork;
8775
+ return typeof fork === "function" ? { fork } : void 0;
8776
+ } catch {
8777
+ return;
8778
+ }
8779
+ };
8780
+ /** Focus a session in the workspace (the branch click's last step). */
8781
+ const openSession = (sessionId) => {
8782
+ try {
8783
+ service("uiWorkspace")?.openSession?.(sessionId);
8784
+ } catch {}
8785
+ };
7810
8786
  /** The injected face every ACP conversation entry receives. */
7811
8787
  const chatFace = (sessionId) => ({
7812
8788
  acp: store,
7813
8789
  sessionId: String(sessionId),
7814
- faceFor
8790
+ faceFor,
8791
+ hostFork,
8792
+ openSession
7815
8793
  });
7816
8794
  ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
7817
8795
  name: "settings.section",
@@ -7850,6 +8828,18 @@ window.__ModuleLoader__.load({
7850
8828
  order: 10,
7851
8829
  inject: (sessionId) => chatFace(sessionId)
7852
8830
  }, PermissionDock)), "dsh-ntr: conversation permission dock");
8831
+ ctx.effect(() => ctx.slots.inject("conversation.input.dock", () => ctx.slots.register({
8832
+ name: "conversation.input.dock",
8833
+ id: "acp-released-banner",
8834
+ order: 20,
8835
+ inject: (sessionId) => chatFace(sessionId)
8836
+ }, ReleasedBanner)), "dsh-ntr: released-session banner");
8837
+ ctx.effect(() => ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
8838
+ name: "conversation.chat.assistant-actions",
8839
+ id: "ntr-branch",
8840
+ order: 20,
8841
+ inject: (sessionId) => chatFace(sessionId)
8842
+ }, AcpBranchAction)), "dsh-ntr: message branch action");
7853
8843
  /**
7854
8844
  * The client runtime's session list store, read structurally. Used to find
7855
8845
  * the session the composer is staged on and whether it runs the ACP preset —
@@ -7861,6 +8851,28 @@ window.__ModuleLoader__.load({
7861
8851
  return list;
7862
8852
  };
7863
8853
  /**
8854
+ * The renderer's current Session scope observable, when this host exposes it.
8855
+ * This is the source the host's own main-view picker prefers; the list's
8856
+ * retained rows are only the fallback for hosts without the adapter.
8857
+ */
8858
+ const currentScopeSource = () => {
8859
+ try {
8860
+ const current = service("uiSession")?.adapter?.current;
8861
+ return current !== void 0 && typeof current.getSnapshot === "function" ? current : void 0;
8862
+ } catch {
8863
+ return;
8864
+ }
8865
+ };
8866
+ /** The session the composer is actually staged on, if the runtime can say. */
8867
+ const currentScopeSessionId = () => {
8868
+ try {
8869
+ const key = currentScopeSource()?.getSnapshot?.()?.key;
8870
+ return typeof key === "string" && key !== "" ? key : void 0;
8871
+ } catch {
8872
+ return;
8873
+ }
8874
+ };
8875
+ /**
7864
8876
  * Chat session ids the ACP-mode tracker has already asked the host about (the
7865
8877
  * `session/chat-list` fallback), mapped to WHEN the last ask went out. The
7866
8878
  * ask is once per id so a host whose answer carries no `acpMode` cannot turn
@@ -7885,6 +8897,8 @@ window.__ModuleLoader__.load({
7885
8897
  const acpModeVerdict = (sessionId) => store.getSnapshot().acpMode[sessionId];
7886
8898
  ctx.effect(() => {
7887
8899
  let stopWatch;
8900
+ /** Subscription to the renderer's current Session binding (see `currentScopeSource`). */
8901
+ let stopCurrent;
7888
8902
  /**
7889
8903
  * The claim on the host's model seat, held while the staged conversation
7890
8904
  * runs the ACP preset. That cell is where the host's own base sits (the
@@ -7921,6 +8935,50 @@ window.__ModuleLoader__.load({
7921
8935
  */
7922
8936
  let permissionClaim;
7923
8937
  /**
8938
+ * ACP-only auxiliary entries: the NTR view tab and the composer's context
8939
+ * ring. Unlike the two seats these are `list` slots with no visibility
8940
+ * predicate of their own, so "self-hide in the component" cannot remove a
8941
+ * tab from the header or free the native bottom row. They are installed by
8942
+ * this gate for exactly the same lifetime as the claims and disposed on
8943
+ * every stand-down — a standard conversation then has no NTR tab at all,
8944
+ * and the host's native context meter sits alone in its dock row.
8945
+ */
8946
+ let viewEntry;
8947
+ let meterEntry;
8948
+ const releaseAuxEntries = () => {
8949
+ try {
8950
+ viewEntry?.();
8951
+ } catch {}
8952
+ viewEntry = void 0;
8953
+ try {
8954
+ meterEntry?.();
8955
+ } catch {}
8956
+ meterEntry = void 0;
8957
+ };
8958
+ const holdAuxEntries = () => {
8959
+ if (viewEntry === void 0) try {
8960
+ viewEntry = ctx.slots.inject("conversation.view", () => ctx.slots.register({
8961
+ name: "conversation.view",
8962
+ id: "acp",
8963
+ order: 20,
8964
+ label: () => label("NTR", "NTR"),
8965
+ inject: (sessionId) => chatFace(sessionId)
8966
+ }, AcpConversationView));
8967
+ } catch {
8968
+ viewEntry = void 0;
8969
+ }
8970
+ if (meterEntry === void 0) try {
8971
+ meterEntry = ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
8972
+ name: "conversation.composer.dock",
8973
+ id: "acp-context-meter",
8974
+ order: 10,
8975
+ inject: (sessionId) => chatFace(sessionId)
8976
+ }, AcpContextMeter));
8977
+ } catch {
8978
+ meterEntry = void 0;
8979
+ }
8980
+ };
8981
+ /**
7924
8982
  * Last diagnostic line from {@link syncAcpMode}. Every way this gate can
7925
8983
  * end with the native seat still on screen used to be silent — an inner
7926
8984
  * `catch { return [] }` and an outer `catch { … }` between them swallowed
@@ -7932,7 +8990,8 @@ window.__ModuleLoader__.load({
7932
8990
  let seatDiag = "idle";
7933
8991
  let permissionSeatDiag = "idle";
7934
8992
  /**
7935
- * Hand BOTH cells back to their shipped occupants.
8993
+ * Hand every ACP-only surface back: both composer cells, the NTR view tab
8994
+ * and the context ring.
7936
8995
  *
7937
8996
  * Every path that is not "this staged conversation is CONFIRMED to run the
7938
8997
  * ACP preset" ends here — including the paths that carry no verdict at all
@@ -7949,6 +9008,8 @@ window.__ModuleLoader__.load({
7949
9008
  seatDiag = reason;
7950
9009
  permissionSeatDiag = `${reason} (released)`;
7951
9010
  disarmVerdictReask();
9011
+ setScopeMarker(false);
9012
+ releaseAuxEntries();
7952
9013
  if (claim !== void 0) {
7953
9014
  claim.release();
7954
9015
  claim = void 0;
@@ -7988,7 +9049,8 @@ window.__ModuleLoader__.load({
7988
9049
  standDown("no session list service");
7989
9050
  return;
7990
9051
  }
7991
- const staged = stagedSessionOf(snapshot);
9052
+ const current = currentScopeSessionId();
9053
+ const staged = stagedSessionOf(snapshot, current);
7992
9054
  if (staged === void 0) {
7993
9055
  standDown("no staged session (nothing retains the main view)");
7994
9056
  return;
@@ -8004,12 +9066,18 @@ window.__ModuleLoader__.load({
8004
9066
  acpModeAsked.set(staged.sessionId, Date.now());
8005
9067
  store.loadChatSessions(staged.sessionId, verdictHeld ? { settleVerdict: true } : void 0);
8006
9068
  } else if (!verdictHeld && askedAt !== void 0) acpModeAsked.delete(staged.sessionId);
8007
- if (!stagedIsAcpMode(snapshot, acpModeVerdict)) {
9069
+ if (!stagedIsAcpMode(snapshot, acpModeVerdict, current)) {
9070
+ if (rowAgentPreset(staged.row) !== void 0) store.noteAcpMode(staged.sessionId, false);
8008
9071
  standDown(`staged ${staged.sessionId} is not in ACP mode`);
8009
9072
  return;
8010
9073
  }
8011
- if (rowAgentPreset(staged.row) !== void 0) disarmVerdictReask();
9074
+ if (rowAgentPreset(staged.row) !== void 0) {
9075
+ store.noteAcpMode(staged.sessionId, rowAgentPreset(staged.row) === "acp");
9076
+ disarmVerdictReask();
9077
+ }
8012
9078
  seatDiag = `staged ${staged.sessionId} is ACP mode`;
9079
+ setScopeMarker(true);
9080
+ probeHostActionLabels();
8013
9081
  claim ??= createModelSeatClaim({
8014
9082
  slots: ctx.slots,
8015
9083
  occupant: () => AcpSessionControls,
@@ -8027,6 +9095,7 @@ window.__ModuleLoader__.load({
8027
9095
  }
8028
9096
  });
8029
9097
  permissionClaim.sync();
9098
+ holdAuxEntries();
8030
9099
  if (rowAgentPreset(staged.row) === void 0) armVerdictReask();
8031
9100
  };
8032
9101
  let stopPreset;
@@ -8048,6 +9117,12 @@ window.__ModuleLoader__.load({
8048
9117
  const attach = () => {
8049
9118
  const list = sessionsList();
8050
9119
  if (list !== void 0 && stopWatch === void 0) stopWatch = list.subscribe(runGate);
9120
+ const current = currentScopeSource();
9121
+ if (current?.subscribe !== void 0 && stopCurrent === void 0) try {
9122
+ stopCurrent = current.subscribe(runGate);
9123
+ } catch {
9124
+ stopCurrent = void 0;
9125
+ }
8051
9126
  attachPresetFrames();
8052
9127
  runGate();
8053
9128
  };
@@ -8100,7 +9175,10 @@ window.__ModuleLoader__.load({
8100
9175
  stopCell();
8101
9176
  stopPreset?.();
8102
9177
  stopWatch?.();
9178
+ stopCurrent?.();
8103
9179
  disarmVerdictReask();
9180
+ setScopeMarker(false);
9181
+ releaseAuxEntries();
8104
9182
  if (claim !== void 0) {
8105
9183
  claim.release();
8106
9184
  claim = void 0;
@@ -8139,9 +9217,9 @@ window.__ModuleLoader__.load({
8139
9217
  const sources = triggers.live?.sources;
8140
9218
  return Array.isArray(sources) ? sources : void 0;
8141
9219
  },
8142
- verdict: (sessionId) => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, (id) => store.getSnapshot().acpMode[id]),
9220
+ verdict: (sessionId) => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, (id) => store.getSnapshot().acpMode[id], currentScopeSessionId()),
8143
9221
  ensureVerdict: async (sessionId) => {
8144
- const held = () => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, (id) => store.getSnapshot().acpMode[id]);
9222
+ const held = () => slashAcpVerdict(sessionsList()?.getSnapshot(), sessionId, (id) => store.getSnapshot().acpMode[id], currentScopeSessionId());
8145
9223
  const known = held();
8146
9224
  if (known !== void 0) return known;
8147
9225
  await store.loadChatSessions(sessionId);
@@ -8204,13 +9282,6 @@ window.__ModuleLoader__.load({
8204
9282
  store.noteSlashDiag("idle");
8205
9283
  };
8206
9284
  }, "dsh-ntr: slash isolation");
8207
- ctx.effect(() => ctx.slots.inject("conversation.view", () => ctx.slots.register({
8208
- name: "conversation.view",
8209
- id: "acp",
8210
- order: 20,
8211
- label: () => "NTR",
8212
- inject: (sessionId) => chatFace(sessionId)
8213
- }, AcpConversationView)), "dsh-ntr: ACP view tab");
8214
9285
  }
8215
9286
  //#endregion
8216
9287
  exports.apply = apply;