dsh-ntr 0.3.0 → 0.3.2

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.
Files changed (51) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +614 -25
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/branch-action.js +133 -0
  8. package/lib/client/composer-mode.js +48 -13
  9. package/lib/client/context-meter.js +86 -0
  10. package/lib/client/index.js +238 -15
  11. package/lib/client/panel.js +127 -30
  12. package/lib/client/released-banner.js +66 -0
  13. package/lib/client/session-controls.js +38 -13
  14. package/lib/client/slash-isolation.js +2 -2
  15. package/lib/client/store.js +403 -16
  16. package/lib/client.js +1267 -70
  17. package/lib/client.js.map +1 -1
  18. package/lib/impl.js +54 -1
  19. package/lib/index.js +7 -0
  20. package/lib/protocol.js +19 -0
  21. package/lib/types/bridge.d.ts +230 -7
  22. package/lib/types/client/acp-ui.d.ts +34 -2
  23. package/lib/types/client/acp-view.d.ts +13 -2
  24. package/lib/types/client/branch-action.d.ts +40 -0
  25. package/lib/types/client/composer-mode.d.ts +28 -0
  26. package/lib/types/client/context-meter.d.ts +51 -0
  27. package/lib/types/client/released-banner.d.ts +34 -0
  28. package/lib/types/client/slash-isolation.d.ts +1 -1
  29. package/lib/types/client/store.d.ts +131 -1
  30. package/lib/types/impl.d.ts +41 -1
  31. package/lib/types/protocol.d.ts +130 -0
  32. package/lib/types/update.d.ts +7 -4
  33. package/lib/update.js +25 -10
  34. package/package.json +2 -1
  35. package/src/bridge.ts +645 -27
  36. package/src/client/acp-ui.tsx +123 -2
  37. package/src/client/acp-view.tsx +30 -5
  38. package/src/client/branch-action.tsx +160 -0
  39. package/src/client/composer-mode.tsx +59 -13
  40. package/src/client/context-meter.tsx +128 -0
  41. package/src/client/index.tsx +265 -14
  42. package/src/client/panel.tsx +229 -41
  43. package/src/client/primitives.d.ts +27 -0
  44. package/src/client/released-banner.tsx +97 -0
  45. package/src/client/session-controls.tsx +34 -11
  46. package/src/client/slash-isolation.ts +2 -1
  47. package/src/client/store.ts +463 -20
  48. package/src/impl.ts +84 -2
  49. package/src/index.ts +8 -0
  50. package/src/protocol.ts +129 -0
  51. package/src/update.ts +21 -8
