dsh-ntr 0.2.1 → 0.3.0

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.
@@ -19,7 +19,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
19
19
  import { formatCommandLine, isAbsolutePathLike, splitCommandLine } from './acp-ui.tsx'
20
20
  import { DEFAULT_KNOWN_PRESETS } from './store.ts'
21
21
  import type { AcpStore, UiState } from './store.ts'
22
- import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode } from '../protocol.ts'
22
+ import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState } from '../protocol.ts'
23
23
 
24
24
  interface WithStore { acp: AcpStore }
25
25
 
@@ -83,7 +83,7 @@ const STYLE = `
83
83
  }
84
84
  .dwa-page-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; }
85
85
  .dwa-page-heading { min-width: 0; }
86
- .dwa-page-title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 550; letter-spacing: -0.02em; }
86
+ .dwa-page-title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 550; letter-spacing: -0.02em; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
87
87
  .dwa-page-description { margin: 5px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-sm); }
88
88
  .dwa-btn {
89
89
  min-height: 32px; box-sizing: border-box; font: inherit; border-radius: var(--ntr-radius-sm);
@@ -115,6 +115,20 @@ const STYLE = `
115
115
  .dwa-badge-success { background: var(--dsw-alias-state-success-tertiary, #dcfce7); color: var(--ntr-success); }
116
116
  .dwa-badge-warn { background: var(--dsw-alias-state-warn-tertiary, #fef3c7); color: var(--ntr-warn); }
117
117
  .dwa-badge-info { background: var(--dsw-alias-state-business-tertiary, #dbeafe); color: var(--dsw-alias-state-business-primary, #2563eb); }
118
+ /* The update badge is a button (it expands the update card): strip the
119
+ browser's default button chrome, keep the badge look. */
120
+ .dwa-update-badge { border: 0; font-family: inherit; cursor: pointer; }
121
+ .dwa-update-badge:hover { opacity: 0.85; }
122
+ /* The version badge doubles as the update entry: same badge look, no chrome. */
123
+ .dwa-version-btn { border: 0; font-family: inherit; cursor: pointer; }
124
+ .dwa-version-btn:hover { opacity: 0.85; }
125
+ .dwa-version { font-family: var(--ds-font-family-code, monospace); }
126
+ /* The update card sits between the page header and the permission mirror. */
127
+ .dwa-update-card { margin-bottom: 16px; }
128
+ .dwa-update-summary { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-weight: 550; }
129
+ .dwa-update-copy { margin: 6px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
130
+ .dwa-update-message { margin: 6px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); white-space: pre-wrap; overflow-wrap: anywhere; }
131
+ .dwa-update-actions { margin-top: 10px; }
118
132
  .dwa-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
119
133
  .dwa-agent-list { display: flex; flex-direction: column; border-top: var(--ntr-line); }
120
134
  .dwa-agent-card { border-bottom: var(--ntr-line); min-width: 0; }
@@ -854,6 +868,157 @@ function ApprovalDeadlinesCard(props: { acp: AcpStore; ui: UiState }) {
854
868
 
855
869
  type SettingsPage = 'agents' | 'add' | 'approvals' | 'appearance' | 'diagnostics'
856
870
 
871
+ /** The states that carry an update badge next to the version badge. */
872
+ function updateBadgeText(update: PluginUpdateState, acp: AcpStore): string | undefined {
873
+ if (update.status === 'available') {
874
+ return acp.label(`可更新 v${update.latest ?? update.current}`, `Update v${update.latest ?? update.current}`)
875
+ }
876
+ if (update.status === 'applying') return acp.label('更新中…', 'Updating…')
877
+ if (update.status === 'applied') {
878
+ const installed = update.installed ?? update.latest ?? update.current
879
+ return acp.label(`已更新 v${installed}`, `Updated v${installed}`)
880
+ }
881
+ if (update.status === 'restart-required') {
882
+ // Only a KNOWN disk version can be named. An interrupted install leaves
883
+ // `installed` unset, and a badge would have to invent a version.
884
+ if (update.installed === undefined) return undefined
885
+ // Neutral: the disk version can be newer OR older than the running one.
886
+ return acp.label(`待重启 v${update.installed}`, `Restart for v${update.installed}`)
887
+ }
888
+ return undefined
889
+ }
890
+
891
+ /**
892
+ * The version badge's update companion. Every badge is a button, `applying`
893
+ * included: the card is where the install's outcome is read, so the running
894
+ * install must be able to open it too. States without anything to act on get
895
+ * no badge at all — the version badge itself still opens the card.
896
+ */
897
+ function PluginUpdateBadge(props: { acp: AcpStore; update: PluginUpdateState; open: boolean; onToggle: () => void }) {
898
+ const { acp, update, open, onToggle } = props
899
+ const text = updateBadgeText(update, acp)
900
+ if (text === undefined) return null
901
+ return (
902
+ <button
903
+ type="button"
904
+ className="dwa-badge dwa-badge-info dwa-update-badge"
905
+ aria-expanded={open}
906
+ aria-controls="ntr-plugin-update-card"
907
+ onClick={onToggle}
908
+ >
909
+ {text}
910
+ </button>
911
+ )
912
+ }
913
+
914
+ /**
915
+ * The update card. In-page, no dialog: the settings page already owns the one
916
+ * action (install the exact version the host reported), and a modal on top of
917
+ * a settings section would hide the version it is updating.
918
+ *
919
+ * The copy stays to one line per state (constraint 33); the host's raw error
920
+ * (`message`) is the only long text, and it wraps on its own line so a stack
921
+ * trace or a registry error is readable without stretching the card.
922
+ */
923
+ function PluginUpdateCard(props: { acp: AcpStore; update: PluginUpdateState; onClose: () => void }) {
924
+ const { acp, update, onClose } = props
925
+ const applying = update.status === 'applying'
926
+ const latest = update.latest ?? update.current
927
+ const onDisk = update.installed !== undefined && update.installed !== update.current
928
+ ? update.installed
929
+ : undefined
930
+ const published = update.publishedAt !== undefined && !Number.isNaN(new Date(update.publishedAt).getTime())
931
+ ? new Date(update.publishedAt).toLocaleDateString()
932
+ : undefined
933
+ const copy = update.status === 'available'
934
+ ? acp.label('更新会卸载并重装插件,之后可能需要重启 DSH。', 'Updating uninstalls and reinstalls the plugin, and may require restarting DSH.')
935
+ : update.status === 'applied'
936
+ ? acp.label('已应用,刷新页面即可使用新版本。', 'Applied. Reload the page to use the new version.')
937
+ : update.status === 'restart-required'
938
+ ? update.installed !== undefined
939
+ ? acp.label(
940
+ `磁盘已是 v${update.installed},重启 DSH 后生效(桌面 App 退出重开;命令行重启 dsh web)。`,
941
+ `On disk v${update.installed}. Restart DSH to apply (quit and reopen the app, or restart dsh web).`,
942
+ )
943
+ : acp.label(
944
+ '更新可能已完成,请刷新页面确认当前版本。',
945
+ 'The update may already be complete. Refresh the page to confirm the current version.',
946
+ )
947
+ : update.status === 'busy'
948
+ ? acp.label('有进行中的对话,结束后才能更新。', 'Finish the running conversation before updating.')
949
+ : update.status === 'failed'
950
+ ? acp.label('更新失败。', 'Update failed.')
951
+ : update.status === 'current'
952
+ ? acp.label('已是最新版本。', 'Up to date.')
953
+ : update.status === 'source-install'
954
+ ? acp.label(
955
+ '源码或本地安装,无法通过 registry 更新。',
956
+ 'Source or local install; update it through its own checkout.',
957
+ )
958
+ : update.status === 'managed'
959
+ ? acp.label('此安装由宿主管理,不能在这里更新。', 'This install is managed by the host and cannot be updated here.')
960
+ : undefined
961
+ const showMessage = (update.status === 'failed' || update.status === 'busy' || update.status === 'managed')
962
+ && update.message !== undefined
963
+ const apply = async (): Promise<void> => {
964
+ if (update.latest === undefined) return
965
+ try {
966
+ await acp.applyPluginUpdate(update.latest)
967
+ } catch {
968
+ // The store keeps the "disk may have changed" state and writes its own notice.
969
+ }
970
+ }
971
+ return (
972
+ <div
973
+ id="ntr-plugin-update-card"
974
+ className="dwa-card dwa-update-card"
975
+ role="group"
976
+ aria-label={acp.label('插件更新', 'Plugin update')}
977
+ >
978
+ <div className="dwa-update-summary">
979
+ <span>{acp.label(`当前 v${update.current}`, `Current v${update.current}`)}</span>
980
+ {onDisk !== undefined && <span className="dwa-hint">{acp.label(`磁盘 v${onDisk}`, `On disk v${onDisk}`)}</span>}
981
+ {update.latest !== undefined && (
982
+ <>
983
+ <span aria-hidden="true">→</span>
984
+ <span>{acp.label(`最新 v${latest}`, `Latest v${latest}`)}</span>
985
+ {published !== undefined && <span className="dwa-hint">{published}</span>}
986
+ </>
987
+ )}
988
+ </div>
989
+ {copy !== undefined && <p className="dwa-update-copy">{copy}</p>}
990
+ {showMessage && <div className="dwa-update-message">{update.message}</div>}
991
+ <div className="dwa-row-actions dwa-update-actions">
992
+ {update.status === 'available' && (
993
+ <button type="button" className="dwa-btn dwa-btn-primary" onClick={() => { void apply() }}>
994
+ {acp.label('更新', 'Update')}
995
+ </button>
996
+ )}
997
+ {applying && (
998
+ <button type="button" className="dwa-btn dwa-btn-primary" disabled>
999
+ {acp.label('正在更新…', 'Updating…')}
1000
+ </button>
1001
+ )}
1002
+ {update.status !== 'applying' && (
1003
+ <button type="button" className="dwa-btn" onClick={onClose}>{acp.label('关闭', 'Close')}</button>
1004
+ )}
1005
+ <button type="button" className="dwa-btn dwa-btn-quiet" disabled={applying} onClick={() => void acp.checkPluginUpdate(true)}>
1006
+ {acp.label('重新检查', 'Check again')}
1007
+ </button>
1008
+ </div>
1009
+ </div>
1010
+ )
1011
+ }
1012
+
1013
+ /**
1014
+ * The card is drawn for every state a host can report about this install,
1015
+ * except the two where there is no install to talk about at all.
1016
+ */
1017
+ function updateCardVisible(update: PluginUpdateState | undefined, open: boolean): boolean {
1018
+ if (update === undefined || !open) return false
1019
+ return update.status !== 'unsupported' && update.status !== 'not-installed'
1020
+ }
1021
+
857
1022
  export function AcpSettingsSection(props: WithStore) {
858
1023
  const acp = props.acp
859
1024
  ensureStyle()
@@ -862,6 +1027,12 @@ export function AcpSettingsSection(props: WithStore) {
862
1027
  const [page, setPage] = useState<SettingsPage>('agents')
863
1028
  const [addOpen, setAddOpen] = useState(false)
864
1029
  const [addTarget, setAddTarget] = useState<DetectedAgentInfo | undefined>(undefined)
1030
+ const [updateOpen, setUpdateOpen] = useState(false)
1031
+ // The store throttles this to one registry read per ten minutes, so every
1032
+ // remount (both settings entries mount this section) is cheap.
1033
+ useEffect(() => {
1034
+ void acp.checkPluginUpdate()
1035
+ }, [acp])
865
1036
  const { configured, available } = partitionAgents(ui.detectedAgents, ui.agents)
866
1037
  const selected = addTarget ?? available[0] ?? NEW_AGENT_SEED
867
1038
  const openAdd = (): void => {
@@ -869,12 +1040,49 @@ export function AcpSettingsSection(props: WithStore) {
869
1040
  setPage('add')
870
1041
  }
871
1042
  const presetProblem = ui.preset !== undefined && (!ui.preset.installed || ui.preset.problem !== undefined)
1043
+ const update = ui.pluginUpdate
1044
+ // A source install cannot be replaced by this browser; the version badge
1045
+ // says so on hover. Neutral wording: a checkout may be git or not.
1046
+ const versionTitle = update?.status === 'source-install'
1047
+ ? acp.label('源码或本地安装,无法通过 registry 更新。', 'Source or local install; update it through its own checkout.')
1048
+ : undefined
1049
+ // The running version is the update entry: every state with a real install
1050
+ // report can open the card, including "up to date" (which offers a re-check).
1051
+ const versionOpensUpdate = update !== undefined
1052
+ && update.status !== 'unsupported'
1053
+ && update.status !== 'not-installed'
872
1054
 
873
1055
  return (
874
1056
  <div className="dwa-root">
875
1057
  <header className="dwa-page-header">
876
1058
  <div className="dwa-page-heading">
877
- <h2 className="dwa-page-title">{acp.label('NTR 设置', 'NTR settings')}</h2>
1059
+ <h2 className="dwa-page-title" aria-label={acp.label('NTR 设置', 'NTR settings')}>
1060
+ {acp.label('NTR 设置', 'NTR settings')}
1061
+ {ui.pluginVersion !== undefined && (
1062
+ versionOpensUpdate
1063
+ ? (
1064
+ <button
1065
+ type="button"
1066
+ className="dwa-badge dwa-version dwa-version-btn"
1067
+ title={versionTitle}
1068
+ aria-expanded={updateOpen}
1069
+ aria-controls="ntr-plugin-update-card"
1070
+ onClick={() => setUpdateOpen(open => !open)}
1071
+ >
1072
+ {`v${ui.pluginVersion}`}
1073
+ </button>
1074
+ )
1075
+ : <span className="dwa-badge dwa-version" title={versionTitle}>{`v${ui.pluginVersion}`}</span>
1076
+ )}
1077
+ {update !== undefined && (
1078
+ <PluginUpdateBadge
1079
+ acp={acp}
1080
+ update={update}
1081
+ open={updateOpen}
1082
+ onToggle={() => setUpdateOpen(open => !open)}
1083
+ />
1084
+ )}
1085
+ </h2>
878
1086
  <p className="dwa-page-description">
879
1087
  {acp.label('连接智能体,设置你的对话偏好。', 'Connect agents and set your conversation preferences.')}
880
1088
  </p>
@@ -886,6 +1094,10 @@ export function AcpSettingsSection(props: WithStore) {
886
1094
  )}
887
1095
  </header>
888
1096
 
1097
+ {update !== undefined && updateCardVisible(update, updateOpen) && (
1098
+ <PluginUpdateCard acp={acp} update={update} onClose={() => setUpdateOpen(false)} />
1099
+ )}
1100
+
889
1101
  {/* The cross-session mirror: the conversation dock answers the asks
890
1102
  belonging to the open conversation; this card answers the rest. */}
891
1103
  {ledger.visible && (
@@ -6,7 +6,7 @@
6
6
  * Deliberately framework-free: React binds through `useSyncExternalStore`.
7
7
  */
8
8
 
9
- import type { AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentSavePayload, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, ApprovalDeadlinesSetPayload, ApprovalDeadlinesResult, DetectedAgentInfo, PermissionTimeoutMode, SessionView } from '../protocol.ts'
9
+ import type { AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentSavePayload, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, ApprovalDeadlinesSetPayload, ApprovalDeadlinesResult, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState, PluginVersionResult, SessionView } from '../protocol.ts'
10
10
  import { DEFAULT_TAG_HALO } from '../protocol.ts'
11
11
 
12
12
  /** One slash command the ACP agent declared, whole row (the menu draws name, description and `input.hint`). */
@@ -298,6 +298,18 @@ export interface UiState {
298
298
  * (`preset/status` answering `null`), in which case the panel hides the block.
299
299
  */
300
300
  preset?: AcpPresetStatus
301
+ /**
302
+ * The installed host package's version (`plugin/version`), rendered as the
303
+ * settings page badge. Absent on a host half without the endpoint, which
304
+ * hides the badge rather than naming a version this browser invented.
305
+ */
306
+ pluginVersion?: string
307
+ /**
308
+ * The running version and its update status (`plugin/update-check`). Absent
309
+ * on a host half without the endpoint, which hides the update entry instead
310
+ * of offering an install this browser cannot perform.
311
+ */
312
+ pluginUpdate?: PluginUpdateState
301
313
  }
302
314
 
303
315
  interface Listener { (): void }
@@ -309,6 +321,13 @@ const MAX_WATCHED = 6
309
321
  /** Raw JSON budget for one event block; the cut is marked. */
310
322
  const RAW_MAX = 256_000
311
323
 
324
+ /**
325
+ * How long a fetched update state stays fresh. The settings panel asks on every
326
+ * mount; without this, every open would hit the registry. A forced check (the
327
+ * card's own button) always goes out.
328
+ */
329
+ const PLUGIN_UPDATE_TTL_MS = 10 * 60 * 1000
330
+
312
331
  /** Pretty-printed JSON of an agent message (never throws, cuts only at {@link RAW_MAX} with a marker). */
313
332
  export function jsonText(value: unknown): string {
314
333
  let text: string
@@ -531,6 +550,14 @@ export const DEFAULT_KNOWN_PRESETS: DetectedAgentInfo[] = [
531
550
  defaultCommand: 'opencode',
532
551
  defaultArgs: ['acp'],
533
552
  },
553
+ {
554
+ id: 'cursor',
555
+ name: 'Cursor CLI',
556
+ description: 'Cursor CLI — run as `agent acp` over stdio (legacy alias `cursor-agent acp`)',
557
+ detected: false,
558
+ defaultCommand: 'agent',
559
+ defaultArgs: ['acp'],
560
+ },
534
561
  {
535
562
  id: 'qwen',
536
563
  name: 'Qwen Code',
@@ -646,6 +673,22 @@ export class AcpStore {
646
673
  private readonly cachedModelReads = new Set<string>()
647
674
  /** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
648
675
  private detectCounts: { total: number; installed: number } | undefined
676
+ /** When {@link UiState.pluginUpdate} was last read from the host (epoch ms). */
677
+ private pluginUpdateFetchedAt = 0
678
+ /**
679
+ * The shared in-flight `plugin/update-check`. The settings section mounts
680
+ * twice (both entries) and the update popover asks too; without this, two
681
+ * mounts in the same tick each fire their own registry read.
682
+ */
683
+ private pluginUpdateInflight?: Promise<PluginUpdateState | undefined>
684
+ /** Re-entry guard for `plugin/update-apply` (a double click is one install). */
685
+ private pluginUpdateApplying = false
686
+ /**
687
+ * Update read generation. An apply bumps it so a check that was already in
688
+ * flight (started before the install) cannot land afterwards and overwrite
689
+ * the install's verdict with the pre-install `available`.
690
+ */
691
+ private pluginUpdateGeneration = 0
649
692
 
650
693
  constructor(private readonly rpc: RpcCall, private readonly locale: (zh: string, en: string) => string) {}
651
694
 
@@ -783,7 +826,7 @@ export class AcpStore {
783
826
  /** Snapshot refresh used on panel open and after a stream outage. */
784
827
  async resync(): Promise<void> {
785
828
  try {
786
- const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
829
+ const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
787
830
  this.call<{ agents: AgentView[] }>('agents/list'),
788
831
  this.call<{ sessions: SessionView[] }>('sessions/list'),
789
832
  // The plugin's OWN default ACP agent — a plugin-private id, not a DSH
@@ -797,6 +840,9 @@ export class AcpStore {
797
840
  // `undefined` (older half) hides the deadline card instead of showing a
798
841
  // number this browser made up.
799
842
  this.call<ApprovalDeadlinesResult>('ui/approval-deadlines').catch(() => ({ deadlines: undefined })),
843
+ // The settings badge's data source; older halves answer `unknown
844
+ // endpoint`, which hides the badge.
845
+ this.call<PluginVersionResult>('plugin/version').catch(() => null),
800
846
  ])
801
847
  const detectedAgents = (detectResult && detectResult.agents.length > 0)
802
848
  ? detectResult.agents
@@ -811,6 +857,7 @@ export class AcpStore {
811
857
  preset: presetResult?.preset ?? undefined,
812
858
  tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
813
859
  approvalDeadlines: deadlinesResult.deadlines,
860
+ pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
814
861
  })
815
862
  const active = this.state.activeSessionId ?? sessions[0]?.sessionId
816
863
  if (active !== undefined) await this.loadTranscript(active)
@@ -819,6 +866,103 @@ export class AcpStore {
819
866
  }
820
867
  }
821
868
 
869
+ /**
870
+ * Read the plugin's update state (`plugin/update-check`). Throttled to ten
871
+ * minutes unless `force`: the settings panel asks on mount, and every
872
+ * remount must not hit the registry.
873
+ *
874
+ * An older host serves no such endpoint (`unknown endpoint`). That is a
875
+ * normal degradation, not an error worth a notice: the answer is `undefined`
876
+ * and the panel simply keeps its update entry hidden.
877
+ */
878
+ async checkPluginUpdate(force = false): Promise<PluginUpdateState | undefined> {
879
+ // One read for every caller while it is in flight, force included: the
880
+ // settings page and the popover mounting together must not hit the
881
+ // registry twice.
882
+ if (this.pluginUpdateInflight !== undefined) return this.pluginUpdateInflight
883
+ const current = this.state.pluginUpdate
884
+ if (!force && current !== undefined && Date.now() - this.pluginUpdateFetchedAt < PLUGIN_UPDATE_TTL_MS) {
885
+ return current
886
+ }
887
+ const generation = this.pluginUpdateGeneration
888
+ const inflight = this.readPluginUpdate(generation)
889
+ this.pluginUpdateInflight = inflight
890
+ try {
891
+ return await inflight
892
+ } finally {
893
+ this.pluginUpdateInflight = undefined
894
+ }
895
+ }
896
+
897
+ private async readPluginUpdate(generation: number): Promise<PluginUpdateState | undefined> {
898
+ try {
899
+ const pluginUpdate = await this.call<PluginUpdateState>('plugin/update-check')
900
+ // An install started while this read was in flight: its answer is
901
+ // already stale, and the install's verdict owns the state now.
902
+ if (generation !== this.pluginUpdateGeneration) return undefined
903
+ this.pluginUpdateFetchedAt = Date.now()
904
+ this.mutate({ pluginUpdate })
905
+ return pluginUpdate
906
+ } catch {
907
+ return undefined
908
+ }
909
+ }
910
+
911
+ /**
912
+ * Install one exact version (`plugin/update-apply`). The status flips to
913
+ * `applying` first so the badge answers the click; the host's answer then
914
+ * becomes the state.
915
+ *
916
+ * A failed call does NOT prove the install did not happen: on an HMR host
917
+ * the update uninstalls the plugin before reinstalling it, which can cut the
918
+ * response. Whatever the error, the disk may already carry the new version,
919
+ * so the state becomes `restart-required` (the one neutral verdict for "the
920
+ * running code is not the disk's") with `installed` dropped (unknown, not a
921
+ * fact to display) and the notice asks for a reload rather than restoring
922
+ * `available` and pretending nothing changed.
923
+ */
924
+ async applyPluginUpdate(version: string): Promise<PluginUpdateState | undefined> {
925
+ if (this.pluginUpdateApplying) return undefined
926
+ this.pluginUpdateApplying = true
927
+ // Any check already in flight predates the install; its answer must not
928
+ // land on top of the install's verdict.
929
+ this.pluginUpdateGeneration += 1
930
+ const previous = this.state.pluginUpdate
931
+ if (previous !== undefined) {
932
+ this.mutate({ pluginUpdate: { ...previous, status: 'applying' } })
933
+ }
934
+ try {
935
+ const pluginUpdate = await this.call<PluginUpdateState>('plugin/update-apply', { version })
936
+ this.pluginUpdateFetchedAt = Date.now()
937
+ this.mutate({ pluginUpdate })
938
+ return pluginUpdate
939
+ } catch (error) {
940
+ const message = error instanceof Error ? error.message : String(error)
941
+ // The disk state is UNKNOWN here: the install may have completed before
942
+ // the response was cut. Keep `latest` for display, drop `installed` so no
943
+ // surface can claim a disk version we never read, and reset the throttle
944
+ // so the next check actually asks the host instead of serving this
945
+ // unknown verdict for ten minutes.
946
+ const interrupted: PluginUpdateState = previous !== undefined
947
+ ? { ...previous, status: 'restart-required', installed: undefined, message }
948
+ : { status: 'restart-required', current: this.state.pluginVersion ?? '?', message }
949
+ this.pluginUpdateFetchedAt = 0
950
+ this.mutate({
951
+ pluginUpdate: interrupted,
952
+ notice: this.locale(
953
+ '更新请求已中断;请刷新页面确认当前版本。',
954
+ 'The update request was interrupted. Reload the page to confirm the installed version.',
955
+ ),
956
+ })
957
+ return undefined
958
+ } finally {
959
+ // Invalidate reads that STARTED during the install: their "applying"
960
+ // answer would otherwise land on top of this call's final verdict.
961
+ this.pluginUpdateGeneration += 1
962
+ this.pluginUpdateApplying = false
963
+ }
964
+ }
965
+
822
966
  async detectAgents(): Promise<void> {
823
967
  this.mutate({ detecting: true })
824
968
  try {
package/src/impl.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { randomUUID } from './ids.ts'
9
9
  import type {
10
10
  AcpAgentCatalog, AcpAgentDef, AcpCatalogOption, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPluginConfig, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult,
11
- AgentView, ApprovalDeadlineKey, ApprovalDeadlinePatch, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, PermissionOptionView, PermissionOutcome, PermissionTimeoutMode, RpcResult,
11
+ AgentView, ApprovalDeadlineKey, ApprovalDeadlinePatch, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, PermissionOptionView, PermissionOutcome, PermissionTimeoutMode, PluginUpdateState, RpcResult,
12
12
  } from './protocol.ts'
13
13
  import { DEFAULT_TAG_HALO } from './protocol.ts'
14
14
 
@@ -535,6 +535,22 @@ export function validateConfig(raw: unknown): AcpPluginConfig {
535
535
  if (typeof staleCancelMaxRetries !== 'number' || !Number.isInteger(staleCancelMaxRetries) || staleCancelMaxRetries < 0 || staleCancelMaxRetries > 5) {
536
536
  throw new AcpError('bad_config', 'acp: staleCancelMaxRetries must be an integer in [0, 5]')
537
537
  }
538
+ // Stall-recovery windows (inactivity watchdog). Two lengths on purpose: a
539
+ // silent turn with the floor is a stuck agent, but a turn waiting on a TOOL
540
+ // call is a working agent, and the tool half may take as long as the guard's
541
+ // own tool-output wait. `0` means "off" / "fall back", never "expire now".
542
+ const turnStallMs = value.turnStallMs ?? 600_000
543
+ if (typeof turnStallMs !== 'number' || !Number.isInteger(turnStallMs) || turnStallMs < 0 || turnStallMs > 86_400_000) {
544
+ throw new AcpError('bad_config', 'acp: turnStallMs must be an integer in [0, 86400000] (0 = disable the watchdog)')
545
+ }
546
+ const turnStallToolMs = value.turnStallToolMs ?? 1_800_000
547
+ if (typeof turnStallToolMs !== 'number' || !Number.isInteger(turnStallToolMs) || turnStallToolMs < 0 || turnStallToolMs > 86_400_000) {
548
+ throw new AcpError('bad_config', 'acp: turnStallToolMs must be an integer in [0, 86400000] (0 = fall back to turnStallMs)')
549
+ }
550
+ const turnStallTickMs = value.turnStallTickMs ?? 30_000
551
+ if (typeof turnStallTickMs !== 'number' || !Number.isInteger(turnStallTickMs) || turnStallTickMs < 1 || turnStallTickMs > 60_000) {
552
+ throw new AcpError('bad_config', 'acp: turnStallTickMs must be an integer in [1, 60000]')
553
+ }
538
554
  return {
539
555
  agents,
540
556
  transcriptMaxEntries: max,
@@ -545,6 +561,9 @@ export function validateConfig(raw: unknown): AcpPluginConfig {
545
561
  cancelFallbackMs,
546
562
  staleCancelRetryDelayMs,
547
563
  staleCancelMaxRetries,
564
+ turnStallMs,
565
+ turnStallToolMs,
566
+ turnStallTickMs,
548
567
  }
549
568
  }
550
569
 
@@ -1649,6 +1668,19 @@ export class PermissionLedger {
1649
1668
  return [...this.pending.values()].map(e => ({ requestId: e.requestId, sessionId: e.sessionId }))
1650
1669
  }
1651
1670
 
1671
+ /**
1672
+ * Whether ANY outstanding ask belongs to `sessionId`, a HOST session id
1673
+ * (`LiveSession.sessionId`) — NOT the ACP protocol session id. Used by the
1674
+ * stall watchdog: a turn blocked on a human is not a silent turn, so the
1675
+ * session with a pending permission must not be finalized as stalled.
1676
+ */
1677
+ hasPendingFor(sessionId: string): boolean {
1678
+ for (const entry of this.pending.values()) {
1679
+ if (entry.sessionId === sessionId) return true
1680
+ }
1681
+ return false
1682
+ }
1683
+
1652
1684
  /**
1653
1685
  * Outstanding asks WITH the view the browser was shown, for the reconnect
1654
1686
  * replay. Entries created without a view are skipped: replaying a frame the
@@ -1874,6 +1906,24 @@ export interface AcpManager {
1874
1906
  * the registry service), which hides the panel block instead of guessing.
1875
1907
  */
1876
1908
  presetStatus?(): Promise<AcpPresetStatus | undefined>
1909
+ /**
1910
+ * The installed host package's version, reported for the settings page's
1911
+ * badge. `undefined` on a host half that predates the endpoint, which hides
1912
+ * the badge rather than showing a number the browser invented.
1913
+ */
1914
+ pluginVersion?(): string | undefined
1915
+ /**
1916
+ * Ask the host's plugin manager what is published for this package. Read-only
1917
+ * toward the installation: never downloads, never swaps files, never restarts
1918
+ * the host. Absent on a host half from before the updater existed.
1919
+ */
1920
+ pluginUpdateCheck?(): Promise<PluginUpdateState>
1921
+ /**
1922
+ * Install one exact stable version THROUGH the host's plugin manager. The
1923
+ * manager owns download/restart semantics; this plugin only relays. Absent on
1924
+ * an older host half.
1925
+ */
1926
+ pluginUpdateApply?(version: string): Promise<PluginUpdateState>
1877
1927
  }
1878
1928
 
1879
1929
  function ok<T>(value: T): RpcResult<T> {
@@ -2038,6 +2088,21 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
2038
2088
  }
2039
2089
  case 'preset/status':
2040
2090
  return ok({ preset: await manager.presetStatus?.() ?? null })
2091
+ case 'plugin/version':
2092
+ // Absent on a host half from before the badge existed: the browser
2093
+ // hides it instead of naming a version it cannot know.
2094
+ return ok({ version: manager.pluginVersion?.() })
2095
+ case 'plugin/update-check':
2096
+ // An older host half answers `unsupported` rather than failing: the
2097
+ // settings card then hides the update section.
2098
+ return ok(await (manager.pluginUpdateCheck?.() ?? { status: 'unsupported' as const, current: '' }))
2099
+ case 'plugin/update-apply': {
2100
+ const version = str(body, 'version', 64)
2101
+ if (!/^\d+\.\d+\.\d+$/.test(version)) {
2102
+ throw new AcpError('bad_payload', 'acp: version must be a stable semver (x.y.z)')
2103
+ }
2104
+ return ok(await (manager.pluginUpdateApply?.(version) ?? { status: 'unsupported' as const, current: '' }))
2105
+ }
2041
2106
  case 'agent/stop':
2042
2107
  await manager.stopAgent(str(body, 'agentId', 64)); return ok({ done: true })
2043
2108
  case 'default/agent':
package/src/index.ts CHANGED
@@ -8,10 +8,12 @@
8
8
  import type { Context } from '@deepseek-ai/cordis'
9
9
  import type {} from '@deepseek-ai/dsh-host-webserver'
10
10
  import type { ConnectionRpcHandler, HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
11
- import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager } from './bridge.ts'
11
+ import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager, PLUGIN_VERSION } from './bridge.ts'
12
12
  import { EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from './impl.ts'
13
13
  import type { AcpEvent, ApprovalDeadlineKey } from './protocol.ts'
14
14
  import type { RosterPresetRow, SessionLogLike } from './impl.ts'
15
+ import { UpdateService } from './update.ts'
16
+ import type { UpdateManagerLike } from './update.ts'
15
17
 
16
18
  export const name = 'dsh-ntr'
17
19
  // Deliberately minimal: every other host service is probed structurally
@@ -86,9 +88,22 @@ export function apply(ctx: Context, config?: unknown): void {
86
88
  cancelFallbackMs: resolved.cancelFallbackMs,
87
89
  staleCancelRetryDelayMs: resolved.staleCancelRetryDelayMs,
88
90
  staleCancelMaxRetries: resolved.staleCancelMaxRetries,
91
+ turnStallMs: resolved.turnStallMs,
92
+ turnStallToolMs: resolved.turnStallToolMs,
93
+ turnStallTickMs: resolved.turnStallTickMs,
89
94
  },
90
95
  )
91
96
  const adapter = new AcpLlmAdapter(manager)
97
+ // Built-in update: the plugin never downloads or swaps files itself. The
98
+ // harness plugin manager owns the install; this only relays its facts.
99
+ // `resolveManager` re-probes on every call so a late-loading service is
100
+ // picked up without an `inject` dependency (a host without it must still
101
+ // load this plugin, and then reports `unsupported`).
102
+ manager.setUpdateService(new UpdateService({
103
+ resolveManager: () => (typeof ctx.get === 'function' ? ctx.get('pluginManager') : undefined) as UpdateManagerLike | undefined,
104
+ currentVersion: PLUGIN_VERSION,
105
+ isBusy: () => manager.hasActiveTurns(),
106
+ }))
92
107
  // The RAW patch-file deadlines, before validation filled the defaults in.
93
108
  // `validateConfig` returns a number for every knob, so the validated object
94
109
  // cannot tell "the deployment asked for 10 minutes" from "the deployment said