dsh-ntr 0.3.0 → 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 (51) 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 +614 -25
  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 +127 -30
  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 +403 -16
  16. package/lib/client.js +1267 -70
  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 +230 -7
  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 +131 -1
  30. package/lib/types/impl.d.ts +41 -1
  31. package/lib/types/protocol.d.ts +130 -0
  32. package/lib/types/update.d.ts +7 -4
  33. package/lib/update.js +25 -10
  34. package/package.json +2 -1
  35. package/src/bridge.ts +645 -27
  36. package/src/client/acp-ui.tsx +123 -2
  37. package/src/client/acp-view.tsx +30 -5
  38. package/src/client/branch-action.tsx +160 -0
  39. package/src/client/composer-mode.tsx +59 -13
  40. package/src/client/context-meter.tsx +128 -0
  41. package/src/client/index.tsx +265 -14
  42. package/src/client/panel.tsx +229 -41
  43. package/src/client/primitives.d.ts +27 -0
  44. package/src/client/released-banner.tsx +97 -0
  45. package/src/client/session-controls.tsx +34 -11
  46. package/src/client/slash-isolation.ts +2 -1
  47. package/src/client/store.ts +463 -20
  48. package/src/impl.ts +84 -2
  49. package/src/index.ts +8 -0
  50. package/src/protocol.ts +129 -0
  51. package/src/update.ts +21 -8
@@ -17,8 +17,9 @@
17
17
 
18
18
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
19
19
  import { formatCommandLine, isAbsolutePathLike, splitCommandLine } from './acp-ui.tsx'
20
- import { DEFAULT_KNOWN_PRESETS } from './store.ts'
20
+ import { DEFAULT_KNOWN_PRESETS, LOG_MAX_ENTRIES } from './store.ts'
21
21
  import type { AcpStore, UiState } from './store.ts'
22
+ import { IDLE_RELEASE_MS } from '../protocol.ts'
22
23
  import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState } from '../protocol.ts'
23
24
 
24
25
  interface WithStore { acp: AcpStore }