@@ -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, PluginUpdateState, PluginVersionResult, SessionView } from '../protocol.ts'
9
+ import type { AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpIdleRelease, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentSavePayload, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, ApprovalDeadlinesSetPayload, ApprovalDeadlinesResult, DetectedAgentInfo, IdleReleaseResult, IdleReleaseSetPayload, 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`). */
@@ -96,6 +96,51 @@ export function permissionsForSession(
96
96
  return permissions.filter(permission => permission.dshSessionId === dshSessionId)
97
97
  }
98
98
 
99
+ /** The branch button's six states (the NTR message-actions plan, §D4). */
100
+ export type ChatForkState =
101
+ | 'ready'
102
+ | 'unsupported'
103
+ | 'released'
104
+ | 'busy'
105
+ | 'notTail'
106
+ | 'hostUnavailable'
107
+
108
+ /**
109
+ * The branch click's outcome: the child DSH session id on success, or one
110
+ * actionable failure line (also flashed into the notice banner — the line is
111
+ * the conversation's local copy of the same failure).
112
+ */
113
+ export type ChatForkOutcome = { childId: string } | { failure: string }
114
+
115
+ /**
116
+ * The branch button's state for one conversation, in the precedence it renders:
117
+ * the host service missing beats everything (nothing can work), then the
118
+ * agent's fork capability, then the session's own state (a parked session
119
+ * before a running one), then the DOM's tail verdict, and only then ready.
120
+ *
121
+ * Every input is a fact someone else owns — the row's `fork` verdict is the
122
+ * host's, `hostFork` is the structural probe of the runtime's `sessions.fork`,
123
+ * `isTail` is read from the host's own `[data-turn-tail]` marker, and
124
+ * `liveRunning` is the SSE-driven running flag (the row's own `running` only
125
+ * refreshes on a chat-list read, so it would answer last turn). `undefined`
126
+ * facts degrade CLOSED: no row and no `fork` verdict both read as not-ready,
127
+ * never as a click-through.
128
+ */
129
+ export function forkStateOf(
130
+ row: ChatSessionView | undefined,
131
+ hostFork: boolean,
132
+ isTail: boolean,
133
+ liveRunning?: boolean,
134
+ ): ChatForkState {
135
+ if (!hostFork) return 'hostUnavailable'
136
+ if (row === undefined) return 'released'
137
+ if (row.fork === undefined) return 'unsupported'
138
+ if (row.fork.supported !== true) return row.fork.reason ?? 'unsupported'
139
+ if (liveRunning === true) return 'busy'
140
+ if (!isTail) return 'notTail'
141
+ return 'ready'
142
+ }
143
+
99
144
  /**
100
145
  * Compact countdown for one ask, or `undefined` when there is nothing to count
101
146
  * down (`wait` mode, or a frame without a deadline). Pure so the formatting is
@@ -135,6 +180,22 @@ export interface ChatTranscript {
135
180
  seq: number
136
181
  }
137
182
 
183
+ /**
184
+ * One line of the settings page's ACP runtime log. Agent-side notices and
185
+ * session-release reasons land here (bounded, append order oldest→newest);
186
+ * failures of the settings page's OWN actions stay in {@link UiState.notice}.
187
+ */
188
+ export interface AcpLogEntry {
189
+ id: number
190
+ at: number
191
+ level: 'info' | 'error'
192
+ text: string
193
+ agentId?: string
194
+ }
195
+
196
+ /** Cap on retained runtime-log lines; older entries fall off the front. */
197
+ export const LOG_MAX_ENTRIES = 200
198
+
138
199
  export interface UiState {
139
200
  open: boolean
140
201
  /**
@@ -156,7 +217,15 @@ export interface UiState {
156
217
  blocks: Block[]
157
218
  permissions: PendingPermission[]
158
219
  busy: boolean
220
+ /**
221
+ * The settings page's one-line banner for its OWN action failures. Agent-side
222
+ * runtime messages do not land here; they append to {@link logs} instead.
223
+ */
159
224
  notice?: string
225
+ /** Agent-side runtime log, append order oldest→newest, capped at {@link LOG_MAX_ENTRIES}. */
226
+ logs: AcpLogEntry[]
227
+ /** Lines appended since the user last opened the log; reset by {@link AcpStore.markLogsRead}. */
228
+ logUnread: number
160
229
  /**
161
230
  * The plugin's OWN default ACP agent (the panel's ★ gesture) — which agent a
162
231
  * new ACP-mode conversation starts on. NOT the deployment default model: that
@@ -212,6 +281,12 @@ export interface UiState {
212
281
  * host answer (returned by the same route) is the authority.
213
282
  */
214
283
  approvalDeadlines?: AcpApprovalDeadlines
284
+ /**
285
+ * The idle-release window in force, as the settings page edits it.
286
+ * Absent on a host half from before the tab existed, which hides the
287
+ * tab rather than showing a number this browser invented.
288
+ */
289
+ idleRelease?: AcpIdleRelease
215
290
  /**
216
291
  * Per-chat-session "does this conversation run the ACP preset" verdicts, as
217
292
  * reported by `session/chat-list` (host-read from the session log). The
@@ -603,6 +678,8 @@ export class AcpStore {
603
678
  blocks: [],
604
679
  permissions: [],
605
680
  busy: false,
681
+ logs: [],
682
+ logUnread: 0,
606
683
  detectedAgents: [...DEFAULT_KNOWN_PRESETS],
607
684
  detecting: false,
608
685
  modelCatalogs: {},
@@ -612,6 +689,7 @@ export class AcpStore {
612
689
  chatSessions: [],
613
690
  tagHalo: { ...DEFAULT_TAG_HALO },
614
691
  approvalDeadlines: undefined,
692
+ idleRelease: undefined,
615
693
  chatTranscripts: {},
616
694
  acpMode: {},
617
695
  chatBoundAgent: {},
@@ -625,11 +703,20 @@ export class AcpStore {
625
703
 
626
704
  private readonly listeners = new Set<Listener>()
627
705
  private source?: EventSource
706
+ /** Monotonic id source for {@link AcpLogEntry}s (never reset by `clearLogs`). */
707
+ private logSeq = 0
628
708
  private readonly lastSeq = new Map<string, number>()
629
709
  /** Sessions with a finalized turn (dedups the cancel-fallback + late real turn/end pair). */
630
710
  private readonly openTurns = new Set<string>()
631
711
  /** In-flight `session/chat-list` reads, keyed by chat session id. */
632
712
  private readonly chatSessionReads = new Map<string, { dropped: boolean; done: Promise<void> }>()
713
+ /**
714
+ * ACP session ids whose chat-list row was requested mid-turn (one read each).
715
+ * A conversation's first ACP session does not exist when the composer's
716
+ * mount/running read fires, so its first `usage_update` would find no row to
717
+ * patch; `turn/start` asks again once the host has materialized the session.
718
+ */
719
+ private readonly chatSessionRowReads = new Set<string>()
633
720
  /**
634
721
  * Chat sessions whose list has answered at least once in this page. The
635
722
  * FIRST answer only seeds the lock; a lock appearing on a later answer is a
@@ -712,6 +799,24 @@ export class AcpStore {
712
799
  for (const listener of [...this.listeners]) listener()
713
800
  }
714
801
 
802
+ /**
803
+ * Build the log patch for one line. Does NOT mutate: the caller merges it
804
+ * into the single `mutate` call it was already making for the same event.
805
+ */
806
+ private logPatch(level: 'info' | 'error', text: string, agentId?: string): Pick<UiState, 'logs' | 'logUnread'> {
807
+ const entry: AcpLogEntry = {
808
+ id: ++this.logSeq,
809
+ at: Date.now(),
810
+ level,
811
+ text,
812
+ ...(agentId === undefined ? {} : { agentId }),
813
+ }
814
+ return {
815
+ logs: [...this.state.logs, entry].slice(-LOG_MAX_ENTRIES),
816
+ logUnread: this.state.logUnread + 1,
817
+ }
818
+ }
819
+
715
820
  // ---- Streaming update coalescing ----
716
821
 
717
822
  /**
@@ -737,9 +842,16 @@ export class AcpStore {
737
842
  else list.push(item)
738
843
  }
739
844
  let chatTranscripts = this.state.chatTranscripts
845
+ let chatSessions = this.state.chatSessions
740
846
  let blocksChanged = false
741
847
  for (const [sessionId, items] of bySession) {
742
848
  const last = items[items.length - 1]
849
+ // `usage_update` is state, not stream content: it never becomes a block,
850
+ // but both observed snapshots that carry usage must move with it —
851
+ // otherwise the ACP view header and the composer's occupancy ring only
852
+ // refresh when some other RPC re-reads the session.
853
+ const usage = usageOfBatch(items)
854
+ if (usage !== undefined) chatSessions = withSessionUsage(chatSessions, sessionId, usage)
743
855
  // The observed transcript folds every frame (a watched session's cards
744
856
  // and prose); blocks are copied only when something actually changed, so
745
857
  // a usage-only tick costs one record object, not a re-render payload.
@@ -749,9 +861,12 @@ export class AcpStore {
749
861
  for (const item of items) {
750
862
  if (foldUpdate(record.blocks, item.update as Record<string, unknown>)) changed = true
751
863
  }
864
+ const session = usage !== undefined && !sameUsage(record.session.usage, usage)
865
+ ? { ...record.session, usage }
866
+ : record.session
752
867
  chatTranscripts = {
753
868
  ...chatTranscripts,
754
- [sessionId]: { ...record, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
869
+ [sessionId]: { ...record, session, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
755
870
  }
756
871
  }
757
872
  if (sessionId !== this.state.activeSessionId) {
@@ -772,6 +887,7 @@ export class AcpStore {
772
887
  }
773
888
  const patch: Partial<UiState> = {}
774
889
  if (chatTranscripts !== this.state.chatTranscripts) patch.chatTranscripts = chatTranscripts
890
+ if (chatSessions !== this.state.chatSessions) patch.chatSessions = chatSessions
775
891
  if (blocksChanged) patch.blocks = [...this.state.blocks]
776
892
  if (Object.keys(patch).length > 0) this.mutate(patch)
777
893
  }
@@ -826,7 +942,7 @@ export class AcpStore {
826
942
  /** Snapshot refresh used on panel open and after a stream outage. */
827
943
  async resync(): Promise<void> {
828
944
  try {
829
- const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
945
+ const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, idleReleaseResult, versionResult] = await Promise.all([
830
946
  this.call<{ agents: AgentView[] }>('agents/list'),
831
947
  this.call<{ sessions: SessionView[] }>('sessions/list'),
832
948
  // The plugin's OWN default ACP agent — a plugin-private id, not a DSH
@@ -840,6 +956,9 @@ export class AcpStore {
840
956
  // `undefined` (older half) hides the deadline card instead of showing a
841
957
  // number this browser made up.
842
958
  this.call<ApprovalDeadlinesResult>('ui/approval-deadlines').catch(() => ({ deadlines: undefined })),
959
+ // `undefined` (older half) hides the tab instead of showing a
960
+ // number this browser made up.
961
+ this.call<IdleReleaseResult>('ui/idle-release').catch(() => ({ idleRelease: undefined })),
843
962
  // The settings badge's data source; older halves answer `unknown
844
963
  // endpoint`, which hides the badge.
845
964
  this.call<PluginVersionResult>('plugin/version').catch(() => null),
@@ -857,8 +976,13 @@ export class AcpStore {
857
976
  preset: presetResult?.preset ?? undefined,
858
977
  tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
859
978
  approvalDeadlines: deadlinesResult.deadlines,
979
+ idleRelease: idleReleaseResult.idleRelease,
860
980
  pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
861
981
  })
