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.
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/cordis.patch.yml +12 -0
- package/lib/bridge.js +640 -62
- package/lib/client/panel.js +130 -2
- package/lib/client/store.js +133 -1
- package/lib/client.js +302 -6
- package/lib/client.js.map +1 -1
- package/lib/impl.js +47 -0
- package/lib/index.js +15 -1
- package/lib/types/bridge.d.ts +205 -3
- package/lib/types/client/store.d.ts +54 -1
- package/lib/types/impl.d.ts +26 -1
- package/lib/types/protocol.d.ts +73 -0
- package/lib/types/update.d.ts +183 -0
- package/lib/update.js +681 -0
- package/package.json +1 -1
- package/src/bridge.ts +684 -61
- package/src/client/panel.tsx +215 -3
- package/src/client/store.ts +146 -2
- package/src/impl.ts +66 -1
- package/src/index.ts +16 -1
- package/src/protocol.ts +76 -0
- package/src/update.ts +699 -0
package/lib/client/panel.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/lib/client/store.js
CHANGED
|
@@ -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 {
|