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/src/client/panel.tsx
CHANGED
|
@@ -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"
|
|
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 && (
|
package/src/client/store.ts
CHANGED
|
@@ -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
|