982
+ // `sessions/list` is the `/acp` RPC face; the composer's chat rows
983
+ // (agent modes, occupancy) are a separate list. A reconnect must refresh
984
+ // that list too or the ring keeps a pre-outage sample until a turn ends.
985
+ if (this.state.chatSessionsFor !== undefined) void this.loadChatSessions(this.state.chatSessionsFor)
862
986
  const active = this.state.activeSessionId ?? sessions[0]?.sessionId
863
987
  if (active !== undefined) await this.loadTranscript(active)
864
988
  } catch (error) {
@@ -1319,9 +1443,9 @@ export class AcpStore {
1319
1443
  * the frame's authority.
1320
1444
  */
1321
1445
  loadChatSessions(dshSessionId: string, options?: { settleVerdict?: boolean }): Promise<void> {
1322
- // Two composer entries read this (the agent-mode control and the ACP
1323
- // cluster) and both re-read when a turn settles; one round-trip answers
1324
- // for both instead of two racing ones. A caller arriving mid-read waits
1446
+ // The composer entries (agent-mode control, ACP cluster, occupancy ring)
1447
+ // read this and re-read when a turn settles; one round-trip answers for
1448
+ // all of them instead of racing reads. A caller arriving mid-read waits
1325
1449
  // for THAT read rather than returning immediately: the slash source's
1326
1450
  // one-shot "do I have a verdict yet?" ask is only useful if it can
1327
1451
  // receive the answer of a read already in flight.
@@ -1340,7 +1464,7 @@ export class AcpStore {
1340
1464
  ): Promise<void> {
1341
1465
  const settle = options?.settleVerdict === true || !this.acpModeLive.has(dshSessionId)
1342
1466
  try {
1343
- const { sessions, acpMode, boundAgentId, lockedAgentId, choice } = await this.call<{ sessions: ChatSessionView[]; acpMode?: boolean; boundAgentId?: string; lockedAgentId?: string; choice?: AcpConversationChoice }>('session/chat-list', { dshSessionId, refresh: true })
1467
+ const { sessions = [], acpMode, boundAgentId, lockedAgentId, choice } = await this.call<{ sessions: ChatSessionView[]; acpMode?: boolean; boundAgentId?: string; lockedAgentId?: string; choice?: AcpConversationChoice }>('session/chat-list', { dshSessionId, refresh: true })
1344
1468
  // A committed `agent-preset/selected` frame may have settled this
1345
1469
  // session's verdict while the read was in flight; the host answer then
1346
1470
  // describes the log as of BEFORE that switch and must not overwrite it.
@@ -1489,6 +1613,13 @@ export class AcpStore {
1489
1613
  * being the one authorized exception. Same no-op-when-unchanged discipline as
1490
1614
  * the other accessors, so a frame restating the held verdict costs no
1491
1615
  * listener churn.
1616
+ *
1617
+ * The seat gate (`syncAcpMode`) is the SECOND caller: it folds the runtime
1618
+ * row's own `agentPreset` — the same session-log source the frames announce —
1619
+ * through here, in both directions. That hands the row the frame's authority
1620
+ * on purpose (one provenance, one rank): a stale projection is corrected by
1621
+ * the gate's next evaluation with fresher row data, and only HOST READS are
1622
+ * barred from regressing what the runtime itself already said.
1492
1623
  */
1493
1624
  noteAcpMode(dshSessionId: string, acpMode: boolean): void {
1494
1625
  this.acpModeLive.add(dshSessionId)
@@ -1572,10 +1703,96 @@ export class AcpStore {
1572
1703
  async setChatSessionMode(dshSessionId: string, modeId: string, agentId?: string): Promise<void> {
1573
1704
  try {
1574
1705
  await this.call('session/chat-mode-set', { dshSessionId, modeId, ...(agentId === undefined ? {} : { agentId }) })
1706
+ const safeAgent = (agentId && agentId.trim()) || undefined
1707
+ this.writeChoice(dshSessionId, choice => ({ ...choice, mode: modeId }), safeAgent)
1708
+ if (this.state.chatSessionsFor === dshSessionId) {
1709
+ const owner = safeAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent
1710
+ let changed = false
1711
+ const chatSessions = this.state.chatSessions.map(s => {
1712
+ if (s.agentId !== owner || !s.modes || s.modes.currentModeId === modeId) return s
1713
+ changed = true
1714
+ return { ...s, modes: { ...s.modes, currentModeId: modeId } }
1715
+ })
1716
+ if (changed) this.mutate({ chatSessions })
1717
+ }
1718
+ } catch (error) {
1719
+ this.flashError(error)
1720
+ }
1721
+ await this.loadChatSessions(dshSessionId)
1722
+ }
1723
+
1724
+ /**
1725
+ * Bring the conversation's released ACP session back on a fresh process —
1726
+ * the released banner's explicit form of the "next message resumes it"
1727
+ * promise (`session/chat-resume`). A failure is reported through the notice
1728
+ * AND rethrown (the `respondPermission` contract), so the banner can pin
1729
+ * the message beside the button the user just pressed; the binding is
1730
+ * re-read either way so the row follows the host's real answer.
1731
+ */
1732
+ async resumeChatSession(dshSessionId: string): Promise<void> {
1733
+ let failure: unknown
1734
+ try {
1735
+ await this.call('session/chat-resume', { dshSessionId })
1575
1736
  } catch (error) {
1576
1737
  this.flashError(error)
1738
+ failure = error
1577
1739
  }
1578
1740
  await this.loadChatSessions(dshSessionId)
1741
+ if (failure !== undefined) throw failure
1742
+ }
1743
+
1744
+ /**
1745
+ * Branch one conversation (the NTR branch button's click): the fixed
1746
+ * two-stage order — the HOST forks the DSH session tree first (copied
1747
+ * history, `(1)` title, workspace), then the plugin adopts the ACP side via
1748
+ * `session/chat-fork-adopt`, which runs the agent's own `session/fork` so
1749
+ * the child actually remembers the parent context. Host first is structural:
1750
+ * ACP fork has no cut-point parameter and nothing to return to if the host
1751
+ * half failed second (see the plan's §D7).
1752
+ *
1753
+ * Resolves `{ childId }` on success, or `{ failure }` with one actionable
1754
+ * line — the reason is ALSO flashed into the notice banner (a user-action
1755
+ * failure, so the banner is its surface), and the local line is what the
1756
+ * conversation renders beside the button (the user is not on the settings
1757
+ * page). A failed adopt leaves the child an ordinary copied-history
1758
+ * conversation (its first message starts a fresh `session/new` — today's
1759
+ * behavior), never a half-bound one, and NOTHING is opened.
1760
+ *
1761
+ * `forkHost` is the runtime's `sessions.fork` face, resolved structurally by
1762
+ * the entry and injected here: the store stays transport-free.
1763
+ */
1764
+ async forkChatSession(
1765
+ dshSessionId: string,
1766
+ forkHost: (sessionId: string) => Promise<unknown>,
1767
+ ): Promise<ChatForkOutcome> {
1768
+ let childId: unknown
1769
+ try {
1770
+ childId = await forkHost(dshSessionId)
1771
+ } catch (error) {
1772
+ const failure = error instanceof Error ? error.message : String(error)
1773
+ this.flashError(failure)
1774
+ return { failure }
1775
+ }
1776
+ if (typeof childId !== 'string' || childId === '') {
1777
+ const failure = this.locale(
1778
+ '宿主没有返回分支会话,分支未完成。',
1779
+ 'The host returned no branched session; the branch did not complete.',
1780
+ )
1781
+ this.flashError(new Error(failure))
1782
+ return { failure }
1783
+ }
1784
+ try {
1785
+ await this.call('session/chat-fork-adopt', { dshSessionId: childId, parentDshSessionId: dshSessionId })
1786
+ } catch (error) {
1787
+ const reason = error instanceof Error ? error.message : String(error)
1788
+ const failure = this.locale(
1789
+ `分支会话已创建,但没有接上 agent 的上下文(${reason});在子会话里发送的第一条消息会开启全新会话。`,
1790
+ `The branched session was created but the agent context was not attached (${reason}); its first message will start a fresh session.`,
1791
+ )
1792
+ this.flashError(new Error(failure))
1793
+ return { failure }
1794
+ }
1795
+ return { childId }
1579
1796
  }
1580
1797
 
1581
1798
  // ---- ACP transcript observation (the conversation's ACP view) ----
@@ -1753,6 +1970,36 @@ export class AcpStore {
1753
1970
  await this.writeApprovalDeadlines({ reset: true })
1754
1971
  }
1755
1972
 
1973
+ /**
1974
+ * Write the idle-release window (in ms).
1975
+ *
1976
+ * The host answer — the same route returns the window now in force — is
1977
+ * the authority, so an optimistic write that the host trims self-corrects
1978
+ * instead of drifting from what runs.
1979
+ */
1980
+ async setIdleRelease(idleTimeoutMs: number): Promise<void> {
1981
+ await this.writeIdleRelease({ idleTimeoutMs })
1982
+ }
1983
+
1984
+ /**
1985
+ * Drop the persisted override: the config file (or the built-in default)
1986
+ * decides again.
1987
+ */
1988
+ async resetIdleRelease(): Promise<void> {
1989
+ await this.writeIdleRelease({ reset: true })
1990
+ }
1991
+
1992
+ private async writeIdleRelease(payload: IdleReleaseSetPayload): Promise<void> {
1993
+ const previous = this.state.idleRelease
1994
+ try {
1995
+ const result = await this.call<IdleReleaseResult>('ui/idle-release-set', payload)
1996
+ this.mutate({ idleRelease: result.idleRelease })
1997
+ } catch (error) {
1998
+ this.mutate({ idleRelease: previous })
1999
+ this.flashError(error)
2000
+ }
2001
+ }
2002
+
1756
2003
  private async writeApprovalDeadlines(payload: ApprovalDeadlinesSetPayload): Promise<void> {
1757
2004
  const previous = this.state.approvalDeadlines
1758
2005
  // Optimistic only where it is safe: the effective numbers and the mode move
@@ -1806,7 +2053,19 @@ export class AcpStore {
1806
2053
  // Optimistic for the same reason the binding is: the host recorded the
1807
2054
  // pick, and a conversation with no ACP session yet would otherwise show
1808
2055
  // the agent's default until its first turn created one.
1809
- this.writeChoice(dshSessionId, choice => ({ ...choice, modelId }), agentId)
2056
+ const safeAgent = (agentId && agentId.trim()) || undefined
2057
+ const targetAgent = safeAgent ?? (modelId ? splitModelAgentId(modelId) : undefined)
2058
+ this.writeChoice(dshSessionId, choice => ({ ...choice, modelId }), targetAgent)
2059
+ if (this.state.chatSessionsFor === dshSessionId) {
2060
+ const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent
2061
+ let changed = false
2062
+ const chatSessions = this.state.chatSessions.map(s => {
2063
+ if (s.agentId !== owner || s.modelId === modelId) return s
2064
+ changed = true
2065
+ return { ...s, modelId }
2066
+ })
2067
+ if (changed) this.mutate({ chatSessions })
2068
+ }
1810
2069
  } catch (error) {
1811
2070
  this.flashError(error)
1812
2071
  throw error
@@ -1821,10 +2080,21 @@ export class AcpStore {
1821
2080
  ...(effort === undefined ? {} : { effort }),
1822
2081
  ...(agentId === undefined ? {} : { agentId }),
1823
2082
  })
2083
+ const targetAgent = (agentId && agentId.trim()) || undefined
1824
2084
  this.writeChoice(dshSessionId, choice => {
1825
2085
  const { effort: _dropped, ...rest } = choice
1826
2086
  return effort === undefined ? rest : { ...rest, effort }
1827
- }, agentId)
2087
+ }, targetAgent)
2088
+ if (this.state.chatSessionsFor === dshSessionId) {
2089
+ const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent
2090
+ let changed = false
2091
+ const chatSessions = this.state.chatSessions.map(s => {
2092
+ if (s.agentId !== owner || s.reasoningEffort === effort) return s
2093
+ changed = true
2094
+ return { ...s, reasoningEffort: effort }
2095
+ })
2096
+ if (changed) this.mutate({ chatSessions })
2097
+ }
1828
2098
  } catch (error) {
1829
2099
  this.flashError(error)
1830
2100
  throw error
@@ -1895,6 +2165,17 @@ export class AcpStore {
1895
2165
  this.mutate({ notice: undefined })
1896
2166
  }
1897
2167
 
2168
+ /** Drop every retained runtime-log line (the unread counter restarts too). */
2169
+ clearLogs(): void {
2170
+ this.mutate({ logs: [], logUnread: 0 })
2171
+ }
2172
+
2173
+ /** The user looked at the log: clear the unread badge. */
2174
+ markLogsRead(): void {
2175
+ if (this.state.logUnread === 0) return
2176
+ this.mutate({ logUnread: 0 })
2177
+ }
2178
+
1898
2179
  private flashError(error: unknown): void {
1899
2180
  this.mutate({ notice: error instanceof Error ? error.message : String(error) })
1900
2181
  }
@@ -1904,7 +2185,7 @@ export class AcpStore {
1904
2185
  private applyEvent(event: AcpEvent): Promise<void> {
1905
2186
  switch (event.type) {
1906
2187
  case 'agent/notice': {
1907
- this.mutate({ notice: event.text })
2188
+ this.mutate(this.logPatch(event.level, event.text, event.agentId))
1908
2189
  return Promise.resolve()
1909
2190
  }
1910
2191
  case 'agent/status': {
@@ -1926,14 +2207,24 @@ export class AcpStore {
1926
2207
  return Promise.resolve()
1927
2208
  }
1928
2209
  case 'session/meta': {
1929
- // Host re-published the session view after mode/config changes.
2210
+ // Host re-published the session view after mode/config changes or a
2211
+ // resume. The composer's occupancy ring reads the chat-list row, so
2212
+ // that row must follow the snapshot's usage too — including an absent
2213
+ // one, which must clear an older reading rather than outlive it.
1930
2214
  const sessions = this.state.sessions.some(s => s.sessionId === event.sessionId)
1931
2215
  ? this.state.sessions.map(s => (s.sessionId === event.sessionId ? event.session : s))
1932
2216
  : this.state.sessions
1933
- this.mutate({ sessions, ...this.watchedSessionPatch(event.sessionId, event.session) })
2217
+ let chatSessions = withSessionUsage(this.state.chatSessions, event.sessionId, event.session.usage)
2218
+ chatSessions = withSessionModes(chatSessions, event.sessionId, event.session.modes)
2219
+ this.mutate({
2220
+ sessions,
2221
+ ...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
2222
+ ...this.watchedSessionPatch(event.sessionId, event.session),
2223
+ })
1934
2224
  return Promise.resolve()
1935
2225
  }
1936
2226
  case 'session/closed': {
2227
+ const closingSession = this.state.sessions.find(s => s.sessionId === event.sessionId)
1937
2228
  const sessions = this.state.sessions.filter(s => s.sessionId !== event.sessionId)
1938
2229
  const active = this.state.activeSessionId === event.sessionId ? sessions[0]?.sessionId : this.state.activeSessionId
1939
2230
  this.openTurns.delete(event.sessionId)
@@ -1949,7 +2240,9 @@ export class AcpStore {
1949
2240
  chatTranscripts: closedTranscripts,
1950
2241
  ...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
1951
2242
  permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
1952
- ...(event.reason === 'agent-died' ? { notice: this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died') } : {}),
2243
+ ...(event.reason === 'agent-died'
2244
+ ? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
2245
+ : {}),
1953
2246
  })
1954
2247
  return Promise.resolve()
1955
2248
  }
@@ -1963,9 +2256,32 @@ export class AcpStore {
1963
2256
  // The composer's own list (`session/chat-list` views) must flip too, or
1964
2257
  // the agent tag's live halo keeps breathing for a process that is gone:
1965
2258
  // nothing else re-reads this list until the next turn settles.
1966
- const chatSessions = this.state.chatSessions.some(s => s.sessionId === event.sessionId)
1967
- ? this.state.chatSessions.map(s => (s.sessionId === event.sessionId ? { ...s, released: true } : s))
1968
- : this.state.chatSessions
2259
+ const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, true)
2260
+ // One runtime-log line per release cause; `idle` stays silent on
2261
+ // purpose (an expected lifecycle transition, not a fault).
2262
+ const cause = event.cause ?? 'died'
2263
+ let log: Partial<Pick<UiState, 'logs' | 'logUnread'>> = {}
2264
+ if (cause === 'workspace') {
2265
+ log = this.logPatch('info', this.locale(
2266
+ 'agent 为切换工作区重启,会话已挂起;再次发送消息将自动恢复。',
2267
+ 'agent restarted for its workspace; the session is paused and will resume on your next message.',
2268
+ ), event.session.agentId)
2269
+ } else if (cause === 'resume-failed') {
2270
+ log = this.logPatch('error', this.locale(
2271
+ '会话恢复失败;再次发送消息将重试恢复。',
2272
+ 'session resume failed; your next message will retry it.',
2273
+ ), event.session.agentId)
2274
+ } else if (cause === 'stalled') {
2275
+ log = this.logPatch('error', this.locale(
2276
+ 'agent 未响应卡死的回合,会话已挂起;再次发送消息将自动恢复。',
2277
+ 'agent did not acknowledge the stalled turn; the session is paused and will resume on your next message.',
2278
+ ), event.session.agentId)
2279
+ } else if (cause === 'died') {
2280
+ log = this.logPatch('error', this.locale(
2281
+ 'agent 进程已退出,会话已挂起;再次发送消息将自动恢复。',
2282
+ 'agent process died; the session is paused and will resume on your next message.',
2283
+ ), event.session.agentId)
2284
+ }
1969
2285
  this.mutate({
1970
2286
  sessions,
1971
2287
  ...(chatSessions !== this.state.chatSessions ? { chatSessions } : {}),
@@ -1973,10 +2289,7 @@ export class AcpStore {
1973
2289
  // An observed transcript keeps its buffer but flips to released, so
1974
2290
  // the ACP view can say "会恢复" instead of silently looking dead.
1975
2291
  ...this.watchedSessionPatch(event.sessionId, event.session),
1976
- notice: this.locale(
1977
- 'agent 进程已退出,会话已挂起;再次发送消息将自动恢复。',
1978
- 'agent process died; the session is paused and will resume on your next message.',
1979
- ),
2292
+ ...log,
1980
2293
  })
1981
2294
  return Promise.resolve()
1982
2295
  }
@@ -1994,8 +2307,15 @@ export class AcpStore {
1994
2307
  // an observed transcript must drop both its blocks and its watermark
1995
2308
  // or the replay would be read as a gap and refetched forever.
1996
2309
  const watched = this.state.chatTranscripts[event.sessionId]
2310
+ // ...and the composer's own list must flip back to live on the same
2311
+ // event (mirror of the `session/released` flip): the released banner
2312
+ // and the tag's halo read that row, and nothing else re-reads it until
2313
+ // a turn settles — a resume would leave the banner claiming a parked
2314
+ // process that is already back.
2315
+ const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, false)
1997
2316
  this.mutate({
1998
2317
  sessions,
2318
+ ...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
1999
2319
  ...(isActive ? { blocks: [] } : {}),
2000
2320
  ...(watched === undefined ? {} : {
2001
2321
  chatTranscripts: {
@@ -2108,6 +2428,17 @@ export class AcpStore {
2108
2428
  case 'turn/start': {
2109
2429
  this.openTurns.add(event.sessionId)
2110
2430
  this.markWatchedRunning(event.sessionId, true)
2431
+ // The session materializes inside this turn; if the composer's list
2432
+ // has not seen its row yet (the mount/running read beat session/new),
2433
+ // read once now so the occupancy ring has a row for the live usage
2434
+ // ticks. The per-session set bounds this to one extra read.
2435
+ const chatSessionId = this.state.chatSessionsFor
2436
+ if (chatSessionId !== undefined
2437
+ && !this.state.chatSessions.some(row => row.sessionId === event.sessionId)
2438
+ && !this.chatSessionRowReads.has(event.sessionId)) {
2439
+ this.chatSessionRowReads.add(event.sessionId)
2440
+ void this.loadChatSessions(chatSessionId)
2441
+ }
2111
2442
  if (event.sessionId === this.state.activeSessionId) {
2112
2443
  this.state.blocks.push({ kind: 'user', text: event.text })
2113
2444
  trimBlocks(this.state.blocks)
@@ -2283,6 +2614,118 @@ export const STATE_ONLY_UPDATES: ReadonlySet<string> = new Set([
2283
2614
  'session_info_update',
2284
2615
  ])
2285
2616
 
2617
+ /**
2618
+ * Context occupancy carried by one raw `usage_update` frame, in the shape the
2619
+ * session snapshots already use. A malformed frame is ignored (the last good
2620
+ * sample stays on screen) instead of zeroing the meter.
2621
+ */
2622
+ export function usageFromUpdate(update: Record<string, unknown>): SessionView['usage'] | undefined {
2623
+ const used = update.used
2624
+ const size = update.size
2625
+ if (typeof used !== 'number' || !Number.isFinite(used)) return undefined
2626
+ if (typeof size !== 'number' || !Number.isFinite(size)) return undefined
2627
+ const cost = update.cost
2628
+ if (cost !== null && typeof cost === 'object' && !Array.isArray(cost)) {
2629
+ const row = cost as Record<string, unknown>
2630
+ if (typeof row.amount === 'number' && Number.isFinite(row.amount)
2631
+ && typeof row.currency === 'string' && row.currency !== '') {
2632
+ return { used, size, cost: { amount: row.amount, currency: row.currency } }
2633
+ }
2634
+ }
2635
+ return { used, size }
2636
+ }
2637
+
2638
+ function sameUsage(left: SessionView['usage'], right: SessionView['usage']): boolean {
2639
+ if (left === right) return true
2640
+ if (left === undefined || right === undefined) return false
2641
+ if (left.used !== right.used || left.size !== right.size) return false
2642
+ const leftCost = left.cost
2643
+ const rightCost = right.cost
2644
+ if (leftCost === undefined || rightCost === undefined) return leftCost === rightCost
2645
+ return leftCost.amount === rightCost.amount && leftCost.currency === rightCost.currency
2646
+ }
2647
+
2648
+ /** Patch one session row's usage; other rows and an unchanged value keep their identities. */
2649
+ function withSessionUsage<T extends { sessionId: string; usage?: SessionView['usage'] }>(
2650
+ rows: T[],
2651
+ sessionId: string,
2652
+ usage: SessionView['usage'],
2653
+ ): T[] {
2654
+ let changed = false
2655
+ const next = rows.map(row => {
2656
+ if (row.sessionId !== sessionId || sameUsage(row.usage, usage)) return row
2657
+ changed = true
2658
+ return { ...row, usage }
2659
+ })
2660
+ return changed ? next : rows
2661
+ }
2662
+
2663
+ /**
2664
+ * Flip one chat row's `released` flag (release/resume bookkeeping). The
2665
+ * composer's own list (`session/chat-list` rows) has no other live writer —
2666
+ * `session/released` must mark it, `session/reset` must un-mark it, or the
2667
+ * agent tag's halo and the released banner read yesterday's process state.
2668
+ */
2669
+ function withSessionReleased<T extends { sessionId: string; released?: boolean }>(
2670
+ rows: T[],
2671
+ sessionId: string,
2672
+ released: boolean,
2673
+ ): T[] {
2674
+ let changed = false
2675
+ const next = rows.map(row => {
2676
+ if (row.sessionId !== sessionId || (row.released === true) === released) return row
2677
+ changed = true
2678
+ return { ...row, released }
2679
+ })
2680
+ return changed ? next : rows
2681
+ }
2682
+
2683
+ function sameModes(a?: SessionView['modes'], b?: SessionView['modes']): boolean {
2684
+ if (a === b) return true
2685
+ if (!a || !b) return false
2686
+ if (a.currentModeId !== b.currentModeId) return false
2687
+ const aModes = a.availableModes ?? []
2688
+ const bModes = b.availableModes ?? []
2689
+ if (aModes.length !== bModes.length) return false
2690
+ return aModes.every((m, i) => {
2691
+ const o = bModes[i]
2692
+ return m != null && o != null && m.id === o.id && m.name === o.name && m.description === o.description
2693
+ })
2694
+ }
2695
+
2696
+ function withSessionModes<T extends { sessionId: string; modes?: SessionView['modes'] }>(
2697
+ rows: T[],
2698
+ sessionId: string,
2699
+ modes?: SessionView['modes'],
2700
+ ): T[] {
2701
+ if (!modes) return rows
2702
+ let changed = false
2703
+ const next = rows.map(row => {
2704
+ if (row.sessionId !== sessionId || sameModes(row.modes, modes)) return row
2705
+ changed = true
2706
+ return { ...row, modes }
2707
+ })
2708
+ return changed ? next : rows
2709
+ }
2710
+
2711
+ function splitModelAgentId(modelId: string): string | undefined {
2712
+ const at = modelId.indexOf('@')
2713
+ const agent = at === -1 ? modelId.trim() : (at === 0 ? '' : modelId.slice(0, at).trim())
2714
+ return agent || undefined
2715
+ }
2716
+
2717
+ /** Last well-formed `usage_update` in one coalesced batch (later ticks win). */
2718
+ function usageOfBatch(items: ReadonlyArray<{ update: unknown }>): SessionView['usage'] | undefined {
2719
+ let usage: SessionView['usage'] | undefined
2720
+ for (const item of items) {
2721
+ const update = item.update as Record<string, unknown>
2722
+ if (update.sessionUpdate !== 'usage_update') continue
2723
+ const parsed = usageFromUpdate(update)
2724
+ if (parsed !== undefined) usage = parsed
2725
+ }
2726
+ return usage
2727
+ }
2728
+
2286
2729
  function trimBlocks(blocks: Block[]): void {
2287
2730
  if (blocks.length > MAX_BLOCKS) blocks.splice(0, blocks.length - MAX_BLOCKS)
2288
2731
  }