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.
@@ -74,7 +74,7 @@ const STYLE = `
74
74
  }
75
75
  .dwa-page-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 28px; }
76
76
  .dwa-page-heading { min-width: 0; }
77
- .dwa-page-title { margin: 0; font-size: 20px; line-height: 1.35; font-weight: 550; letter-spacing: -0.02em; }
77
+ .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; }
78
78
  .dwa-page-description { margin: 5px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-sm); }
79
79
  .dwa-btn {
80
80
  min-height: 32px; box-sizing: border-box; font: inherit; border-radius: var(--ntr-radius-sm);
@@ -106,6 +106,20 @@ const STYLE = `
106
106
  .dwa-badge-success { background: var(--dsw-alias-state-success-tertiary, #dcfce7); color: var(--ntr-success); }
107
107
  .dwa-badge-warn { background: var(--dsw-alias-state-warn-tertiary, #fef3c7); color: var(--ntr-warn); }
108
108
  .dwa-badge-info { background: var(--dsw-alias-state-business-tertiary, #dbeafe); color: var(--dsw-alias-state-business-primary, #2563eb); }
109
+ /* The update badge is a button (it expands the update card): strip the
110
+ browser's default button chrome, keep the badge look. */
111
+ .dwa-update-badge { border: 0; font-family: inherit; cursor: pointer; }
112
+ .dwa-update-badge:hover { opacity: 0.85; }
113
+ /* The version badge doubles as the update entry: same badge look, no chrome. */
114
+ .dwa-version-btn { border: 0; font-family: inherit; cursor: pointer; }
115
+ .dwa-version-btn:hover { opacity: 0.85; }
116
+ .dwa-version { font-family: var(--ds-font-family-code, monospace); }
117
+ /* The update card sits between the page header and the permission mirror. */
118
+ .dwa-update-card { margin-bottom: 16px; }
119
+ .dwa-update-summary { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-weight: 550; }
120
+ .dwa-update-copy { margin: 6px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); }
121
+ .dwa-update-message { margin: 6px 0 0; color: var(--ntr-muted); font-size: var(--ntr-font-xs); white-space: pre-wrap; overflow-wrap: anywhere; }
122
+ .dwa-update-actions { margin-top: 10px; }
109
123
  .dwa-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
110
124
  .dwa-agent-list { display: flex; flex-direction: column; border-top: var(--ntr-line); }
111
125
  .dwa-agent-card { border-bottom: var(--ntr-line); min-width: 0; }
@@ -712,6 +726,101 @@ function ApprovalDeadlinesCard(props) {
712
726
  const waitMode = deadlines.mode === 'wait';
713
727
  return (_jsxs("section", { className: "dwa-section", children: [_jsx("div", { className: "dwa-section-heading", children: _jsxs("div", { children: [_jsx("h3", { className: "dwa-section-title", children: acp.label('审批时限', 'Approval deadlines') }), _jsx("p", { className: "dwa-section-copy", children: acp.label('权限请求的等待时长与超时处理方式。', 'How long to wait for a permission request, and what happens when nobody answers.') })] }) }), _jsxs("div", { className: "dwa-preferences", children: [_jsx(DeadlineModeBlock, { acp: acp, mode: deadlines.mode, override: modeNote }), _jsx(DeadlineSliderBlock, { acp: acp, value: waitMode ? undefined : deadlines.permissionTimeoutMs, mode: deadlines.mode, override: baselineNote }), facts.overridden && (_jsx("div", { className: "dwa-deadline-reset", children: _jsx("button", { type: "button", className: "dwa-btn dwa-btn-secondary", onClick: () => void acp.resetApprovalDeadlines(), children: acp.label('恢复配置文件的值', 'Use config file value') }) }))] })] }));
714
728
  }
729
+ /** The states that carry an update badge next to the version badge. */
730
+ function updateBadgeText(update, acp) {
731
+ if (update.status === 'available') {
732
+ return acp.label(`可更新 v${update.latest ?? update.current}`, `Update v${update.latest ?? update.current}`);
733
+ }
734
+ if (update.status === 'applying')
735
+ return acp.label('更新中…', 'Updating…');
736
+ if (update.status === 'applied') {
737
+ const installed = update.installed ?? update.latest ?? update.current;
738
+ return acp.label(`已更新 v${installed}`, `Updated v${installed}`);
739
+ }
740
+ if (update.status === 'restart-required') {
741
+ // Only a KNOWN disk version can be named. An interrupted install leaves
742
+ // `installed` unset, and a badge would have to invent a version.
743
+ if (update.installed === undefined)
744
+ return undefined;
745
+ // Neutral: the disk version can be newer OR older than the running one.
746
+ return acp.label(`待重启 v${update.installed}`, `Restart for v${update.installed}`);
747
+ }
748
+ return undefined;
749
+ }
750
+ /**
751
+ * The version badge's update companion. Every badge is a button, `applying`
752
+ * included: the card is where the install's outcome is read, so the running
753
+ * install must be able to open it too. States without anything to act on get
754
+ * no badge at all — the version badge itself still opens the card.
755
+ */
756
+ function PluginUpdateBadge(props) {
757
+ const { acp, update, open, onToggle } = props;
758
+ const text = updateBadgeText(update, acp);
759
+ if (text === undefined)
760
+ return null;
761
+ return (_jsx("button", { type: "button", className: "dwa-badge dwa-badge-info dwa-update-badge", "aria-expanded": open, "aria-controls": "ntr-plugin-update-card", onClick: onToggle, children: text }));
762
+ }
763
+ /**
764
+ * The update card. In-page, no dialog: the settings page already owns the one
765
+ * action (install the exact version the host reported), and a modal on top of
766
+ * a settings section would hide the version it is updating.
767
+ *
768
+ * The copy stays to one line per state (constraint 33); the host's raw error
769
+ * (`message`) is the only long text, and it wraps on its own line so a stack
770
+ * trace or a registry error is readable without stretching the card.
771
+ */
772
+ function PluginUpdateCard(props) {
773
+ const { acp, update, onClose } = props;
774
+ const applying = update.status === 'applying';
775
+ const latest = update.latest ?? update.current;
776
+ const onDisk = update.installed !== undefined && update.installed !== update.current
777
+ ? update.installed
778
+ : undefined;
779
+ const published = update.publishedAt !== undefined && !Number.isNaN(new Date(update.publishedAt).getTime())
780
+ ? new Date(update.publishedAt).toLocaleDateString()
781
+ : undefined;
782
+ const copy = update.status === 'available'
783
+ ? acp.label('更新会卸载并重装插件,之后可能需要重启 DSH。', 'Updating uninstalls and reinstalls the plugin, and may require restarting DSH.')
784
+ : update.status === 'applied'
785
+ ? acp.label('已应用,刷新页面即可使用新版本。', 'Applied. Reload the page to use the new version.')
786
+ : update.status === 'restart-required'
787
+ ? update.installed !== undefined
788
+ ? acp.label(`磁盘已是 v${update.installed},重启 DSH 后生效(桌面 App 退出重开;命令行重启 dsh web)。`, `On disk v${update.installed}. Restart DSH to apply (quit and reopen the app, or restart dsh web).`)
789
+ : acp.label('更新可能已完成,请刷新页面确认当前版本。', 'The update may already be complete. Refresh the page to confirm the current version.')
790
+ : update.status === 'busy'
791
+ ? acp.label('有进行中的对话,结束后才能更新。', 'Finish the running conversation before updating.')
792
+ : update.status === 'failed'
793
+ ? acp.label('更新失败。', 'Update failed.')
794
+ : update.status === 'current'
795
+ ? acp.label('已是最新版本。', 'Up to date.')
796
+ : update.status === 'source-install'
797
+ ? acp.label('源码或本地安装,无法通过 registry 更新。', 'Source or local install; update it through its own checkout.')
798
+ : update.status === 'managed'
799
+ ? acp.label('此安装由宿主管理,不能在这里更新。', 'This install is managed by the host and cannot be updated here.')
800
+ : undefined;
801
+ const showMessage = (update.status === 'failed' || update.status === 'busy' || update.status === 'managed')
802
+ && update.message !== undefined;
803
+ const apply = async () => {
804
+ if (update.latest === undefined)
805
+ return;
806
+ try {
807
+ await acp.applyPluginUpdate(update.latest);
808
+ }
809
+ catch {
810
+ // The store keeps the "disk may have changed" state and writes its own notice.
811
+ }
812
+ };
813
+ return (_jsxs("div", { id: "ntr-plugin-update-card", className: "dwa-card dwa-update-card", role: "group", "aria-label": acp.label('插件更新', 'Plugin update'), children: [_jsxs("div", { className: "dwa-update-summary", children: [_jsx("span", { children: acp.label(`当前 v${update.current}`, `Current v${update.current}`) }), onDisk !== undefined && _jsx("span", { className: "dwa-hint", children: acp.label(`磁盘 v${onDisk}`, `On disk v${onDisk}`) }), update.latest !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "\u2192" }), _jsx("span", { children: acp.label(`最新 v${latest}`, `Latest v${latest}`) }), published !== undefined && _jsx("span", { className: "dwa-hint", children: published })] }))] }), copy !== undefined && _jsx("p", { className: "dwa-update-copy", children: copy }), showMessage && _jsx("div", { className: "dwa-update-message", children: update.message }), _jsxs("div", { className: "dwa-row-actions dwa-update-actions", children: [update.status === 'available' && (_jsx("button", { type: "button", className: "dwa-btn dwa-btn-primary", onClick: () => { void apply(); }, children: acp.label('更新', 'Update') })), applying && (_jsx("button", { type: "button", className: "dwa-btn dwa-btn-primary", disabled: true, children: acp.label('正在更新…', 'Updating…') })), update.status !== 'applying' && (_jsx("button", { type: "button", className: "dwa-btn", onClick: onClose, children: acp.label('关闭', 'Close') })), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-quiet", disabled: applying, onClick: () => void acp.checkPluginUpdate(true), children: acp.label('重新检查', 'Check again') })] })] }));
814
+ }
815
+ /**
816
+ * The card is drawn for every state a host can report about this install,
817
+ * except the two where there is no install to talk about at all.
818
+ */
819
+ function updateCardVisible(update, open) {
820
+ if (update === undefined || !open)
821
+ return false;
822
+ return update.status !== 'unsupported' && update.status !== 'not-installed';
823
+ }
715
824
  export function AcpSettingsSection(props) {
716
825
  const acp = props.acp;
717
826
  ensureStyle();
@@ -720,6 +829,12 @@ export function AcpSettingsSection(props) {
720
829
  const [page, setPage] = useState('agents');
721
830
  const [addOpen, setAddOpen] = useState(false);
722
831
  const [addTarget, setAddTarget] = useState(undefined);
832
+ const [updateOpen, setUpdateOpen] = useState(false);
833
+ // The store throttles this to one registry read per ten minutes, so every
834
+ // remount (both settings entries mount this section) is cheap.
835
+ useEffect(() => {
836
+ void acp.checkPluginUpdate();
837
+ }, [acp]);
723
838
  const { configured, available } = partitionAgents(ui.detectedAgents, ui.agents);
724
839
  const selected = addTarget ?? available[0] ?? NEW_AGENT_SEED;
725
840
  const openAdd = () => {
@@ -727,7 +842,20 @@ export function AcpSettingsSection(props) {
727
842
  setPage('add');
728
843
  };
729
844
  const presetProblem = ui.preset !== undefined && (!ui.preset.installed || ui.preset.problem !== undefined);
730
- return (_jsxs("div", { className: "dwa-root", children: [_jsxs("header", { className: "dwa-page-header", children: [_jsxs("div", { className: "dwa-page-heading", children: [_jsx("h2", { className: "dwa-page-title", children: acp.label('NTR 设置', 'NTR settings') }), _jsx("p", { className: "dwa-page-description", children: acp.label('连接智能体,设置你的对话偏好。', 'Connect agents and set your conversation preferences.') })] }), page === 'agents' && configured.length > 0 && (_jsx("button", { type: "button", className: "dwa-btn dwa-btn-primary", onClick: openAdd, children: acp.label('添加智能体', 'Add agent') }))] }), ledger.visible && (_jsxs("div", { className: "dwa-card dwa-card-warn", style: { marginBottom: 16 }, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 4 }, children: acp.label('待处理权限请求', 'Pending Permission Requests') }), _jsx("div", { className: "dwa-hint", style: { marginBottom: 8 }, children: acp.label('对话打开时在那边的卡片上回答;这里兜底显示其他会话的询问。', 'Answer in the conversation card while that conversation is open; this list covers the asks whose conversation is not.') }), ui.permissions.map(permission => (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }, children: [_jsxs("span", { style: { flex: 1, minWidth: 160 }, children: [_jsx("span", { style: { fontWeight: 600 }, children: permission.toolName }), permission.reason !== undefined && (_jsxs("span", { style: { color: 'var(--ntr-muted)' }, children: [" \u00B7 ", permission.reason] }))] }), permission.options.map(option => (_jsx("button", { type: "button", className: "dwa-btn", onClick: () => { void acp.respondPermission(permission.requestId, option.optionId).catch(() => undefined); }, children: option.name }, option.optionId))), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-quiet", onClick: () => { void acp.respondPermission(permission.requestId).catch(() => undefined); }, children: acp.label('取消', 'Cancel') })] }, permission.requestId)))] })), ui.notice !== undefined && (_jsxs("div", { className: "dwa-card dwa-card-warn", style: { marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { style: { flex: 1 }, children: ui.notice }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-quiet", "aria-label": acp.label('关闭通知', 'Dismiss notice'), onClick: () => acp.clearNotice(), children: "\u2715" })] })), presetProblem && (_jsxs("div", { className: "dwa-card dwa-card-warn dwa-notice", role: "status", children: [_jsx("span", { children: acp.label('NTR 模式需要检查,当前可能无法开始对话。', 'NTR mode needs attention; new conversations may be unavailable.') }), _jsx("button", { type: "button", className: "dwa-btn", onClick: () => setPage('diagnostics'), children: acp.label('查看诊断', 'View diagnostics') })] })), _jsxs("nav", { className: "dwa-tabs", "aria-label": acp.label('NTR 设置分类', 'NTR settings categories'), children: [_jsx("button", { type: "button", "aria-pressed": page === 'agents', onClick: () => setPage('agents'), children: acp.label('智能体', 'Agents') }), ui.approvalDeadlines !== undefined && _jsx("button", { type: "button", "aria-pressed": page === 'approvals', onClick: () => setPage('approvals'), children: acp.label('审批时限', 'Approval deadlines') }), _jsx("button", { type: "button", "aria-pressed": page === 'appearance', onClick: () => setPage('appearance'), children: acp.label('对话外观', 'Appearance') })] }), page === 'diagnostics' && (_jsxs("section", { className: "dwa-section", children: [_jsx("h3", { className: "dwa-section-title", children: acp.label('连接诊断', 'Connection diagnostics') }), _jsxs("div", { className: "dwa-preference-row", children: [_jsx("span", { children: acp.label('宿主事件流', 'Host event stream') }), _jsx("span", { className: "dwa-hint", children: streamText(ui.stream, acp) })] }), _jsxs("div", { className: "dwa-row-actions", children: [_jsx("button", { type: "button", className: "dwa-btn", onClick: () => { void acp.resync(); void acp.detectAgents(); }, disabled: ui.detecting, children: ui.detecting ? acp.label('检测中...', 'Detecting...') : acp.label('重新检测与刷新', 'Re-detect & Refresh') }), ui.detectSummary !== undefined && _jsx("span", { className: "dwa-hint", role: "status", children: ui.detectSummary })] })] })), page === 'diagnostics' && ui.preset !== undefined && (_jsxs("div", { className: "dwa-card dwa-mode-banner", children: [_jsxs("div", { className: "dwa-mode-title", children: [acp.label('NTR 模式', 'NTR mode'), _jsx("span", { className: "dwa-badge", children: ui.preset.id })] }), _jsx("span", { className: `dwa-badge ${ui.preset.installed ? 'dwa-badge-success' : 'dwa-badge-warn'}`, children: ui.preset.installed ? acp.label('已就绪', 'Ready') : acp.label('需检查', 'Needs attention') }), _jsxs("div", { className: "dwa-mode-copy", children: [ui.preset.installed
845
+ const update = ui.pluginUpdate;
846
+ // A source install cannot be replaced by this browser; the version badge
847
+ // says so on hover. Neutral wording: a checkout may be git or not.
848
+ const versionTitle = update?.status === 'source-install'
849
+ ? acp.label('源码或本地安装,无法通过 registry 更新。', 'Source or local install; update it through its own checkout.')
850
+ : undefined;
851
+ // The running version is the update entry: every state with a real install
852
+ // report can open the card, including "up to date" (which offers a re-check).
853
+ const versionOpensUpdate = update !== undefined
854
+ && update.status !== 'unsupported'
855
+ && update.status !== 'not-installed';
856
+ return (_jsxs("div", { className: "dwa-root", children: [_jsxs("header", { className: "dwa-page-header", children: [_jsxs("div", { className: "dwa-page-heading", children: [_jsxs("h2", { className: "dwa-page-title", "aria-label": acp.label('NTR 设置', 'NTR settings'), children: [acp.label('NTR 设置', 'NTR settings'), ui.pluginVersion !== undefined && (versionOpensUpdate
857
+ ? (_jsx("button", { type: "button", className: "dwa-badge dwa-version dwa-version-btn", title: versionTitle, "aria-expanded": updateOpen, "aria-controls": "ntr-plugin-update-card", onClick: () => setUpdateOpen(open => !open), children: `v${ui.pluginVersion}` }))
858
+ : _jsx("span", { className: "dwa-badge dwa-version", title: versionTitle, children: `v${ui.pluginVersion}` })), update !== undefined && (_jsx(PluginUpdateBadge, { acp: acp, update: update, open: updateOpen, onToggle: () => setUpdateOpen(open => !open) }))] }), _jsx("p", { className: "dwa-page-description", children: acp.label('连接智能体,设置你的对话偏好。', 'Connect agents and set your conversation preferences.') })] }), page === 'agents' && configured.length > 0 && (_jsx("button", { type: "button", className: "dwa-btn dwa-btn-primary", onClick: openAdd, children: acp.label('添加智能体', 'Add agent') }))] }), update !== undefined && updateCardVisible(update, updateOpen) && (_jsx(PluginUpdateCard, { acp: acp, update: update, onClose: () => setUpdateOpen(false) })), ledger.visible && (_jsxs("div", { className: "dwa-card dwa-card-warn", style: { marginBottom: 16 }, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 4 }, children: acp.label('待处理权限请求', 'Pending Permission Requests') }), _jsx("div", { className: "dwa-hint", style: { marginBottom: 8 }, children: acp.label('对话打开时在那边的卡片上回答;这里兜底显示其他会话的询问。', 'Answer in the conversation card while that conversation is open; this list covers the asks whose conversation is not.') }), ui.permissions.map(permission => (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }, children: [_jsxs("span", { style: { flex: 1, minWidth: 160 }, children: [_jsx("span", { style: { fontWeight: 600 }, children: permission.toolName }), permission.reason !== undefined && (_jsxs("span", { style: { color: 'var(--ntr-muted)' }, children: [" \u00B7 ", permission.reason] }))] }), permission.options.map(option => (_jsx("button", { type: "button", className: "dwa-btn", onClick: () => { void acp.respondPermission(permission.requestId, option.optionId).catch(() => undefined); }, children: option.name }, option.optionId))), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-quiet", onClick: () => { void acp.respondPermission(permission.requestId).catch(() => undefined); }, children: acp.label('取消', 'Cancel') })] }, permission.requestId)))] })), ui.notice !== undefined && (_jsxs("div", { className: "dwa-card dwa-card-warn", style: { marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { style: { flex: 1 }, children: ui.notice }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-quiet", "aria-label": acp.label('关闭通知', 'Dismiss notice'), onClick: () => acp.clearNotice(), children: "\u2715" })] })), presetProblem && (_jsxs("div", { className: "dwa-card dwa-card-warn dwa-notice", role: "status", children: [_jsx("span", { children: acp.label('NTR 模式需要检查,当前可能无法开始对话。', 'NTR mode needs attention; new conversations may be unavailable.') }), _jsx("button", { type: "button", className: "dwa-btn", onClick: () => setPage('diagnostics'), children: acp.label('查看诊断', 'View diagnostics') })] })), _jsxs("nav", { className: "dwa-tabs", "aria-label": acp.label('NTR 设置分类', 'NTR settings categories'), children: [_jsx("button", { type: "button", "aria-pressed": page === 'agents', onClick: () => setPage('agents'), children: acp.label('智能体', 'Agents') }), ui.approvalDeadlines !== undefined && _jsx("button", { type: "button", "aria-pressed": page === 'approvals', onClick: () => setPage('approvals'), children: acp.label('审批时限', 'Approval deadlines') }), _jsx("button", { type: "button", "aria-pressed": page === 'appearance', onClick: () => setPage('appearance'), children: acp.label('对话外观', 'Appearance') })] }), page === 'diagnostics' && (_jsxs("section", { className: "dwa-section", children: [_jsx("h3", { className: "dwa-section-title", children: acp.label('连接诊断', 'Connection diagnostics') }), _jsxs("div", { className: "dwa-preference-row", children: [_jsx("span", { children: acp.label('宿主事件流', 'Host event stream') }), _jsx("span", { className: "dwa-hint", children: streamText(ui.stream, acp) })] }), _jsxs("div", { className: "dwa-row-actions", children: [_jsx("button", { type: "button", className: "dwa-btn", onClick: () => { void acp.resync(); void acp.detectAgents(); }, disabled: ui.detecting, children: ui.detecting ? acp.label('检测中...', 'Detecting...') : acp.label('重新检测与刷新', 'Re-detect & Refresh') }), ui.detectSummary !== undefined && _jsx("span", { className: "dwa-hint", role: "status", children: ui.detectSummary })] })] })), page === 'diagnostics' && ui.preset !== undefined && (_jsxs("div", { className: "dwa-card dwa-mode-banner", children: [_jsxs("div", { className: "dwa-mode-title", children: [acp.label('NTR 模式', 'NTR mode'), _jsx("span", { className: "dwa-badge", children: ui.preset.id })] }), _jsx("span", { className: `dwa-badge ${ui.preset.installed ? 'dwa-badge-success' : 'dwa-badge-warn'}`, children: ui.preset.installed ? acp.label('已就绪', 'Ready') : acp.label('需检查', 'Needs attention') }), _jsxs("div", { className: "dwa-mode-copy", children: [ui.preset.installed
731
859
  ? acp.label('已加入宿主模式列表,新建会话时选择「NTR 模式」即可使用。', 'Added to the harness roster. Pick “NTR mode” when creating a session.')
732
860
  : acp.label('宿主模式列表中没有可用的「NTR 模式」,请检查预设声明。', 'The harness roster has no usable “NTR mode”. Check the preset declaration.'), _jsx("code", { className: "dwa-mode-source dwa-mono", children: ui.preset.source })] }), ui.preset.problem !== undefined && (_jsx("div", { className: "dwa-hint", style: { gridColumn: '1 / -1', color: 'var(--ntr-warn)' }, children: acp.label(`宿主报告:${ui.preset.problem}`, `Harness reported: ${ui.preset.problem}`) }))] })), _jsxs("section", { className: "dwa-section", hidden: page !== 'agents', "aria-label": acp.label('已接入的智能体', 'Connected agents'), children: [_jsxs("div", { className: "dwa-section-heading", children: [_jsx("div", { children: _jsxs("h3", { className: "dwa-section-title", children: [acp.label('已接入的智能体', 'Connected agents'), ui.agentsLoaded && _jsx("span", { className: "dwa-badge", children: configured.length })] }) }), configured.length > 0 && _jsx("span", { className: "dwa-hint", children: acp.label('默认项用于新的 NTR 会话', 'The default serves new NTR conversations') })] }), !ui.agentsLoaded && _jsx("div", { className: "dwa-empty", role: "status", children: acp.label('正在读取智能体…', 'Loading agents…') }), ui.agentsLoaded && configured.length === 0 && (_jsxs("div", { className: "dwa-empty", children: [_jsx("h3", { children: acp.label('接入第一个智能体', 'Connect your first agent') }), _jsx("p", { className: "dwa-section-copy", children: acp.label('选择已有预设,或填写自己的 ACP 启动命令。', 'Choose a preset or enter your own ACP launch command.') }), _jsx("button", { type: "button", className: "dwa-btn dwa-btn-primary", onClick: openAdd, children: acp.label('添加智能体', 'Add agent') })] })), _jsx("div", { className: "dwa-agent-list", children: configured.map(({ info, synthesized }) => {
733
861
  const activeAgent = ui.agents.find(a => a.id === info.id);
@@ -44,6 +44,12 @@ const MAX_BLOCKS = 400;
44
44
  const MAX_WATCHED = 6;
45
45
  /** Raw JSON budget for one event block; the cut is marked. */
46
46
  const RAW_MAX = 256_000;
47
+ /**
48
+ * How long a fetched update state stays fresh. The settings panel asks on every
49
+ * mount; without this, every open would hit the registry. A forced check (the
50
+ * card's own button) always goes out.
51
+ */
52
+ const PLUGIN_UPDATE_TTL_MS = 10 * 60 * 1000;
47
53
  /** Pretty-printed JSON of an agent message (never throws, cuts only at {@link RAW_MAX} with a marker). */
48
54
  export function jsonText(value) {
49
55
  let text;
@@ -279,6 +285,14 @@ export const DEFAULT_KNOWN_PRESETS = [
279
285
  defaultCommand: 'opencode',
280
286
  defaultArgs: ['acp'],
281
287
  },
288
+ {
289
+ id: 'cursor',
290
+ name: 'Cursor CLI',
291
+ description: 'Cursor CLI — run as `agent acp` over stdio (legacy alias `cursor-agent acp`)',
292
+ detected: false,
293
+ defaultCommand: 'agent',
294
+ defaultArgs: ['acp'],
295
+ },
282
296
  {
283
297
  id: 'qwen',
284
298
  name: 'Qwen Code',
@@ -394,6 +408,22 @@ export class AcpStore {
394
408
  cachedModelReads = new Set();
395
409
  /** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
396
410
  detectCounts;
411
+ /** When {@link UiState.pluginUpdate} was last read from the host (epoch ms). */
412
+ pluginUpdateFetchedAt = 0;
413
+ /**
414
+ * The shared in-flight `plugin/update-check`. The settings section mounts
415
+ * twice (both entries) and the update popover asks too; without this, two
416
+ * mounts in the same tick each fire their own registry read.
417
+ */
418
+ pluginUpdateInflight;
419
+ /** Re-entry guard for `plugin/update-apply` (a double click is one install). */
420
+ pluginUpdateApplying = false;
421
+ /**
422
+ * Update read generation. An apply bumps it so a check that was already in
423
+ * flight (started before the install) cannot land afterwards and overwrite
424
+ * the install's verdict with the pre-install `available`.
425
+ */
426
+ pluginUpdateGeneration = 0;
397
427
  constructor(rpc, locale) {
398
428
  this.rpc = rpc;
399
429
  this.locale = locale;
@@ -533,7 +563,7 @@ export class AcpStore {
533
563
  /** Snapshot refresh used on panel open and after a stream outage. */
534
564
  async resync() {
535
565
  try {
536
- const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
566
+ const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
537
567
  this.call('agents/list'),
538
568
  this.call('sessions/list'),
539
569
  // The plugin's OWN default ACP agent — a plugin-private id, not a DSH
@@ -547,6 +577,9 @@ export class AcpStore {
547
577
  // `undefined` (older half) hides the deadline card instead of showing a
548
578
  // number this browser made up.
549
579
  this.call('ui/approval-deadlines').catch(() => ({ deadlines: undefined })),
580
+ // The settings badge's data source; older halves answer `unknown
581
+ // endpoint`, which hides the badge.
582
+ this.call('plugin/version').catch(() => null),
550
583
  ]);
551
584
  const detectedAgents = (detectResult && detectResult.agents.length > 0)
552
585
  ? detectResult.agents
@@ -561,6 +594,7 @@ export class AcpStore {
561
594
  preset: presetResult?.preset ?? undefined,
562
595
  tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
563
596
  approvalDeadlines: deadlinesResult.deadlines,
597
+ pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
564
598
  });
565
599
  const active = this.state.activeSessionId ?? sessions[0]?.sessionId;
566
600
  if (active !== undefined)
@@ -570,6 +604,104 @@ export class AcpStore {
570
604
  this.mutate({ notice: String(error instanceof Error ? error.message : error) });
571
605
  }
572
606
  }
607
+ /**
608
+ * Read the plugin's update state (`plugin/update-check`). Throttled to ten
609
+ * minutes unless `force`: the settings panel asks on mount, and every
610
+ * remount must not hit the registry.
611
+ *
612
+ * An older host serves no such endpoint (`unknown endpoint`). That is a
613
+ * normal degradation, not an error worth a notice: the answer is `undefined`
614
+ * and the panel simply keeps its update entry hidden.
615
+ */
616
+ async checkPluginUpdate(force = false) {
617
+ // One read for every caller while it is in flight, force included: the
618
+ // settings page and the popover mounting together must not hit the
619
+ // registry twice.
620
+ if (this.pluginUpdateInflight !== undefined)
621
+ return this.pluginUpdateInflight;
622
+ const current = this.state.pluginUpdate;
623
+ if (!force && current !== undefined && Date.now() - this.pluginUpdateFetchedAt < PLUGIN_UPDATE_TTL_MS) {
624
+ return current;
625
+ }
626
+ const generation = this.pluginUpdateGeneration;
627
+ const inflight = this.readPluginUpdate(generation);
628
+ this.pluginUpdateInflight = inflight;
629
+ try {
630
+ return await inflight;
631
+ }
632
+ finally {
633
+ this.pluginUpdateInflight = undefined;
634
+ }
635
+ }
636
+ async readPluginUpdate(generation) {
637
+ try {
638
+ const pluginUpdate = await this.call('plugin/update-check');
639
+ // An install started while this read was in flight: its answer is
640
+ // already stale, and the install's verdict owns the state now.
641
+ if (generation !== this.pluginUpdateGeneration)
642
+ return undefined;
643
+ this.pluginUpdateFetchedAt = Date.now();
644
+ this.mutate({ pluginUpdate });
645
+ return pluginUpdate;
646
+ }
647
+ catch {
648
+ return undefined;
649
+ }
650
+ }
651
+ /**
652
+ * Install one exact version (`plugin/update-apply`). The status flips to
653
+ * `applying` first so the badge answers the click; the host's answer then
654
+ * becomes the state.
655
+ *
656
+ * A failed call does NOT prove the install did not happen: on an HMR host
657
+ * the update uninstalls the plugin before reinstalling it, which can cut the
658
+ * response. Whatever the error, the disk may already carry the new version,
659
+ * so the state becomes `restart-required` (the one neutral verdict for "the
660
+ * running code is not the disk's") with `installed` dropped (unknown, not a
661
+ * fact to display) and the notice asks for a reload rather than restoring
662
+ * `available` and pretending nothing changed.
663
+ */
664
+ async applyPluginUpdate(version) {
665
+ if (this.pluginUpdateApplying)
666
+ return undefined;
667
+ this.pluginUpdateApplying = true;
668
+ // Any check already in flight predates the install; its answer must not
669
+ // land on top of the install's verdict.
670
+ this.pluginUpdateGeneration += 1;
671
+ const previous = this.state.pluginUpdate;
672
+ if (previous !== undefined) {
673
+ this.mutate({ pluginUpdate: { ...previous, status: 'applying' } });
674
+ }
675
+ try {
676
+ const pluginUpdate = await this.call('plugin/update-apply', { version });
677
+ this.pluginUpdateFetchedAt = Date.now();
678
+ this.mutate({ pluginUpdate });
679
+ return pluginUpdate;
680
+ }
681
+ catch (error) {
682
+ const message = error instanceof Error ? error.message : String(error);
683
+ // The disk state is UNKNOWN here: the install may have completed before
684
+ // the response was cut. Keep `latest` for display, drop `installed` so no
685
+ // surface can claim a disk version we never read, and reset the throttle
686
+ // so the next check actually asks the host instead of serving this
687
+ // unknown verdict for ten minutes.
688
+ const interrupted = previous !== undefined
689
+ ? { ...previous, status: 'restart-required', installed: undefined, message }
690
+ : { status: 'restart-required', current: this.state.pluginVersion ?? '?', message };
691
+ this.pluginUpdateFetchedAt = 0;
692
+ this.mutate({
693
+ pluginUpdate: interrupted,
694
+ notice: this.locale('更新请求已中断;请刷新页面确认当前版本。', 'The update request was interrupted. Reload the page to confirm the installed version.'),
695
+ });
696
+ return undefined;
697
+ }
698
+ finally {
699
+ // Invalidate reads that STARTED during the install: their "applying"
700
+ // answer would otherwise land on top of this call's final verdict.
701
+ this.pluginUpdateGeneration += 1;
702
+ this.pluginUpdateApplying = false;
703
+ }
704
+ }
573
705
  async detectAgents() {
574
706
  this.mutate({ detecting: true });
575
707
  try {