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.
@@ -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({ notice: event.text })
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' ? { notice: this.locale('会话因 agent 进程退出而终止', 'session ended: agent process 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
- notice: this.locale(
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`