dsh-ntr 0.2.1 → 0.3.1
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 +646 -68
- package/lib/client/panel.js +162 -4
- package/lib/client/store.js +188 -4
- package/lib/client.js +431 -9
- 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 +206 -4
- package/lib/types/client/store.d.ts +87 -1
- package/lib/types/impl.d.ts +26 -1
- package/lib/types/protocol.d.ts +80 -0
- package/lib/types/update.d.ts +186 -0
- package/lib/update.js +696 -0
- package/package.json +1 -1
- package/src/bridge.ts +691 -68
- package/src/client/panel.tsx +288 -5
- package/src/client/store.ts +234 -8
- package/src/impl.ts +66 -1
- package/src/index.ts +16 -1
- package/src/protocol.ts +84 -0
- package/src/update.ts +712 -0
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`). */
|
|
@@ -135,6 +135,22 @@ export interface ChatTranscript {
|
|
|
135
135
|
seq: number
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* One line of the settings page's ACP runtime log. Agent-side notices and
|
|
140
|
+
* session-release reasons land here (bounded, append order oldest→newest);
|
|
141
|
+
* failures of the settings page's OWN actions stay in {@link UiState.notice}.
|
|
142
|
+
*/
|
|
143
|
+
export interface AcpLogEntry {
|
|
144
|
+
id: number
|
|
145
|
+
at: number
|
|
146
|
+
level: 'info' | 'error'
|
|
147
|
+
text: string
|
|
148
|
+
agentId?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Cap on retained runtime-log lines; older entries fall off the front. */
|
|
152
|
+
export const LOG_MAX_ENTRIES = 200
|
|
153
|
+
|
|
138
154
|
export interface UiState {
|
|
139
155
|
open: boolean
|
|
140
156
|
/**
|
|
@@ -156,7 +172,15 @@ export interface UiState {
|
|
|
156
172
|
blocks: Block[]
|
|
157
173
|
permissions: PendingPermission[]
|
|
158
174
|
busy: boolean
|
|
175
|
+
/**
|
|
176
|
+
* The settings page's one-line banner for its OWN action failures. Agent-side
|
|
177
|
+
* runtime messages do not land here; they append to {@link logs} instead.
|
|
178
|
+
*/
|
|
159
179
|
notice?: string
|
|
180
|
+
/** Agent-side runtime log, append order oldest→newest, capped at {@link LOG_MAX_ENTRIES}. */
|
|
181
|
+
logs: AcpLogEntry[]
|
|
182
|
+
/** Lines appended since the user last opened the log; reset by {@link AcpStore.markLogsRead}. */
|
|
183
|
+
logUnread: number
|
|
160
184
|
/**
|
|
161
185
|
* The plugin's OWN default ACP agent (the panel's ★ gesture) — which agent a
|
|
162
186
|
* new ACP-mode conversation starts on. NOT the deployment default model: that
|
|
@@ -298,6 +322,18 @@ export interface UiState {
|
|
|
298
322
|
* (`preset/status` answering `null`), in which case the panel hides the block.
|
|
299
323
|
*/
|
|
300
324
|
preset?: AcpPresetStatus
|
|
325
|
+
/**
|
|
326
|
+
* The installed host package's version (`plugin/version`), rendered as the
|
|
327
|
+
* settings page badge. Absent on a host half without the endpoint, which
|
|
328
|
+
* hides the badge rather than naming a version this browser invented.
|
|
329
|
+
*/
|
|
330
|
+
pluginVersion?: string
|
|
331
|
+
/**
|
|
332
|
+
* The running version and its update status (`plugin/update-check`). Absent
|
|
333
|
+
* on a host half without the endpoint, which hides the update entry instead
|
|
334
|
+
* of offering an install this browser cannot perform.
|
|
335
|
+
*/
|
|
336
|
+
pluginUpdate?: PluginUpdateState
|
|
301
337
|
}
|
|
302
338
|
|
|
303
339
|
interface Listener { (): void }
|
|
@@ -309,6 +345,13 @@ const MAX_WATCHED = 6
|
|
|
309
345
|
/** Raw JSON budget for one event block; the cut is marked. */
|
|
310
346
|
const RAW_MAX = 256_000
|
|
311
347
|
|
|
348
|
+
/**
|
|
349
|
+
* How long a fetched update state stays fresh. The settings panel asks on every
|
|
350
|
+
* mount; without this, every open would hit the registry. A forced check (the
|
|
351
|
+
* card's own button) always goes out.
|
|
352
|
+
*/
|
|
353
|
+
const PLUGIN_UPDATE_TTL_MS = 10 * 60 * 1000
|
|
354
|
+
|
|
312
355
|
/** Pretty-printed JSON of an agent message (never throws, cuts only at {@link RAW_MAX} with a marker). */
|
|
313
356
|
export function jsonText(value: unknown): string {
|
|
314
357
|
let text: string
|
|
@@ -531,6 +574,14 @@ export const DEFAULT_KNOWN_PRESETS: DetectedAgentInfo[] = [
|
|
|
531
574
|
defaultCommand: 'opencode',
|
|
532
575
|
defaultArgs: ['acp'],
|
|
533
576
|
},
|
|
577
|
+
{
|
|
578
|
+
id: 'cursor',
|
|
579
|
+
name: 'Cursor CLI',
|
|
580
|
+
description: 'Cursor CLI — run as `agent acp` over stdio (legacy alias `cursor-agent acp`)',
|
|
581
|
+
detected: false,
|
|
582
|
+
defaultCommand: 'agent',
|
|
583
|
+
defaultArgs: ['acp'],
|
|
584
|
+
},
|
|
534
585
|
{
|
|
535
586
|
id: 'qwen',
|
|
536
587
|
name: 'Qwen Code',
|
|
@@ -576,6 +627,8 @@ export class AcpStore {
|
|
|
576
627
|
blocks: [],
|
|
577
628
|
permissions: [],
|
|
578
629
|
busy: false,
|
|
630
|
+
logs: [],
|
|
631
|
+
logUnread: 0,
|
|
579
632
|
detectedAgents: [...DEFAULT_KNOWN_PRESETS],
|
|
580
633
|
detecting: false,
|
|
581
634
|
modelCatalogs: {},
|
|
@@ -598,6 +651,8 @@ export class AcpStore {
|
|
|
598
651
|
|
|
599
652
|
private readonly listeners = new Set<Listener>()
|
|
600
653
|
private source?: EventSource
|
|
654
|
+
/** Monotonic id source for {@link AcpLogEntry}s (never reset by `clearLogs`). */
|
|
655
|
+
private logSeq = 0
|
|
601
656
|
private readonly lastSeq = new Map<string, number>()
|
|
602
657
|
/** Sessions with a finalized turn (dedups the cancel-fallback + late real turn/end pair). */
|
|
603
658
|
private readonly openTurns = new Set<string>()
|
|
@@ -646,6 +701,22 @@ export class AcpStore {
|
|
|
646
701
|
private readonly cachedModelReads = new Set<string>()
|
|
647
702
|
/** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
|
|
648
703
|
private detectCounts: { total: number; installed: number } | undefined
|
|
704
|
+
/** When {@link UiState.pluginUpdate} was last read from the host (epoch ms). */
|
|
705
|
+
private pluginUpdateFetchedAt = 0
|
|
706
|
+
/**
|
|
707
|
+
* The shared in-flight `plugin/update-check`. The settings section mounts
|
|
708
|
+
* twice (both entries) and the update popover asks too; without this, two
|
|
709
|
+
* mounts in the same tick each fire their own registry read.
|
|
710
|
+
*/
|
|
711
|
+
private pluginUpdateInflight?: Promise<PluginUpdateState | undefined>
|
|
712
|
+
/** Re-entry guard for `plugin/update-apply` (a double click is one install). */
|
|
713
|
+
private pluginUpdateApplying = false
|
|
714
|
+
/**
|
|
715
|
+
* Update read generation. An apply bumps it so a check that was already in
|
|
716
|
+
* flight (started before the install) cannot land afterwards and overwrite
|
|
717
|
+
* the install's verdict with the pre-install `available`.
|
|
718
|
+
*/
|
|
719
|
+
private pluginUpdateGeneration = 0
|
|
649
720
|
|
|
650
721
|
constructor(private readonly rpc: RpcCall, private readonly locale: (zh: string, en: string) => string) {}
|
|
651
722
|
|
|
@@ -669,6 +740,24 @@ export class AcpStore {
|
|
|
669
740
|
for (const listener of [...this.listeners]) listener()
|
|
670
741
|
}
|
|
671
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Build the log patch for one line. Does NOT mutate: the caller merges it
|
|
745
|
+
* into the single `mutate` call it was already making for the same event.
|
|
746
|
+
*/
|
|
747
|
+
private logPatch(level: 'info' | 'error', text: string, agentId?: string): Pick<UiState, 'logs' | 'logUnread'> {
|
|
748
|
+
const entry: AcpLogEntry = {
|
|
749
|
+
id: ++this.logSeq,
|
|
750
|
+
at: Date.now(),
|
|
751
|
+
level,
|
|
752
|
+
text,
|
|
753
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
logs: [...this.state.logs, entry].slice(-LOG_MAX_ENTRIES),
|
|
757
|
+
logUnread: this.state.logUnread + 1,
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
672
761
|
// ---- Streaming update coalescing ----
|
|
673
762
|
|
|
674
763
|
/**
|
|
@@ -783,7 +872,7 @@ export class AcpStore {
|
|
|
783
872
|
/** Snapshot refresh used on panel open and after a stream outage. */
|
|
784
873
|
async resync(): Promise<void> {
|
|
785
874
|
try {
|
|
786
|
-
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
|
|
875
|
+
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
|
|
787
876
|
this.call<{ agents: AgentView[] }>('agents/list'),
|
|
788
877
|
this.call<{ sessions: SessionView[] }>('sessions/list'),
|
|
789
878
|
// The plugin's OWN default ACP agent — a plugin-private id, not a DSH
|
|
@@ -797,6 +886,9 @@ export class AcpStore {
|
|
|
797
886
|
// `undefined` (older half) hides the deadline card instead of showing a
|
|
798
887
|
// number this browser made up.
|
|
799
888
|
this.call<ApprovalDeadlinesResult>('ui/approval-deadlines').catch(() => ({ deadlines: undefined })),
|
|
889
|
+
// The settings badge's data source; older halves answer `unknown
|
|
890
|
+
// endpoint`, which hides the badge.
|
|
891
|
+
this.call<PluginVersionResult>('plugin/version').catch(() => null),
|
|
800
892
|
])
|
|
801
893
|
const detectedAgents = (detectResult && detectResult.agents.length > 0)
|
|
802
894
|
? detectResult.agents
|
|
@@ -811,6 +903,7 @@ export class AcpStore {
|
|
|
811
903
|
preset: presetResult?.preset ?? undefined,
|
|
812
904
|
tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
|
|
813
905
|
approvalDeadlines: deadlinesResult.deadlines,
|
|
906
|
+
pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
|
|
814
907
|
})
|
|
815
908
|
const active = this.state.activeSessionId ?? sessions[0]?.sessionId
|
|
816
909
|
if (active !== undefined) await this.loadTranscript(active)
|
|
@@ -819,6 +912,103 @@ export class AcpStore {
|
|
|
819
912
|
}
|
|
820
913
|
}
|
|
821
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Read the plugin's update state (`plugin/update-check`). Throttled to ten
|
|
917
|
+
* minutes unless `force`: the settings panel asks on mount, and every
|
|
918
|
+
* remount must not hit the registry.
|
|
919
|
+
*
|
|
920
|
+
* An older host serves no such endpoint (`unknown endpoint`). That is a
|
|
921
|
+
* normal degradation, not an error worth a notice: the answer is `undefined`
|
|
922
|
+
* and the panel simply keeps its update entry hidden.
|
|
923
|
+
*/
|
|
924
|
+
async checkPluginUpdate(force = false): Promise<PluginUpdateState | undefined> {
|
|
925
|
+
// One read for every caller while it is in flight, force included: the
|
|
926
|
+
// settings page and the popover mounting together must not hit the
|
|
927
|
+
// registry twice.
|
|
928
|
+
if (this.pluginUpdateInflight !== undefined) return this.pluginUpdateInflight
|
|
929
|
+
const current = this.state.pluginUpdate
|
|
930
|
+
if (!force && current !== undefined && Date.now() - this.pluginUpdateFetchedAt < PLUGIN_UPDATE_TTL_MS) {
|
|
931
|
+
return current
|
|
932
|
+
}
|
|
933
|
+
const generation = this.pluginUpdateGeneration
|
|
934
|
+
const inflight = this.readPluginUpdate(generation)
|
|
935
|
+
this.pluginUpdateInflight = inflight
|
|
936
|
+
try {
|
|
937
|
+
return await inflight
|
|
938
|
+
} finally {
|
|
939
|
+
this.pluginUpdateInflight = undefined
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private async readPluginUpdate(generation: number): Promise<PluginUpdateState | undefined> {
|
|
944
|
+
try {
|
|
945
|
+
const pluginUpdate = await this.call<PluginUpdateState>('plugin/update-check')
|
|
946
|
+
// An install started while this read was in flight: its answer is
|
|
947
|
+
// already stale, and the install's verdict owns the state now.
|
|
948
|
+
if (generation !== this.pluginUpdateGeneration) return undefined
|
|
949
|
+
this.pluginUpdateFetchedAt = Date.now()
|
|
950
|
+
this.mutate({ pluginUpdate })
|
|
951
|
+
return pluginUpdate
|
|
952
|
+
} catch {
|
|
953
|
+
return undefined
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Install one exact version (`plugin/update-apply`). The status flips to
|
|
959
|
+
* `applying` first so the badge answers the click; the host's answer then
|
|
960
|
+
* becomes the state.
|
|
961
|
+
*
|
|
962
|
+
* A failed call does NOT prove the install did not happen: on an HMR host
|
|
963
|
+
* the update uninstalls the plugin before reinstalling it, which can cut the
|
|
964
|
+
* response. Whatever the error, the disk may already carry the new version,
|
|
965
|
+
* so the state becomes `restart-required` (the one neutral verdict for "the
|
|
966
|
+
* running code is not the disk's") with `installed` dropped (unknown, not a
|
|
967
|
+
* fact to display) and the notice asks for a reload rather than restoring
|
|
968
|
+
* `available` and pretending nothing changed.
|
|
969
|
+
*/
|
|
970
|
+
async applyPluginUpdate(version: string): Promise<PluginUpdateState | undefined> {
|
|
971
|
+
if (this.pluginUpdateApplying) return undefined
|
|
972
|
+
this.pluginUpdateApplying = true
|
|
973
|
+
// Any check already in flight predates the install; its answer must not
|
|
974
|
+
// land on top of the install's verdict.
|
|
975
|
+
this.pluginUpdateGeneration += 1
|
|
976
|
+
const previous = this.state.pluginUpdate
|
|
977
|
+
if (previous !== undefined) {
|
|
978
|
+
this.mutate({ pluginUpdate: { ...previous, status: 'applying' } })
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
const pluginUpdate = await this.call<PluginUpdateState>('plugin/update-apply', { version })
|
|
982
|
+
this.pluginUpdateFetchedAt = Date.now()
|
|
983
|
+
this.mutate({ pluginUpdate })
|
|
984
|
+
return pluginUpdate
|
|
985
|
+
} catch (error) {
|
|
986
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
987
|
+
// The disk state is UNKNOWN here: the install may have completed before
|
|
988
|
+
// the response was cut. Keep `latest` for display, drop `installed` so no
|
|
989
|
+
// surface can claim a disk version we never read, and reset the throttle
|
|
990
|
+
// so the next check actually asks the host instead of serving this
|
|
991
|
+
// unknown verdict for ten minutes.
|
|
992
|
+
const interrupted: PluginUpdateState = previous !== undefined
|
|
993
|
+
? { ...previous, status: 'restart-required', installed: undefined, message }
|
|
994
|
+
: { status: 'restart-required', current: this.state.pluginVersion ?? '?', message }
|
|
995
|
+
this.pluginUpdateFetchedAt = 0
|
|
996
|
+
this.mutate({
|
|
997
|
+
pluginUpdate: interrupted,
|
|
998
|
+
notice: this.locale(
|
|
999
|
+
'更新请求已中断;请刷新页面确认当前版本。',
|
|
1000
|
+
'The update request was interrupted. Reload the page to confirm the installed version.',
|
|
1001
|
+
),
|
|
1002
|
+
})
|
|
1003
|
+
return undefined
|
|
1004
|
+
} finally {
|
|
1005
|
+
// Invalidate reads that STARTED during the install: their "applying"
|
|
1006
|
+
// answer would otherwise land on top of this call's final verdict.
|
|
1007
|
+
this.pluginUpdateGeneration += 1
|
|
1008
|
+
this.pluginUpdateApplying = false
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
822
1012
|
async detectAgents(): Promise<void> {
|
|
823
1013
|
this.mutate({ detecting: true })
|
|
824
1014
|
try {
|
|
@@ -1751,6 +1941,17 @@ export class AcpStore {
|
|
|
1751
1941
|
this.mutate({ notice: undefined })
|
|
1752
1942
|
}
|
|
1753
1943
|
|
|
1944
|
+
/** Drop every retained runtime-log line (the unread counter restarts too). */
|
|
1945
|
+
clearLogs(): void {
|
|
1946
|
+
this.mutate({ logs: [], logUnread: 0 })
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/** The user looked at the log: clear the unread badge. */
|
|
1950
|
+
markLogsRead(): void {
|
|
1951
|
+
if (this.state.logUnread === 0) return
|
|
1952
|
+
this.mutate({ logUnread: 0 })
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1754
1955
|
private flashError(error: unknown): void {
|
|
1755
1956
|
this.mutate({ notice: error instanceof Error ? error.message : String(error) })
|
|
1756
1957
|
}
|
|
@@ -1760,7 +1961,7 @@ export class AcpStore {
|
|
|
1760
1961
|
private applyEvent(event: AcpEvent): Promise<void> {
|
|
1761
1962
|
switch (event.type) {
|
|
1762
1963
|
case 'agent/notice': {
|
|
1763
|
-
this.mutate(
|
|
1964
|
+
this.mutate(this.logPatch(event.level, event.text, event.agentId))
|
|
1764
1965
|
return Promise.resolve()
|
|
1765
1966
|
}
|
|
1766
1967
|
case 'agent/status': {
|
|
@@ -1790,6 +1991,7 @@ export class AcpStore {
|
|
|
1790
1991
|
return Promise.resolve()
|
|
1791
1992
|
}
|
|
1792
1993
|
case 'session/closed': {
|
|
1994
|
+
const closingSession = this.state.sessions.find(s => s.sessionId === event.sessionId)
|
|
1793
1995
|
const sessions = this.state.sessions.filter(s => s.sessionId !== event.sessionId)
|
|
1794
1996
|
const active = this.state.activeSessionId === event.sessionId ? sessions[0]?.sessionId : this.state.activeSessionId
|
|
1795
1997
|
this.openTurns.delete(event.sessionId)
|
|
@@ -1805,7 +2007,9 @@ export class AcpStore {
|
|
|
1805
2007
|
chatTranscripts: closedTranscripts,
|
|
1806
2008
|
...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
|
|
1807
2009
|
permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
|
|
1808
|
-
...(event.reason === 'agent-died'
|
|
2010
|
+
...(event.reason === 'agent-died'
|
|
2011
|
+
? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
|
|
2012
|
+
: {}),
|
|
1809
2013
|
})
|
|
1810
2014
|
return Promise.resolve()
|
|
1811
2015
|
}
|
|
@@ -1822,6 +2026,31 @@ export class AcpStore {
|
|
|
1822
2026
|
const chatSessions = this.state.chatSessions.some(s => s.sessionId === event.sessionId)
|
|
1823
2027
|
? this.state.chatSessions.map(s => (s.sessionId === event.sessionId ? { ...s, released: true } : s))
|
|
1824
2028
|
: this.state.chatSessions
|
|
2029
|
+
// One runtime-log line per release cause; `idle` stays silent on
|
|
2030
|
+
// purpose (an expected lifecycle transition, not a fault).
|
|
2031
|
+
const cause = event.cause ?? 'died'
|
|
2032
|
+
let log: Partial<Pick<UiState, 'logs' | 'logUnread'>> = {}
|
|
2033
|
+
if (cause === 'workspace') {
|
|
2034
|
+
log = this.logPatch('info', this.locale(
|
|
2035
|
+
'agent 为切换工作区重启,会话已挂起;再次发送消息将自动恢复。',
|
|
2036
|
+
'agent restarted for its workspace; the session is paused and will resume on your next message.',
|
|
2037
|
+
), event.session.agentId)
|
|
2038
|
+
} else if (cause === 'resume-failed') {
|
|
2039
|
+
log = this.logPatch('error', this.locale(
|
|
2040
|
+
'会话恢复失败;再次发送消息将重试恢复。',
|
|
2041
|
+
'session resume failed; your next message will retry it.',
|
|
2042
|
+
), event.session.agentId)
|
|
2043
|
+
} else if (cause === 'stalled') {
|
|
2044
|
+
log = this.logPatch('error', this.locale(
|
|
2045
|
+
'agent 未响应卡死的回合,会话已挂起;再次发送消息将自动恢复。',
|
|
2046
|
+
'agent did not acknowledge the stalled turn; the session is paused and will resume on your next message.',
|
|
2047
|
+
), event.session.agentId)
|
|
2048
|
+
} else if (cause === 'died') {
|
|
2049
|
+
log = this.logPatch('error', this.locale(
|
|
2050
|
+
'agent 进程已退出,会话已挂起;再次发送消息将自动恢复。',
|
|
2051
|
+
'agent process died; the session is paused and will resume on your next message.',
|
|
2052
|
+
), event.session.agentId)
|
|
2053
|
+
}
|
|
1825
2054
|
this.mutate({
|
|
1826
2055
|
sessions,
|
|
1827
2056
|
...(chatSessions !== this.state.chatSessions ? { chatSessions } : {}),
|
|
@@ -1829,10 +2058,7 @@ export class AcpStore {
|
|
|
1829
2058
|
// An observed transcript keeps its buffer but flips to released, so
|
|
1830
2059
|
// the ACP view can say "会恢复" instead of silently looking dead.
|
|
1831
2060
|
...this.watchedSessionPatch(event.sessionId, event.session),
|
|
1832
|
-
|
|
1833
|
-
'agent 进程已退出,会话已挂起;再次发送消息将自动恢复。',
|
|
1834
|
-
'agent process died; the session is paused and will resume on your next message.',
|
|
1835
|
-
),
|
|
2061
|
+
...log,
|
|
1836
2062
|
})
|
|
1837
2063
|
return Promise.resolve()
|
|
1838
2064
|
}
|
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
|
package/src/protocol.ts
CHANGED
|
@@ -238,6 +238,50 @@ export interface AcpPluginConfig {
|
|
|
238
238
|
* up and reports the `cancelled` stop as-is. Default 2.
|
|
239
239
|
*/
|
|
240
240
|
staleCancelMaxRetries?: number
|
|
241
|
+
/**
|
|
242
|
+
* Inactivity watchdog for a turn the agent owes an answer: how long the turn
|
|
243
|
+
* may stay SILENT before the host finalizes it as stalled.
|
|
244
|
+
*
|
|
245
|
+
* The clock is `LiveSession.lastAgentActivityAt`, refreshed by ANY
|
|
246
|
+
* `session/update`, at every turn start, at each segment start (the host
|
|
247
|
+
* handing the turn back after a card), and when a permission ask arrives or
|
|
248
|
+
* is resolved. The watchdog only READS it while the turn is watched: an OPEN
|
|
249
|
+
* agent segment (always true on the `/acp`-RPC path, which has no driver
|
|
250
|
+
* stream) and no pending approval. The host-card execution phase and an
|
|
251
|
+
* unanswered ask are exempt by that gate, not because the clock is frozen —
|
|
252
|
+
* they legitimately move it too.
|
|
253
|
+
*
|
|
254
|
+
* Firing finalizes the TURN only (an `agent/notice` plus the usual turn
|
|
255
|
+
* end); it does NOT kill the agent process. The wedged `session/prompt` is
|
|
256
|
+
* abandoned, and the next message reclaims the session: parked with
|
|
257
|
+
* `releaseAll` and resumed with `session/load`, or reset outright when the
|
|
258
|
+
* agent declares no `loadSession` (the context is gone and the user is
|
|
259
|
+
* told).
|
|
260
|
+
*
|
|
261
|
+
* Default 600000 ms (10 minutes); `0` disables the WHOLE watchdog, the tool
|
|
262
|
+
* window included.
|
|
263
|
+
*/
|
|
264
|
+
turnStallMs?: number
|
|
265
|
+
/**
|
|
266
|
+
* The longer silence window applied while ANY ACP tool call is still active
|
|
267
|
+
* by `toolCallId` — parallel calls are tracked as a set, so the window lasts
|
|
268
|
+
* until the LAST of them finishes. The agent is working on its own clock in
|
|
269
|
+
* there, so the plain turn window would call a healthy long-running tool
|
|
270
|
+
* stalled.
|
|
271
|
+
*
|
|
272
|
+
* Default 1800000 ms (30 minutes); `0` falls back to
|
|
273
|
+
* {@link AcpPluginConfig.turnStallMs}. It matches
|
|
274
|
+
* `TOOL_OUTPUT_WAIT_MS = 30 * 60 * 1000` in `presets/acp/acp-guard.mjs`, so
|
|
275
|
+
* the two halves of one turn agree on how long a tool may take.
|
|
276
|
+
*/
|
|
277
|
+
turnStallToolMs?: number
|
|
278
|
+
/**
|
|
279
|
+
* How often the watchdog scans for silent turns, in milliseconds. A tuning /
|
|
280
|
+
* test knob only: NO settings-page control exists for it — and none for the
|
|
281
|
+
* two windows above either; the whole watchdog is configured in the patch
|
|
282
|
+
* file. Default 30000 ms.
|
|
283
|
+
*/
|
|
284
|
+
turnStallTickMs?: number
|
|
241
285
|
}
|
|
242
286
|
|
|
243
287
|
// ---- Host → browser events (`/api/acp/stream`, SSE) ----
|
|
@@ -288,6 +332,12 @@ export interface AgentView {
|
|
|
288
332
|
idleTimeoutMs?: number
|
|
289
333
|
}
|
|
290
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Why the host released a session's agent process. The browser turns this into
|
|
337
|
+
* one runtime-log line per cause; `idle` stays silent (expected lifecycle).
|
|
338
|
+
*/
|
|
339
|
+
export type AcpReleaseCause = 'idle' | 'workspace' | 'resume-failed' | 'stalled' | 'died'
|
|
340
|
+
|
|
291
341
|
export type AcpEvent =
|
|
292
342
|
| { type: 'agent/status'; agentId: string; state: AgentProcessState; error?: string; agent?: AgentView }
|
|
293
343
|
| { type: 'agent/notice'; agentId: string; level: 'info' | 'error'; text: string }
|
|
@@ -300,6 +350,8 @@ export type AcpEvent =
|
|
|
300
350
|
sessionId: string
|
|
301
351
|
session: SessionView
|
|
302
352
|
reason: string
|
|
353
|
+
/** Host's release-cause classification; absent on an older host half. */
|
|
354
|
+
cause?: AcpReleaseCause
|
|
303
355
|
}
|
|
304
356
|
| {
|
|
305
357
|
/**
|
|
@@ -564,6 +616,38 @@ export interface AgentIdPayload { agentId: string }
|
|
|
564
616
|
export interface DefaultAgentSetPayload { agentId?: string }
|
|
565
617
|
/** `default/agent`: the plugin's own default agent id, or `undefined` when unset. */
|
|
566
618
|
export type DefaultAgentResult = { agentId?: string }
|
|
619
|
+
/** `plugin/version`: the installed host package's version, for the settings badge. */
|
|
620
|
+
export type PluginVersionResult = { version?: string }
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Where the plugin's own update check stands. The host half only ever reports
|
|
624
|
+
* facts it can read from the harness plugin manager and the npm registry — it
|
|
625
|
+
* never downloads, swaps files, or restarts itself.
|
|
626
|
+
*/
|
|
627
|
+
export type PluginUpdateStatus =
|
|
628
|
+
| 'unsupported' | 'not-installed' | 'source-install' | 'managed'
|
|
629
|
+
| 'current' | 'available' | 'applying' | 'busy'
|
|
630
|
+
| 'applied' | 'restart-required' | 'failed'
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* One update-check answer, shared by `plugin/update-check` and
|
|
634
|
+
* `plugin/update-apply`.
|
|
635
|
+
*/
|
|
636
|
+
export interface PluginUpdateState {
|
|
637
|
+
status: PluginUpdateStatus
|
|
638
|
+
/** 运行中(内存里)的插件版本 */
|
|
639
|
+
current: string
|
|
640
|
+
/** 磁盘上装着的版本(listBundles().version),可读时才有 */
|
|
641
|
+
installed?: string
|
|
642
|
+
/** profile registry 链上最新的稳定版 */
|
|
643
|
+
latest?: string
|
|
644
|
+
/** latest 的 npm 发布时间 ISO */
|
|
645
|
+
publishedAt?: string
|
|
646
|
+
/** 实际按顺序问过的 registry */
|
|
647
|
+
registries?: string[]
|
|
648
|
+
/** 失败/额外说明(英文技术信息,UI 自己负责本地化) */
|
|
649
|
+
message?: string
|
|
650
|
+
}
|
|
567
651
|
|
|
568
652
|
/**
|
|
569
653
|
* How the composer-edge agent tag shows liveness (the blue halo). `breath`
|