@@ -251,6 +252,17 @@ const STYLE = `
251
252
  .dwa-deadline-flag { margin-top: 4px; font-size: var(--ntr-font-xs); font-weight: 550; color: var(--ntr-warn); }
252
253
  .dwa-deadline-warn { margin-top: 10px; padding: 9px 12px; border-left: 3px solid var(--dsw-alias-state-warn-primary, #d97706); background: var(--dsw-alias-state-warn-secondary, #fffbeb); border-radius: var(--ntr-radius-sm); font-size: var(--ntr-font-xs); line-height: 1.55; }
253
254
  .dwa-deadline-reset { display: flex; justify-content: flex-end; margin-top: 8px; }
255
+ /* Runtime log: one bounded list, newest first. The time column never wraps;
256
+ the message is the only cell that may break anywhere. */
257
+ .dwa-log-list { list-style: none; margin: 0; padding: 0; border: var(--ntr-line); border-radius: var(--ntr-radius); overflow: hidden; }
258
+ .dwa-log-row { display: flex; align-items: baseline; gap: 8px; padding: 6px 10px; font-size: var(--ntr-font-sm); border-top: var(--ntr-line); min-width: 0; }
259
+ .dwa-log-row:first-child { border-top: 0; }
260
+ .dwa-log-row.is-error { background: var(--dsw-alias-state-error-tertiary, #fef2f2); }
261
+ .dwa-log-time { color: var(--ntr-muted); white-space: nowrap; }
262
+ .dwa-log-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
263
+ .dwa-log-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ntr-danger); display: inline-block; margin-left: 6px; }
264
+ .dwa-log-level.is-info { background: var(--dsw-alias-state-business-tertiary, #dbeafe); color: var(--dsw-alias-state-business-primary, #2563eb); }
265
+ .dwa-log-level.is-error { background: var(--dsw-alias-state-error-tertiary, #fef2f2); color: var(--ntr-danger); }
254
266
  @media (max-width: 640px) {
255
267
  .dwa-root { padding: 20px 16px; }
256
268
  }
@@ -445,20 +457,14 @@ function partitionAgents(
445
457
  * def (env / npmPackage / exposedModels included); the synthesized fallback
446
458
  * covers only the fields an `AgentView` carries, so anything else compares as
447
459
  * "changed" — the honest answer when the host never reported it.
460
+ *
461
+ * The idle-release field is compared AS STORED: an absent per-agent field
462
+ * means "follow the global window", and the host's resolved value must not be
463
+ * written back into it — that would freeze the global window into this agent's
464
+ * definition and the global slider would then never reach it.
448
465
  */
449
- /**
450
- * The idle-release window in effect for an agent: an unset per-agent field is
451
- * resolved by the HOST and reported back on `AgentView`, so filling it from
452
- * there keeps "unset" and "explicitly the host default" the same state —
453
- * merely opening the form must never read as an unsaved change.
454
- */
455
- function resolvedIdleTimeoutMs(def: AcpAgentDef, activeAgent: AgentView | undefined): AcpAgentDef {
456
- if (def.idleTimeoutMs !== undefined || activeAgent?.idleTimeoutMs === undefined) return def
457
- return { ...def, idleTimeoutMs: activeAgent.idleTimeoutMs }
458
- }
459
-
460
466
  function persistedDef(detected: DetectedAgentInfo, activeAgent: AgentView | undefined): AcpAgentDef | undefined {
461
- if (detected.currentConfig !== undefined) return resolvedIdleTimeoutMs(detected.currentConfig, activeAgent)
467
+ if (detected.currentConfig !== undefined) return detected.currentConfig
462
468
  if (activeAgent === undefined) return undefined
463
469
  return {
464
470
  id: activeAgent.id,
@@ -468,7 +474,6 @@ function persistedDef(detected: DetectedAgentInfo, activeAgent: AgentView | unde
468
474
  ...(activeAgent.cwd === undefined ? {} : { cwd: activeAgent.cwd }),
469
475
  allowedCwdRoots: [...activeAgent.allowedCwdRoots],
470
476
  env: {},
471
- ...(activeAgent.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: activeAgent.idleTimeoutMs }),
472
477
  }
473
478
  }
474
479
 
@@ -490,9 +495,10 @@ export function payloadEquals(payload: AgentSavePayload, current: AcpAgentDef):
490
495
  if (exposureKey(payload.exposedModels) !== exposureKey(current.exposedModels)) return false
491
496
  if (mcpKey(payload.mcpServers) !== mcpKey(current.mcpServers)) return false
492
497
  if ((payload.autoDiscoverModels !== false) !== (current.autoDiscoverModels !== false)) return false
493
- // An absent field means "use the host default" — which is what `current`
494
- // already resolved to (see `resolvedIdleTimeoutMs`), so it is not a change.
495
- if (payload.idleTimeoutMs !== undefined && payload.idleTimeoutMs !== current.idleTimeoutMs) return false
498
+ // The per-agent OVERRIDE, compared as stored: an absent field on both sides
499
+ // means "follow the global window" and is not a change; a field on one
500
+ // side only means the user is adding or dropping an override.
501
+ if (payload.idleTimeoutMs !== current.idleTimeoutMs) return false
496
502
  return true
497
503
  }
498
504
 
@@ -866,7 +872,144 @@ function ApprovalDeadlinesCard(props: { acp: AcpStore; ui: UiState }) {
866
872
  )
867
873
  }
868
874
 
869
- type SettingsPage = 'agents' | 'add' | 'approvals' | 'appearance' | 'diagnostics'
875
+ /**
876
+ * The idle-release window, as the settings page edits it.
877
+ *
878
+ * ONE control, because there is ONE window: how long a chat process may sit
879
+ * idle before it is parked. The slider snaps to the shared ladder
880
+ * ({@link IDLE_RELEASE_MS}), so every rung it offers is a window the host can
881
+ * run.
882
+ *
883
+ * The card reports the deployment baseline as the fact being overridden, and
884
+ * offers a reset ONLY when something is really overridden — the same rule the
885
+ * approval deadline card follows.
886
+ */
887
+ function IdleReleaseCard(props: { acp: AcpStore; ui: UiState }) {
888
+ const { acp, ui } = props
889
+ const release = ui.idleRelease
890
+ // Older host half: no card rather than a number this browser invented.
891
+ if (release === undefined) return null
892
+ // The value a RESET returns to: the window `cordis.patch.yml` names, else
893
+ // the built-in default. The same split the approval card keeps — the
894
+ // reset must restore what the deployment asked for, not a constant.
895
+ const baseline = release.defaultIdleTimeoutMs
896
+ // A reset is only honest when something is actually being overridden.
897
+ const overridden = release.idleTimeoutMs !== baseline
898
+ // A window the deployment named that is not on the ladder still runs as
899
+ // configured: the thumb parks on the nearest rung and the readout prints the
900
+ // exact number, so the card never lies about what is in force.
901
+ const index = IDLE_RELEASE_MS.indexOf(release.idleTimeoutMs)
902
+ const exact = index >= 0
903
+ const shown = exact ? release.idleTimeoutMs : nearestIdleRung(release.idleTimeoutMs)
904
+ // The config file's window is worth saying only when it is being
905
+ // outranked — otherwise it IS what runs, and saying "the value set here
906
+ // wins" would name a win nobody claimed.
907
+ const note = release.configuredIdleTimeoutMs === undefined
908
+ || release.configuredIdleTimeoutMs === release.idleTimeoutMs
909
+ ? undefined
910
+ : acp.label(
911
+ `配置文件(cordis.patch.yml)设为 ${idleWindowText(release.configuredIdleTimeoutMs, acp)},此处设置优先。`,
912
+ `The config file (cordis.patch.yml) asks for ${idleWindowText(release.configuredIdleTimeoutMs, acp)}; the value set here wins.`,
913
+ )
914
+ const resetLabel = release.configuredIdleTimeoutMs === undefined
915
+ ? acp.label('恢复默认值', 'Restore default')
916
+ : acp.label('恢复配置文件的值', 'Use config file value')
917
+ return (
918
+ <section className="dwa-section">
919
+ <div className="dwa-section-heading">
920
+ <div>
921
+ <h3 className="dwa-section-title">{acp.label('空闲释放', 'Idle release')}</h3>
922
+ <p className="dwa-section-copy">
923
+ {acp.label('空闲多久后挂起聊天进程。', 'How long an idle chat process is kept before it is parked.')}
924
+ </p>
925
+ </div>
926
+ </div>
927
+ <div className="dwa-preferences">
928
+ <div className="dwa-preference-block">
929
+ <div className="dwa-preference-label">{acp.label('释放窗口', 'Release window')}</div>
930
+ <div className="dwa-deadline-value">{idleWindowText(release.idleTimeoutMs, acp)}</div>
931
+ <input
932
+ className="dwa-deadline-range"
933
+ type="range"
934
+ min={0}
935
+ max={IDLE_RELEASE_MS.length - 1}
936
+ step={1}
937
+ value={exact ? index : IDLE_RELEASE_MS.indexOf(shown)}
938
+ aria-label={acp.label('释放窗口', 'Release window')}
939
+ onChange={event => {
940
+ const next = IDLE_RELEASE_MS[Number(event.target.value)]
941
+ if (next !== undefined && next !== release.idleTimeoutMs) void acp.setIdleRelease(next)
942
+ }}
943
+ />
944
+ <div className="dwa-deadline-ticks">
945
+ {IDLE_RELEASE_MS.filter((_, i) => i % 2 === 0).map(ms => (
946
+ <span key={ms}>{idleWindowText(ms, acp)}</span>
947
+ ))}
948
+ </div>
949
+ <div className="dwa-preference-copy">
950
+ {acp.label(
951
+ '挂起后会话仍保留,下一条消息自动恢复。',
952
+ 'A parked session is kept; the next message resumes it.',
953
+ )}
954
+ </div>
955
+ {note !== undefined && <div className="dwa-deadline-flag">⚠ {note}</div>}
956
+ </div>
957
+ {overridden && (
958
+ <div className="dwa-deadline-reset">
959
+ <button type="button" className="dwa-btn dwa-btn-secondary" onClick={() => void acp.resetIdleRelease()}>
960
+ {resetLabel}
961
+ </button>
962
+ </div>
963
+ )}
964
+ </div>
965
+ </section>
966
+ )
967
+ }
968
+
969
+ /**
970
+ * The rung nearest a window that is not on the ladder — used only to park the
971
+ * thumb; the readout always prints the exact value.
972
+ */
973
+ function nearestIdleRung(ms: number): number {
974
+ let best = IDLE_RELEASE_MS[0]
975
+ let bestDistance = Number.POSITIVE_INFINITY
976
+ for (const rung of IDLE_RELEASE_MS) {
977
+ const distance = Math.abs(rung - ms)
978
+ if (distance < bestDistance) {
979
+ bestDistance = distance
980
+ best = rung
981
+ }
982
+ }
983
+ return best
984
+ }
985
+
986
+ /**
987
+ * `30 分钟` — the readout and the tick label, one formatter.
988
+ *
989
+ * ALWAYS minutes, even at the top of the ladder: a scale that runs "1 分钟"
990
+ * to "50 分钟" and then "1 小时" reads as two different scales.
991
+ */
992
+ function idleWindowText(ms: number, acp: AcpStore): string {
993
+ // 0 is the deployment's way of saying "never release", and reading it as
994
+ // "0 分钟" would say the opposite.
995
+ if (ms <= 0) return acp.label('永不释放', 'Never')
996
+ const minutes = Math.round(ms / 60_000)
997
+ return acp.label(`${String(minutes)} 分钟`, `${String(minutes)}m`)
998
+ }
999
+
1000
+ type SettingsPage = 'agents' | 'add' | 'approvals' | 'processes' | 'appearance' | 'logs' | 'diagnostics'
1001
+
1002
+ /**
1003
+ * Local wall-clock `HH:MM:SS` for one runtime-log line. Hand-built on purpose:
1004
+ * `toLocaleTimeString` may drop seconds or pick a 12-hour clock depending on
1005
+ * the browser locale, which makes the column ragged and ambiguous.
1006
+ */
1007
+ function logClock(at: number): string {
1008
+ const date = new Date(at)
1009
+ return [date.getHours(), date.getMinutes(), date.getSeconds()]
1010
+ .map(value => String(value).padStart(2, '0'))
1011
+ .join(':')
1012
+ }
870
1013
 
871
1014
  /** The states that carry an update badge next to the version badge. */
872
1015
  function updateBadgeText(update: PluginUpdateState, acp: AcpStore): string | undefined {
@@ -1033,6 +1176,11 @@ export function AcpSettingsSection(props: WithStore) {
1033
1176
  useEffect(() => {
1034
1177
  void acp.checkPluginUpdate()
1035
1178
  }, [acp])
1179
+ // Opening the log is the read receipt. The store does not mutate at zero,
1180
+ // so this cannot loop.
1181
+ useEffect(() => {
1182
+ if (page === 'logs' && ui.logUnread > 0) acp.markLogsRead()
1183
+ }, [acp, page, ui.logUnread])
1036
1184
  const { configured, available } = partitionAgents(ui.detectedAgents, ui.agents)
1037
1185
  const selected = addTarget ?? available[0] ?? NEW_AGENT_SEED
1038
1186
  const openAdd = (): void => {
@@ -1153,7 +1301,12 @@ export function AcpSettingsSection(props: WithStore) {
1153
1301
  <nav className="dwa-tabs" aria-label={acp.label('NTR 设置分类', 'NTR settings categories')}>
1154
1302
  <button type="button" aria-pressed={page === 'agents'} onClick={() => setPage('agents')}>{acp.label('智能体', 'Agents')}</button>
1155
1303
  {ui.approvalDeadlines !== undefined && <button type="button" aria-pressed={page === 'approvals'} onClick={() => setPage('approvals')}>{acp.label('审批时限', 'Approval deadlines')}</button>}
1304
+ {ui.idleRelease !== undefined && <button type="button" aria-pressed={page === 'processes'} onClick={() => setPage('processes')}>{acp.label('进程管理', 'Processes')}</button>}
1156
1305
  <button type="button" aria-pressed={page === 'appearance'} onClick={() => setPage('appearance')}>{acp.label('对话外观', 'Appearance')}</button>
1306
+ <button type="button" aria-pressed={page === 'logs'} onClick={() => setPage('logs')}>
1307
+ {acp.label('日志', 'Logs')}
1308
+ {ui.logUnread > 0 && <span className="dwa-log-dot" aria-hidden="true" />}
1309
+ </button>
1157
1310
  </nav>
1158
1311
  {page === 'diagnostics' && (
1159
1312
  <section className="dwa-section">
@@ -1285,6 +1438,7 @@ export function AcpSettingsSection(props: WithStore) {
1285
1438
  </section>
1286
1439
 
1287
1440
  <div hidden={page !== 'approvals'}><ApprovalDeadlinesCard acp={acp} ui={ui} /></div>
1441
+ <div hidden={page !== 'processes'}><IdleReleaseCard acp={acp} ui={ui} /></div>
1288
1442
 
1289
1443
  <section className="dwa-section" hidden={page !== 'appearance'} aria-label={acp.label('对话外观', 'Appearance')}>
1290
1444
  <div className="dwa-section-heading">
@@ -1334,6 +1488,45 @@ export function AcpSettingsSection(props: WithStore) {
1334
1488
  )}
1335
1489
  </div>
1336
1490
  </section>
1491
+ {/* Conditional on purpose: this list can hold 200 rows, and keeping it
1492
+ mounted through a `hidden` page would re-render every line on each
1493
+ `session/update`. It carries no local state, so remounting is free. */}
1494
+ {page === 'logs' && (
1495
+ <section className="dwa-section" aria-label={acp.label('运行日志', 'Runtime log')}>
1496
+ <div className="dwa-section-heading">
1497
+ <div>
1498
+ <h3 className="dwa-section-title">{acp.label('运行日志', 'Runtime log')}</h3>
1499
+ <p className="dwa-section-copy">
1500
+ {acp.label(
1501
+ `agent 运行消息,最多 ${String(LOG_MAX_ENTRIES)} 条。`,
1502
+ `Agent runtime messages, up to ${String(LOG_MAX_ENTRIES)}.`,
1503
+ )}
1504
+ </p>
1505
+ </div>
1506
+ {ui.logs.length > 0 && (
1507
+ <button type="button" className="dwa-btn dwa-btn-quiet" onClick={() => acp.clearLogs()}>
1508
+ {acp.label('清空', 'Clear')}
1509
+ </button>
1510
+ )}
1511
+ </div>
1512
+ {ui.logs.length === 0
1513
+ ? <div className="dwa-empty" role="status">{acp.label('暂无运行日志。', 'No runtime messages yet.')}</div>
1514
+ : (
1515
+ <ul className="dwa-log-list">
1516
+ {[...ui.logs].reverse().map(entry => (
1517
+ <li key={entry.id} className={entry.level === 'error' ? 'dwa-log-row is-error' : 'dwa-log-row'}>
1518
+ <span className="dwa-log-time dwa-mono">{logClock(entry.at)}</span>
1519
+ <span className={`dwa-badge dwa-log-level ${entry.level === 'error' ? 'is-error' : 'is-info'}`}>
1520
+ {entry.level === 'error' ? acp.label('错误', 'Error') : acp.label('信息', 'Info')}
1521
+ </span>
1522
+ {entry.agentId !== undefined && <span className="dwa-badge">{entry.agentId}</span>}
1523
+ <span className="dwa-log-text">{entry.text}</span>
1524
+ </li>
1525
+ ))}
1526
+ </ul>
1527
+ )}
1528
+ </section>
1529
+ )}
1337
1530
  <footer className="dwa-health">
1338
1531
  <div className="dwa-health-status">
1339
1532
  <span className="dwa-dot" style={{ background: ui.stream === 'live' ? 'var(--ntr-success)' : ui.stream === 'down' ? 'var(--ntr-danger)' : 'var(--ntr-muted)' }} />
@@ -1612,14 +1805,6 @@ interface AgentForm {
1612
1805
  mcpText: string
1613
1806
  }
1614
1807
 
1615
- /**
1616
- * Hand copy of `DEFAULT_TUNING.idleTimeoutMs` (src/bridge.ts): the browser
1617
- * cannot import the host plane, and this number is only ever placeholder text
1618
- * for a NOT-yet-configured agent (a configured one renders the host's resolved
1619
- * value from `AgentView.idleTimeoutMs`). Keep the two in sync.
1620
- */
1621
- const DEFAULT_IDLE_RELEASE_MS = 1_800_000
1622
-
1623
1808
  /**
1624
1809
  * Hand copy of `DEFAULT_TUNING.probeIdleTimeoutMs` (src/bridge.ts). A process
1625
1810
  * that has never hosted a session — the model probe / post-save probe — lives
@@ -1648,8 +1833,11 @@ export function initialForm(detected: DetectedAgentInfo, activeAgent: AgentView
1648
1833
  envRows: Object.entries(config?.env ?? {}).map(([key, value]) => ({ key, value })),
1649
1834
  exposed: config?.exposedModels === undefined ? undefined : [...config.exposedModels],
1650
1835
  autoDiscoverModels: config?.autoDiscoverModels !== false,
1651
- // Prefer the host's resolved value: it is the one actually in effect.
1652
- idleStr: idleText(activeAgent?.idleTimeoutMs ?? config?.idleTimeoutMs),
1836
+ // The agent's OWN override only. The host's resolved value is NOT
1837
+ // written back here: it would freeze the global window into this
1838
+ // agent's definition, and the global slider would then never reach it.
1839
+ // An empty field means "follow the global window".
1840
+ idleStr: idleText(config?.idleTimeoutMs),
1653
1841
  // The MCP list comes back from the host in its NORMALIZED shape (defaults
1654
1842
  // filled in), so re-serializing it reproduces exactly what is stored —
1655
1843
  // that is what keeps the card from being permanently dirty.
@@ -1892,24 +2080,24 @@ function AgentDetails({
1892
2080
  </div>
1893
2081
  </div>
1894
2082
 
1895
- {/* Idle release: scoped to agents with NO live session (see
1896
- AgentBridge.releaseIfIdle), so it reclaims the processes that the
1897
- model probe / post-save probe leave behind without ever costing a
1898
- chat a cold start. TWO windows exist (AgentBridge.currentIdleTimeoutMs):
1899
- this field is the chat window of a process that HAS hosted a session;
1900
- a never-used process (a probe) always gets the short probe window and
1901
- ignores this field — which is why the copy below names both. */}
2083
+ {/* Idle release: an OVERRIDE of the global window (「进程管理」). Empty
2084
+ means "follow the global window", so the global slider reaches every
2085
+ agent that does not pin its own. TWO windows exist
2086
+ (AgentBridge.currentIdleTimeoutMs): this field is the chat window of
2087
+ a process that HAS hosted a session; a never-used process (a probe)
2088
+ always gets the short probe window and ignores this field — which is
2089
+ why the copy below names both. */}
1902
2090
  <div className="dwa-form-row">
1903
2091
  <div className="dwa-form-col">
1904
- <label className="dwa-form-label">{acp.label('空闲释放(毫秒)', 'Idle Release (ms)')}</label>
2092
+ <label className="dwa-form-label">{acp.label('空闲释放覆盖(毫秒)', 'Idle Release Override (ms)')}</label>
1905
2093
  <input
1906
2094
  className="dwa-input dwa-input-mono"
1907
- aria-label={acp.label('空闲释放(毫秒)', 'Idle Release (ms)')}
2095
+ aria-label={acp.label('空闲释放覆盖(毫秒)', 'Idle Release Override (ms)')}
1908
2096
  value={form.idleStr}
1909
2097
  onChange={e => patch({ idleStr: e.target.value })}
1910
2098
  placeholder={acp.label(
1911
- `留空 = 宿主默认(承载会话 ${String(DEFAULT_IDLE_RELEASE_MS / 60_000)} 分钟 / 探测进程 ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} 分钟)`,
1912
- `Blank = host default (${String(DEFAULT_IDLE_RELEASE_MS / 60_000)} min for a chat process / ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} min for a probe)`,
2099
+ `留空 = 跟随全局设置(探测进程固定 ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} 分钟)`,
2100
+ `Blank = follow the global setting (probes always release after ${String(DEFAULT_PROBE_RELEASE_MS / 60_000)} min)`,
1913
2101
  )}
1914
2102
  />
1915
2103
  <span className={idleInvalid ? 'dwa-error' : 'dwa-hint'}>
@@ -1921,8 +2109,8 @@ function AgentDetails({
1921
2109
  : (
1922
2110
  <>
1923
2111
  {acp.label(
1924
- '承载过会话的进程空闲后挂起;0 不释放,探测进程固定 5 分钟。',
1925
- 'Park idle chat processes; 0 keeps them alive. Probe processes always release after 5 minutes.',
2112
+ '仅覆盖此智能体;留空即跟随「进程管理」的全局设置。',
2113
+ 'Overrides this agent only; leave blank to follow the global setting in “Processes”.',
1926
2114
  )}
1927
2115
  </>
1928
2116
  )}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Type shim for `@deepseek-ai/dsh-client-ui-primitives` — a HOST-provided
3
+ * client module (resolved by the shell's lazy CJS module table at runtime, so
4
+ * it is intentionally absent from devDependencies and listed in
5
+ * tsdown.config.mjs's EXTERNAL + package.json `dsh.client.inject`).
6
+ *
7
+ * Only the members this bundle uses are declared, structurally: the host
8
+ * package is not part of a plugin's dependency closure, and the full surface
9
+ * would be a second copy of types this plugin cannot import anyway. The
10
+ * branch icons are the candidates the host's own branch button renders
11
+ * (`MessageIconActions`) across shipped host generations — desktop 0.2.1-rc.2
12
+ * exports `IconBranchOutlineRegular` (and `…Medium`) but NOT `…16`, while
13
+ * newer packages export `…16` — which is why `branch-action.tsx` picks the
14
+ * first present one instead of hard-importing a single name.
15
+ */
16
+ declare module '@deepseek-ai/dsh-client-ui-primitives' {
17
+ import type { ComponentType, ReactNode } from 'react'
18
+
19
+ export const IconBranchOutline16: ComponentType<Record<string, never>> | undefined
20
+ export const IconBranchOutlineRegular: ComponentType<Record<string, never>> | undefined
21
+ export const IconBranchOutlineMedium: ComponentType<Record<string, never>> | undefined
22
+ export const Tooltip: ComponentType<{
23
+ label: ReactNode
24
+ side?: 'top' | 'bottom' | 'left' | 'right'
25
+ children: ReactNode
26
+ }>
27
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The conversation's released-session banner: one row above the composer
3
+ * (`conversation.input.dock`) saying the agent process behind this
4
+ * conversation has been released, with the explicit resume button.
5
+ *
6
+ * WHY A BANNER: releasing a process is deliberately silent (constraint 19 —
7
+ * an idle teardown is the expected lifecycle, no `agent/notice`), and the
8
+ * only other trace was the NTR view tab's status badge — a surface most
9
+ * users never open. The "your next message resumes it" promise is real but
10
+ * unverifiable from the chat itself, so the banner states it here and the
11
+ * button turns it into a gesture (`session/chat-resume`), the explicit form
12
+ * of the same recovery the next prompt performs lazily.
13
+ *
14
+ * The gate is a pure function ({@link releasedBannerVisible}) — the seam the
15
+ * composer's other visibility decisions use, so the rules are testable with
16
+ * no DOM. Like the agent tag and the context ring, an explicit
17
+ * `acpMode === false` outranks a lingering `chatSessions` row: a conversation
18
+ * that left NTR mode must not keep claiming a parked NTR process.
19
+ */
20
+
21
+ import { useEffect, useState } from 'react'
22
+ import { ensureAcpStyle, useUi } from './acp-ui.tsx'
23
+ import type { AcpStore, UiState } from './store.ts'
24
+
25
+ export interface ReleasedBannerProps {
26
+ /** Injected face: the store plus the chat session id this banner belongs to. */
27
+ acp: AcpStore
28
+ sessionId: string
29
+ }
30
+
31
+ /**
32
+ * Whether the banner should render: this conversation's ACP session records
33
+ * are all released — the process is parked (idle release or crash) and
34
+ * nothing is live to serve the next message yet. An empty list (no agent has
35
+ * run here) and a live row anywhere both mean "nothing to recover", so the
36
+ * banner stays out of the way.
37
+ */
38
+ export function releasedBannerVisible(
39
+ ui: Pick<UiState, 'acpMode' | 'chatSessions' | 'chatSessionsFor'>,
40
+ dshSessionId: string,
41
+ ): boolean {
42
+ if (ui.acpMode[dshSessionId] === false) return false
43
+ if (ui.chatSessionsFor !== dshSessionId) return false
44
+ return ui.chatSessions.length > 0 && ui.chatSessions.every(row => row.released === true)
45
+ }
46
+
47
+ export function ReleasedBanner({ acp, sessionId }: ReleasedBannerProps) {
48
+ ensureAcpStyle()
49
+ const ui = useUi(acp)
50
+ const dshSessionId = String(sessionId)
51
+ const [busy, setBusy] = useState(false)
52
+ const [error, setError] = useState<string>()
53
+ // The binding has no announcing frame on load (a conversation opened on an
54
+ // already-parked session shows nothing else that would read the list), so
55
+ // this entry reads it once itself — deduped in flight by the store.
56
+ useEffect(() => {
57
+ void acp.loadChatSessions(dshSessionId)
58
+ }, [acp, dshSessionId])
59
+ if (!releasedBannerVisible(ui, dshSessionId)) return null
60
+ const resume = (): void => {
61
+ if (busy) return
62
+ setBusy(true)
63
+ setError(undefined)
64
+ void acp.resumeChatSession(dshSessionId)
65
+ // `resumeChatSession` reports to the store's notice too, but that notice
66
+ // lives on the settings page; this pins the failure where the user is
67
+ // looking (same contract as the permission card).
68
+ .catch((failure: unknown) => {
69
+ setError(failure instanceof Error ? failure.message : String(failure))
70
+ })
71
+ .finally(() => { setBusy(false) })
72
+ }
73
+ return (
74
+ <div className="ntr-released-banner" role="region" aria-label={acp.label('进程已释放', 'Process released')}>
75
+ <div className="ntr-released-banner-row">
76
+ <div className="ntr-released-banner-text">
77
+ <span className="ntr-released-banner-title">{acp.label('进程已释放', 'Process released')}</span>
78
+ <span className="ntr-released-banner-body">
79
+ {acp.label('上下文已保留;下一条消息将自动恢复,或点击【恢复进程】按钮。', 'Context is kept — your next message resumes it, or click [Resume process].')}
80
+ </span>
81
+ </div>
82
+ <button
83
+ type="button"
84
+ className="ntr-acp-more is-primary"
85
+ disabled={busy}
86
+ title={acp.label('立即拉起 agent 进程并恢复这个会话', 'Bring the agent process back and restore this session now')}
87
+ onClick={resume}
88
+ >
89
+ {busy ? acp.label('正在恢复…', 'Resuming…') : acp.label('恢复进程', 'Resume process')}
90
+ </button>
91
+ </div>
92
+ {error !== undefined && (
93
+ <div className="ntr-permission-error" role="alert">{error}</div>
94
+ )}
95
+ </div>
96
+ )
97
+ }
@@ -295,9 +295,20 @@ export function describeRoute(
295
295
  // pick a conversation set up ahead of its first message could not show the
296
296
  // model and thought level it is about to use, which is the whole point of
297
297
  // choosing them early.
298
+ //
299
+ // A RELEASED row flips the precedence: it only describes what ran when the
300
+ // process was parked, while the pick is what the next resume applies
301
+ // (`applyChatChoice`) — a newer pick must outrank it, or a pre-switch on a
302
+ // parked session looks like a dead control (the chip snaps back to the
303
+ // parked value on every re-read). The parked row still beats nothing, so a
304
+ // conversation that never picked keeps naming what it ran.
298
305
  const pick = choice !== undefined && choice.agentId === agentId ? choice : undefined
299
- const modelId = bound?.modelId ?? pick?.modelId
300
- const effort = bound?.reasoningEffort ?? pick?.effort
306
+ const modelId = bound?.released === true
307
+ ? (pick?.modelId ?? bound?.modelId)
308
+ : (bound?.modelId ?? pick?.modelId)
309
+ const effort = bound?.released === true
310
+ ? (pick?.effort ?? bound?.reasoningEffort)
311
+ : (bound?.reasoningEffort ?? pick?.effort)
301
312
  const reasoning = catalog?.reasoning
302
313
  // The model the AGENT says it is on, for a conversation nobody has picked a
303
314
  // model for yet. Without it the chip has no label and does not render at all,
@@ -554,6 +565,16 @@ export function AcpSessionControls(props: AcpClusterProps) {
554
565
  useEffect(() => {
555
566
  if (served && agentListed && agentId !== undefined) void acp.loadAgentCatalog(agentId)
556
567
  }, [acp, served, agentListed, agentId])
568
+ // The picker could already be open when the first turn starts (clicking the
569
+ // send button is inside the composer card, which AcpMenu treats as its own
570
+ // furniture, so it does not close the menu). Once locked, that menu can only
571
+ // offer a switch the host will refuse — close it instead of leaving a live
572
+ // control over a locked conversation. Kept above every early return so the
573
+ // component never changes its hook order between renders.
574
+ useEffect(() => {
575
+ if (lockedTo === undefined || menu === null) return
576
+ if (menu.sessionId === sessionId && menu.kind === 'agent') closeAcpMenu()
577
+ }, [lockedTo, menu?.sessionId, menu?.kind, sessionId])
557
578
  const route = describeRoute(
558
579
  agents,
559
580
  bound,
@@ -579,15 +600,14 @@ export function AcpSessionControls(props: AcpClusterProps) {
579
600
  // base never comes back, and the tag is the only place the agent's name
580
601
  // lives.
581
602
  //
582
- // The picker could already be open when the first turn starts (clicking the
583
- // send button is inside the composer card, which AcpMenu treats as its own
584
- // furniture, so it does not close the menu). Once locked, that menu can only
585
- // offer a switch the host will refuse — close it instead of leaving a live
586
- // control over a locked conversation.
587
- useEffect(() => {
588
- if (lockedTo === undefined || menu === null) return
589
- if (menu.sessionId === sessionId && menu.kind === 'agent') closeAcpMenu()
590
- }, [lockedTo, menu?.sessionId, menu?.kind, sessionId])
603
+ // The gate releases this cell when the conversation leaves ACP mode, and it
604
+ // only ever installs the claim for a session whose verdict it has already
605
+ // folded as TRUE — so the occupant requires that same affirmative fact. A
606
+ // claim held for one more evaluation (or a layout with more than one visible
607
+ // session) must never put NTR chips on an ordinary conversation's bar.
608
+ // Placed AFTER every hook: an early return above a hook would change the
609
+ // hook order between renders.
610
+ if (ui.acpMode[sessionId] !== true) return null
591
611
  // Keep the base mounted for the shatter half even though the lock already
592
612
  // retired it: unmounting on the same commit the lock arrives would skip the
593
613
  // transition entirely (the tag waits for the same phase to leave 'shatter').
@@ -1008,6 +1028,9 @@ export function AcpSessionMenu(props: AcpClusterProps) {
1008
1028
  }, [acp, menu, served, agentId])
1009
1029
 
1010
1030
  if (menu === null || menu.sessionId !== sessionId) return null
1031
+ // A stale picker must not outlive the conversation's ACP mode (the gate
1032
+ // releases the view/tab, but the overlay is unconditionally registered).
1033
+ if (ui.acpMode[sessionId] !== true) return null
1011
1034
  const route = describeRoute(
1012
1035
  agents,
1013
1036
  bound,
@@ -131,8 +131,9 @@ export function slashAcpVerdict(
131
131
  snapshot: AcpSessionsSnapshot | undefined,
132
132
  sessionId: string,
133
133
  verdict: (sessionId: string) => boolean | undefined,
134
+ currentSessionId?: string,
134
135
  ): boolean | undefined {
135
- const staged = stagedSessionOf(snapshot)
136
+ const staged = stagedSessionOf(snapshot, currentSessionId)
136
137
  if (staged !== undefined && staged.sessionId === sessionId) {
137
138
  const listed = rowAgentPreset(staged.row)
138
139
  if (listed !== undefined) return listed === ACP_PRESET_ID