dsh-ntr 0.3.1 → 0.3.3
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 +3 -1
- package/README.md +3 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +2436 -82
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/activity-pill.js +156 -0
- package/lib/client/branch-action.js +149 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +279 -16
- package/lib/client/model-seat.js +8 -2
- package/lib/client/panel.js +96 -29
- package/lib/client/released-banner.js +66 -0
- package/lib/client/session-controls.js +38 -13
- package/lib/client/slash-isolation.js +2 -2
- package/lib/client/store.js +380 -13
- package/lib/client.js +1364 -70
- package/lib/client.js.map +1 -1
- package/lib/impl.js +1229 -8
- package/lib/index.js +34 -0
- package/lib/protocol.js +19 -0
- package/lib/tps.js +32 -0
- package/lib/types/bridge.d.ts +850 -18
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/activity-pill.d.ts +81 -0
- package/lib/types/client/branch-action.d.ts +47 -0
- package/lib/types/client/composer-mode.d.ts +28 -0
- package/lib/types/client/context-meter.d.ts +51 -0
- package/lib/types/client/model-seat.d.ts +21 -0
- package/lib/types/client/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +108 -1
- package/lib/types/impl.d.ts +94 -7
- package/lib/types/protocol.d.ts +145 -0
- package/lib/types/tps.d.ts +17 -0
- package/package.json +2 -1
- package/src/bridge.ts +2754 -86
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/activity-pill.tsx +201 -0
- package/src/client/branch-action.tsx +178 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +298 -15
- package/src/client/model-seat.tsx +28 -3
- package/src/client/panel.tsx +157 -40
- package/src/client/primitives.d.ts +27 -0
- package/src/client/released-banner.tsx +97 -0
- package/src/client/session-controls.tsx +34 -11
- package/src/client/slash-isolation.ts +2 -1
- package/src/client/store.ts +412 -14
- package/src/impl.ts +1251 -13
- package/src/index.ts +33 -0
- package/src/protocol.ts +145 -0
- package/src/tps.ts +40 -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, 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
|
|
@@ -223,6 +268,11 @@ export interface UiState {
|
|
|
223
268
|
chatSessions: ChatSessionView[]
|
|
224
269
|
/** The DSH chat session id {@link chatSessions} was loaded for. */
|
|
225
270
|
chatSessionsFor?: string
|
|
271
|
+
/**
|
|
272
|
+
* Live burst aggregate per ACP session id, from `turn/metrics`. Absent means
|
|
273
|
+
* no measured burst yet; a row's persisted `burst` is the reload seed.
|
|
274
|
+
*/
|
|
275
|
+
burstStats: Record<string, { outputTokens: number; burstMs: number; turnId: string; final: boolean }>
|
|
226
276
|
/**
|
|
227
277
|
* The agent tag's halo preference (settings-editable display choice). The
|
|
228
278
|
* tag reads it per render; the host answer in `resync` is the authority and
|
|
@@ -236,6 +286,12 @@ export interface UiState {
|
|
|
236
286
|
* host answer (returned by the same route) is the authority.
|
|
237
287
|
*/
|
|
238
288
|
approvalDeadlines?: AcpApprovalDeadlines
|
|
289
|
+
/**
|
|
290
|
+
* The idle-release window in force, as the settings page edits it.
|
|
291
|
+
* Absent on a host half from before the tab existed, which hides the
|
|
292
|
+
* tab rather than showing a number this browser invented.
|
|
293
|
+
*/
|
|
294
|
+
idleRelease?: AcpIdleRelease
|
|
239
295
|
/**
|
|
240
296
|
* Per-chat-session "does this conversation run the ACP preset" verdicts, as
|
|
241
297
|
* reported by `session/chat-list` (host-read from the session log). The
|
|
@@ -636,8 +692,10 @@ export class AcpStore {
|
|
|
636
692
|
probingModels: {},
|
|
637
693
|
probeErrors: {},
|
|
638
694
|
chatSessions: [],
|
|
695
|
+
burstStats: {},
|
|
639
696
|
tagHalo: { ...DEFAULT_TAG_HALO },
|
|
640
697
|
approvalDeadlines: undefined,
|
|
698
|
+
idleRelease: undefined,
|
|
641
699
|
chatTranscripts: {},
|
|
642
700
|
acpMode: {},
|
|
643
701
|
chatBoundAgent: {},
|
|
@@ -658,6 +716,13 @@ export class AcpStore {
|
|
|
658
716
|
private readonly openTurns = new Set<string>()
|
|
659
717
|
/** In-flight `session/chat-list` reads, keyed by chat session id. */
|
|
660
718
|
private readonly chatSessionReads = new Map<string, { dropped: boolean; done: Promise<void> }>()
|
|
719
|
+
/**
|
|
720
|
+
* ACP session ids whose chat-list row was requested mid-turn (one read each).
|
|
721
|
+
* A conversation's first ACP session does not exist when the composer's
|
|
722
|
+
* mount/running read fires, so its first `usage_update` would find no row to
|
|
723
|
+
* patch; `turn/start` asks again once the host has materialized the session.
|
|
724
|
+
*/
|
|
725
|
+
private readonly chatSessionRowReads = new Set<string>()
|
|
661
726
|
/**
|
|
662
727
|
* Chat sessions whose list has answered at least once in this page. The
|
|
663
728
|
* FIRST answer only seeds the lock; a lock appearing on a later answer is a
|
|
@@ -783,9 +848,16 @@ export class AcpStore {
|
|
|
783
848
|
else list.push(item)
|
|
784
849
|
}
|
|
785
850
|
let chatTranscripts = this.state.chatTranscripts
|
|
851
|
+
let chatSessions = this.state.chatSessions
|
|
786
852
|
let blocksChanged = false
|
|
787
853
|
for (const [sessionId, items] of bySession) {
|
|
788
854
|
const last = items[items.length - 1]
|
|
855
|
+
// `usage_update` is state, not stream content: it never becomes a block,
|
|
856
|
+
// but both observed snapshots that carry usage must move with it —
|
|
857
|
+
// otherwise the ACP view header and the composer's occupancy ring only
|
|
858
|
+
// refresh when some other RPC re-reads the session.
|
|
859
|
+
const usage = usageOfBatch(items)
|
|
860
|
+
if (usage !== undefined) chatSessions = withSessionUsage(chatSessions, sessionId, usage)
|
|
789
861
|
// The observed transcript folds every frame (a watched session's cards
|
|
790
862
|
// and prose); blocks are copied only when something actually changed, so
|
|
791
863
|
// a usage-only tick costs one record object, not a re-render payload.
|
|
@@ -795,9 +867,12 @@ export class AcpStore {
|
|
|
795
867
|
for (const item of items) {
|
|
796
868
|
if (foldUpdate(record.blocks, item.update as Record<string, unknown>)) changed = true
|
|
797
869
|
}
|
|
870
|
+
const session = usage !== undefined && !sameUsage(record.session.usage, usage)
|
|
871
|
+
? { ...record.session, usage }
|
|
872
|
+
: record.session
|
|
798
873
|
chatTranscripts = {
|
|
799
874
|
...chatTranscripts,
|
|
800
|
-
[sessionId]: { ...record, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
|
|
875
|
+
[sessionId]: { ...record, session, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
|
|
801
876
|
}
|
|
802
877
|
}
|
|
803
878
|
if (sessionId !== this.state.activeSessionId) {
|
|
@@ -818,6 +893,7 @@ export class AcpStore {
|
|
|
818
893
|
}
|
|
819
894
|
const patch: Partial<UiState> = {}
|
|
820
895
|
if (chatTranscripts !== this.state.chatTranscripts) patch.chatTranscripts = chatTranscripts
|
|
896
|
+
if (chatSessions !== this.state.chatSessions) patch.chatSessions = chatSessions
|
|
821
897
|
if (blocksChanged) patch.blocks = [...this.state.blocks]
|
|
822
898
|
if (Object.keys(patch).length > 0) this.mutate(patch)
|
|
823
899
|
}
|
|
@@ -872,7 +948,7 @@ export class AcpStore {
|
|
|
872
948
|
/** Snapshot refresh used on panel open and after a stream outage. */
|
|
873
949
|
async resync(): Promise<void> {
|
|
874
950
|
try {
|
|
875
|
-
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
|
|
951
|
+
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, idleReleaseResult, versionResult] = await Promise.all([
|
|
876
952
|
this.call<{ agents: AgentView[] }>('agents/list'),
|
|
877
953
|
this.call<{ sessions: SessionView[] }>('sessions/list'),
|
|
878
954
|
// The plugin's OWN default ACP agent — a plugin-private id, not a DSH
|
|
@@ -886,6 +962,9 @@ export class AcpStore {
|
|
|
886
962
|
// `undefined` (older half) hides the deadline card instead of showing a
|
|
887
963
|
// number this browser made up.
|
|
888
964
|
this.call<ApprovalDeadlinesResult>('ui/approval-deadlines').catch(() => ({ deadlines: undefined })),
|
|
965
|
+
// `undefined` (older half) hides the tab instead of showing a
|
|
966
|
+
// number this browser made up.
|
|
967
|
+
this.call<IdleReleaseResult>('ui/idle-release').catch(() => ({ idleRelease: undefined })),
|
|
889
968
|
// The settings badge's data source; older halves answer `unknown
|
|
890
969
|
// endpoint`, which hides the badge.
|
|
891
970
|
this.call<PluginVersionResult>('plugin/version').catch(() => null),
|
|
@@ -903,8 +982,13 @@ export class AcpStore {
|
|
|
903
982
|
preset: presetResult?.preset ?? undefined,
|
|
904
983
|
tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
|
|
905
984
|
approvalDeadlines: deadlinesResult.deadlines,
|
|
985
|
+
idleRelease: idleReleaseResult.idleRelease,
|
|
906
986
|
pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
|
|
907
987
|
})
|
|
988
|
+
// `sessions/list` is the `/acp` RPC face; the composer's chat rows
|
|
989
|
+
// (agent modes, occupancy) are a separate list. A reconnect must refresh
|
|
990
|
+
// that list too or the ring keeps a pre-outage sample until a turn ends.
|
|
991
|
+
if (this.state.chatSessionsFor !== undefined) void this.loadChatSessions(this.state.chatSessionsFor)
|
|
908
992
|
const active = this.state.activeSessionId ?? sessions[0]?.sessionId
|
|
909
993
|
if (active !== undefined) await this.loadTranscript(active)
|
|
910
994
|
} catch (error) {
|
|
@@ -1365,9 +1449,9 @@ export class AcpStore {
|
|
|
1365
1449
|
* the frame's authority.
|
|
1366
1450
|
*/
|
|
1367
1451
|
loadChatSessions(dshSessionId: string, options?: { settleVerdict?: boolean }): Promise<void> {
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1452
|
+
// The composer entries (agent-mode control, ACP cluster, occupancy ring)
|
|
1453
|
+
// read this and re-read when a turn settles; one round-trip answers for
|
|
1454
|
+
// all of them instead of racing reads. A caller arriving mid-read waits
|
|
1371
1455
|
// for THAT read rather than returning immediately: the slash source's
|
|
1372
1456
|
// one-shot "do I have a verdict yet?" ask is only useful if it can
|
|
1373
1457
|
// receive the answer of a read already in flight.
|
|
@@ -1386,7 +1470,7 @@ export class AcpStore {
|
|
|
1386
1470
|
): Promise<void> {
|
|
1387
1471
|
const settle = options?.settleVerdict === true || !this.acpModeLive.has(dshSessionId)
|
|
1388
1472
|
try {
|
|
1389
|
-
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 })
|
|
1473
|
+
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 })
|
|
1390
1474
|
// A committed `agent-preset/selected` frame may have settled this
|
|
1391
1475
|
// session's verdict while the read was in flight; the host answer then
|
|
1392
1476
|
// describes the log as of BEFORE that switch and must not overwrite it.
|
|
@@ -1535,6 +1619,13 @@ export class AcpStore {
|
|
|
1535
1619
|
* being the one authorized exception. Same no-op-when-unchanged discipline as
|
|
1536
1620
|
* the other accessors, so a frame restating the held verdict costs no
|
|
1537
1621
|
* listener churn.
|
|
1622
|
+
*
|
|
1623
|
+
* The seat gate (`syncAcpMode`) is the SECOND caller: it folds the runtime
|
|
1624
|
+
* row's own `agentPreset` — the same session-log source the frames announce —
|
|
1625
|
+
* through here, in both directions. That hands the row the frame's authority
|
|
1626
|
+
* on purpose (one provenance, one rank): a stale projection is corrected by
|
|
1627
|
+
* the gate's next evaluation with fresher row data, and only HOST READS are
|
|
1628
|
+
* barred from regressing what the runtime itself already said.
|
|
1538
1629
|
*/
|
|
1539
1630
|
noteAcpMode(dshSessionId: string, acpMode: boolean): void {
|
|
1540
1631
|
this.acpModeLive.add(dshSessionId)
|
|
@@ -1618,12 +1709,98 @@ export class AcpStore {
|
|
|
1618
1709
|
async setChatSessionMode(dshSessionId: string, modeId: string, agentId?: string): Promise<void> {
|
|
1619
1710
|
try {
|
|
1620
1711
|
await this.call('session/chat-mode-set', { dshSessionId, modeId, ...(agentId === undefined ? {} : { agentId }) })
|
|
1712
|
+
const safeAgent = (agentId && agentId.trim()) || undefined
|
|
1713
|
+
this.writeChoice(dshSessionId, choice => ({ ...choice, mode: modeId }), safeAgent)
|
|
1714
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
1715
|
+
const owner = safeAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent
|
|
1716
|
+
let changed = false
|
|
1717
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
1718
|
+
if (s.agentId !== owner || !s.modes || s.modes.currentModeId === modeId) return s
|
|
1719
|
+
changed = true
|
|
1720
|
+
return { ...s, modes: { ...s.modes, currentModeId: modeId } }
|
|
1721
|
+
})
|
|
1722
|
+
if (changed) this.mutate({ chatSessions })
|
|
1723
|
+
}
|
|
1621
1724
|
} catch (error) {
|
|
1622
1725
|
this.flashError(error)
|
|
1623
1726
|
}
|
|
1624
1727
|
await this.loadChatSessions(dshSessionId)
|
|
1625
1728
|
}
|
|
1626
1729
|
|
|
1730
|
+
/**
|
|
1731
|
+
* Bring the conversation's released ACP session back on a fresh process —
|
|
1732
|
+
* the released banner's explicit form of the "next message resumes it"
|
|
1733
|
+
* promise (`session/chat-resume`). A failure is reported through the notice
|
|
1734
|
+
* AND rethrown (the `respondPermission` contract), so the banner can pin
|
|
1735
|
+
* the message beside the button the user just pressed; the binding is
|
|
1736
|
+
* re-read either way so the row follows the host's real answer.
|
|
1737
|
+
*/
|
|
1738
|
+
async resumeChatSession(dshSessionId: string): Promise<void> {
|
|
1739
|
+
let failure: unknown
|
|
1740
|
+
try {
|
|
1741
|
+
await this.call('session/chat-resume', { dshSessionId })
|
|
1742
|
+
} catch (error) {
|
|
1743
|
+
this.flashError(error)
|
|
1744
|
+
failure = error
|
|
1745
|
+
}
|
|
1746
|
+
await this.loadChatSessions(dshSessionId)
|
|
1747
|
+
if (failure !== undefined) throw failure
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Branch one conversation (the NTR branch button's click): the fixed
|
|
1752
|
+
* two-stage order — the HOST forks the DSH session tree first (copied
|
|
1753
|
+
* history, `(1)` title, workspace), then the plugin adopts the ACP side via
|
|
1754
|
+
* `session/chat-fork-adopt`, which runs the agent's own `session/fork` so
|
|
1755
|
+
* the child actually remembers the parent context. Host first is structural:
|
|
1756
|
+
* ACP fork has no cut-point parameter and nothing to return to if the host
|
|
1757
|
+
* half failed second (see the plan's §D7).
|
|
1758
|
+
*
|
|
1759
|
+
* Resolves `{ childId }` on success, or `{ failure }` with one actionable
|
|
1760
|
+
* line — the reason is ALSO flashed into the notice banner (a user-action
|
|
1761
|
+
* failure, so the banner is its surface), and the local line is what the
|
|
1762
|
+
* conversation renders beside the button (the user is not on the settings
|
|
1763
|
+
* page). A failed adopt leaves the child an ordinary copied-history
|
|
1764
|
+
* conversation (its first message starts a fresh `session/new` — today's
|
|
1765
|
+
* behavior), never a half-bound one, and NOTHING is opened.
|
|
1766
|
+
*
|
|
1767
|
+
* `forkHost` is the runtime's `sessions.fork` face, resolved structurally by
|
|
1768
|
+
* the entry and injected here: the store stays transport-free.
|
|
1769
|
+
*/
|
|
1770
|
+
async forkChatSession(
|
|
1771
|
+
dshSessionId: string,
|
|
1772
|
+
forkHost: (sessionId: string) => Promise<unknown>,
|
|
1773
|
+
): Promise<ChatForkOutcome> {
|
|
1774
|
+
let childId: unknown
|
|
1775
|
+
try {
|
|
1776
|
+
childId = await forkHost(dshSessionId)
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
const failure = error instanceof Error ? error.message : String(error)
|
|
1779
|
+
this.flashError(failure)
|
|
1780
|
+
return { failure }
|
|
1781
|
+
}
|
|
1782
|
+
if (typeof childId !== 'string' || childId === '') {
|
|
1783
|
+
const failure = this.locale(
|
|
1784
|
+
'宿主没有返回分支会话,分支未完成。',
|
|
1785
|
+
'The host returned no branched session; the branch did not complete.',
|
|
1786
|
+
)
|
|
1787
|
+
this.flashError(new Error(failure))
|
|
1788
|
+
return { failure }
|
|
1789
|
+
}
|
|
1790
|
+
try {
|
|
1791
|
+
await this.call('session/chat-fork-adopt', { dshSessionId: childId, parentDshSessionId: dshSessionId })
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
1794
|
+
const failure = this.locale(
|
|
1795
|
+
`分支会话已创建,但没有接上 agent 的上下文(${reason});在子会话里发送的第一条消息会开启全新会话。`,
|
|
1796
|
+
`The branched session was created but the agent context was not attached (${reason}); its first message will start a fresh session.`,
|
|
1797
|
+
)
|
|
1798
|
+
this.flashError(new Error(failure))
|
|
1799
|
+
return { failure }
|
|
1800
|
+
}
|
|
1801
|
+
return { childId }
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1627
1804
|
// ---- ACP transcript observation (the conversation's ACP view) ----
|
|
1628
1805
|
|
|
1629
1806
|
/**
|
|
@@ -1799,6 +1976,36 @@ export class AcpStore {
|
|
|
1799
1976
|
await this.writeApprovalDeadlines({ reset: true })
|
|
1800
1977
|
}
|
|
1801
1978
|
|
|
1979
|
+
/**
|
|
1980
|
+
* Write the idle-release window (in ms).
|
|
1981
|
+
*
|
|
1982
|
+
* The host answer — the same route returns the window now in force — is
|
|
1983
|
+
* the authority, so an optimistic write that the host trims self-corrects
|
|
1984
|
+
* instead of drifting from what runs.
|
|
1985
|
+
*/
|
|
1986
|
+
async setIdleRelease(idleTimeoutMs: number): Promise<void> {
|
|
1987
|
+
await this.writeIdleRelease({ idleTimeoutMs })
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Drop the persisted override: the config file (or the built-in default)
|
|
1992
|
+
* decides again.
|
|
1993
|
+
*/
|
|
1994
|
+
async resetIdleRelease(): Promise<void> {
|
|
1995
|
+
await this.writeIdleRelease({ reset: true })
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
private async writeIdleRelease(payload: IdleReleaseSetPayload): Promise<void> {
|
|
1999
|
+
const previous = this.state.idleRelease
|
|
2000
|
+
try {
|
|
2001
|
+
const result = await this.call<IdleReleaseResult>('ui/idle-release-set', payload)
|
|
2002
|
+
this.mutate({ idleRelease: result.idleRelease })
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
this.mutate({ idleRelease: previous })
|
|
2005
|
+
this.flashError(error)
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1802
2009
|
private async writeApprovalDeadlines(payload: ApprovalDeadlinesSetPayload): Promise<void> {
|
|
1803
2010
|
const previous = this.state.approvalDeadlines
|
|
1804
2011
|
// Optimistic only where it is safe: the effective numbers and the mode move
|
|
@@ -1852,7 +2059,19 @@ export class AcpStore {
|
|
|
1852
2059
|
// Optimistic for the same reason the binding is: the host recorded the
|
|
1853
2060
|
// pick, and a conversation with no ACP session yet would otherwise show
|
|
1854
2061
|
// the agent's default until its first turn created one.
|
|
1855
|
-
|
|
2062
|
+
const safeAgent = (agentId && agentId.trim()) || undefined
|
|
2063
|
+
const targetAgent = safeAgent ?? (modelId ? splitModelAgentId(modelId) : undefined)
|
|
2064
|
+
this.writeChoice(dshSessionId, choice => ({ ...choice, modelId }), targetAgent)
|
|
2065
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
2066
|
+
const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent
|
|
2067
|
+
let changed = false
|
|
2068
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
2069
|
+
if (s.agentId !== owner || s.modelId === modelId) return s
|
|
2070
|
+
changed = true
|
|
2071
|
+
return { ...s, modelId }
|
|
2072
|
+
})
|
|
2073
|
+
if (changed) this.mutate({ chatSessions })
|
|
2074
|
+
}
|
|
1856
2075
|
} catch (error) {
|
|
1857
2076
|
this.flashError(error)
|
|
1858
2077
|
throw error
|
|
@@ -1867,10 +2086,21 @@ export class AcpStore {
|
|
|
1867
2086
|
...(effort === undefined ? {} : { effort }),
|
|
1868
2087
|
...(agentId === undefined ? {} : { agentId }),
|
|
1869
2088
|
})
|
|
2089
|
+
const targetAgent = (agentId && agentId.trim()) || undefined
|
|
1870
2090
|
this.writeChoice(dshSessionId, choice => {
|
|
1871
2091
|
const { effort: _dropped, ...rest } = choice
|
|
1872
2092
|
return effort === undefined ? rest : { ...rest, effort }
|
|
1873
|
-
},
|
|
2093
|
+
}, targetAgent)
|
|
2094
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
2095
|
+
const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent
|
|
2096
|
+
let changed = false
|
|
2097
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
2098
|
+
if (s.agentId !== owner || s.reasoningEffort === effort) return s
|
|
2099
|
+
changed = true
|
|
2100
|
+
return { ...s, reasoningEffort: effort }
|
|
2101
|
+
})
|
|
2102
|
+
if (changed) this.mutate({ chatSessions })
|
|
2103
|
+
}
|
|
1874
2104
|
} catch (error) {
|
|
1875
2105
|
this.flashError(error)
|
|
1876
2106
|
throw error
|
|
@@ -1983,11 +2213,20 @@ export class AcpStore {
|
|
|
1983
2213
|
return Promise.resolve()
|
|
1984
2214
|
}
|
|
1985
2215
|
case 'session/meta': {
|
|
1986
|
-
// Host re-published the session view after mode/config changes
|
|
2216
|
+
// Host re-published the session view after mode/config changes or a
|
|
2217
|
+
// resume. The composer's occupancy ring reads the chat-list row, so
|
|
2218
|
+
// that row must follow the snapshot's usage too — including an absent
|
|
2219
|
+
// one, which must clear an older reading rather than outlive it.
|
|
1987
2220
|
const sessions = this.state.sessions.some(s => s.sessionId === event.sessionId)
|
|
1988
2221
|
? this.state.sessions.map(s => (s.sessionId === event.sessionId ? event.session : s))
|
|
1989
2222
|
: this.state.sessions
|
|
1990
|
-
this.
|
|
2223
|
+
let chatSessions = withSessionUsage(this.state.chatSessions, event.sessionId, event.session.usage)
|
|
2224
|
+
chatSessions = withSessionModes(chatSessions, event.sessionId, event.session.modes)
|
|
2225
|
+
this.mutate({
|
|
2226
|
+
sessions,
|
|
2227
|
+
...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
|
|
2228
|
+
...this.watchedSessionPatch(event.sessionId, event.session),
|
|
2229
|
+
})
|
|
1991
2230
|
return Promise.resolve()
|
|
1992
2231
|
}
|
|
1993
2232
|
case 'session/closed': {
|
|
@@ -2001,11 +2240,20 @@ export class AcpStore {
|
|
|
2001
2240
|
// entry would keep reading as a live session (the agent tag's halo
|
|
2002
2241
|
// breathes on `!released`) until some future turn re-reads the list.
|
|
2003
2242
|
const chatSessions = this.state.chatSessions.filter(s => s.sessionId !== event.sessionId)
|
|
2243
|
+
// The burst aggregate belongs to this ACP session only; another
|
|
2244
|
+
// session's entry must survive. Only patch when there was an entry.
|
|
2245
|
+
const hadBurst = this.state.burstStats[event.sessionId] !== undefined
|
|
2246
|
+
let burstStats = this.state.burstStats
|
|
2247
|
+
if (hadBurst) {
|
|
2248
|
+
burstStats = { ...burstStats }
|
|
2249
|
+
delete burstStats[event.sessionId]
|
|
2250
|
+
}
|
|
2004
2251
|
this.mutate({
|
|
2005
2252
|
sessions,
|
|
2006
2253
|
activeSessionId: active,
|
|
2007
2254
|
chatTranscripts: closedTranscripts,
|
|
2008
2255
|
...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
|
|
2256
|
+
...(hadBurst ? { burstStats } : {}),
|
|
2009
2257
|
permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
|
|
2010
2258
|
...(event.reason === 'agent-died'
|
|
2011
2259
|
? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
|
|
@@ -2023,9 +2271,7 @@ export class AcpStore {
|
|
|
2023
2271
|
// The composer's own list (`session/chat-list` views) must flip too, or
|
|
2024
2272
|
// the agent tag's live halo keeps breathing for a process that is gone:
|
|
2025
2273
|
// nothing else re-reads this list until the next turn settles.
|
|
2026
|
-
const chatSessions = this.state.chatSessions
|
|
2027
|
-
? this.state.chatSessions.map(s => (s.sessionId === event.sessionId ? { ...s, released: true } : s))
|
|
2028
|
-
: this.state.chatSessions
|
|
2274
|
+
const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, true)
|
|
2029
2275
|
// One runtime-log line per release cause; `idle` stays silent on
|
|
2030
2276
|
// purpose (an expected lifecycle transition, not a fault).
|
|
2031
2277
|
const cause = event.cause ?? 'died'
|
|
@@ -2076,8 +2322,15 @@ export class AcpStore {
|
|
|
2076
2322
|
// an observed transcript must drop both its blocks and its watermark
|
|
2077
2323
|
// or the replay would be read as a gap and refetched forever.
|
|
2078
2324
|
const watched = this.state.chatTranscripts[event.sessionId]
|
|
2325
|
+
// ...and the composer's own list must flip back to live on the same
|
|
2326
|
+
// event (mirror of the `session/released` flip): the released banner
|
|
2327
|
+
// and the tag's halo read that row, and nothing else re-reads it until
|
|
2328
|
+
// a turn settles — a resume would leave the banner claiming a parked
|
|
2329
|
+
// process that is already back.
|
|
2330
|
+
const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, false)
|
|
2079
2331
|
this.mutate({
|
|
2080
2332
|
sessions,
|
|
2333
|
+
...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
|
|
2081
2334
|
...(isActive ? { blocks: [] } : {}),
|
|
2082
2335
|
...(watched === undefined ? {} : {
|
|
2083
2336
|
chatTranscripts: {
|
|
@@ -2187,9 +2440,42 @@ export class AcpStore {
|
|
|
2187
2440
|
})
|
|
2188
2441
|
return Promise.resolve()
|
|
2189
2442
|
}
|
|
2443
|
+
case 'turn/metrics': {
|
|
2444
|
+
// Plugin-side burst aggregate for one ACP session. A late LIVE frame
|
|
2445
|
+
// must not regress a turn this page already saw finalized (frames can
|
|
2446
|
+
// land out of order across a stream reconnect); any other frame writes
|
|
2447
|
+
// the entry. Blocks / sessions stay untouched — this is a metric.
|
|
2448
|
+
const prior = this.state.burstStats[event.sessionId]
|
|
2449
|
+
if (event.final === false && prior !== undefined && prior.final === true && prior.turnId === event.turnId) {
|
|
2450
|
+
return Promise.resolve()
|
|
2451
|
+
}
|
|
2452
|
+
this.mutate({
|
|
2453
|
+
burstStats: {
|
|
2454
|
+
...this.state.burstStats,
|
|
2455
|
+
[event.sessionId]: {
|
|
2456
|
+
outputTokens: event.outputTokens,
|
|
2457
|
+
burstMs: event.burstMs,
|
|
2458
|
+
turnId: event.turnId,
|
|
2459
|
+
final: event.final,
|
|
2460
|
+
},
|
|
2461
|
+
},
|
|
2462
|
+
})
|
|
2463
|
+
return Promise.resolve()
|
|
2464
|
+
}
|
|
2190
2465
|
case 'turn/start': {
|
|
2191
2466
|
this.openTurns.add(event.sessionId)
|
|
2192
2467
|
this.markWatchedRunning(event.sessionId, true)
|
|
2468
|
+
// The session materializes inside this turn; if the composer's list
|
|
2469
|
+
// has not seen its row yet (the mount/running read beat session/new),
|
|
2470
|
+
// read once now so the occupancy ring has a row for the live usage
|
|
2471
|
+
// ticks. The per-session set bounds this to one extra read.
|
|
2472
|
+
const chatSessionId = this.state.chatSessionsFor
|
|
2473
|
+
if (chatSessionId !== undefined
|
|
2474
|
+
&& !this.state.chatSessions.some(row => row.sessionId === event.sessionId)
|
|
2475
|
+
&& !this.chatSessionRowReads.has(event.sessionId)) {
|
|
2476
|
+
this.chatSessionRowReads.add(event.sessionId)
|
|
2477
|
+
void this.loadChatSessions(chatSessionId)
|
|
2478
|
+
}
|
|
2193
2479
|
if (event.sessionId === this.state.activeSessionId) {
|
|
2194
2480
|
this.state.blocks.push({ kind: 'user', text: event.text })
|
|
2195
2481
|
trimBlocks(this.state.blocks)
|
|
@@ -2365,6 +2651,118 @@ export const STATE_ONLY_UPDATES: ReadonlySet<string> = new Set([
|
|
|
2365
2651
|
'session_info_update',
|
|
2366
2652
|
])
|
|
2367
2653
|
|
|
2654
|
+
/**
|
|
2655
|
+
* Context occupancy carried by one raw `usage_update` frame, in the shape the
|
|
2656
|
+
* session snapshots already use. A malformed frame is ignored (the last good
|
|
2657
|
+
* sample stays on screen) instead of zeroing the meter.
|
|
2658
|
+
*/
|
|
2659
|
+
export function usageFromUpdate(update: Record<string, unknown>): SessionView['usage'] | undefined {
|
|
2660
|
+
const used = update.used
|
|
2661
|
+
const size = update.size
|
|
2662
|
+
if (typeof used !== 'number' || !Number.isFinite(used)) return undefined
|
|
2663
|
+
if (typeof size !== 'number' || !Number.isFinite(size)) return undefined
|
|
2664
|
+
const cost = update.cost
|
|
2665
|
+
if (cost !== null && typeof cost === 'object' && !Array.isArray(cost)) {
|
|
2666
|
+
const row = cost as Record<string, unknown>
|
|
2667
|
+
if (typeof row.amount === 'number' && Number.isFinite(row.amount)
|
|
2668
|
+
&& typeof row.currency === 'string' && row.currency !== '') {
|
|
2669
|
+
return { used, size, cost: { amount: row.amount, currency: row.currency } }
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
return { used, size }
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
function sameUsage(left: SessionView['usage'], right: SessionView['usage']): boolean {
|
|
2676
|
+
if (left === right) return true
|
|
2677
|
+
if (left === undefined || right === undefined) return false
|
|
2678
|
+
if (left.used !== right.used || left.size !== right.size) return false
|
|
2679
|
+
const leftCost = left.cost
|
|
2680
|
+
const rightCost = right.cost
|
|
2681
|
+
if (leftCost === undefined || rightCost === undefined) return leftCost === rightCost
|
|
2682
|
+
return leftCost.amount === rightCost.amount && leftCost.currency === rightCost.currency
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
/** Patch one session row's usage; other rows and an unchanged value keep their identities. */
|
|
2686
|
+
function withSessionUsage<T extends { sessionId: string; usage?: SessionView['usage'] }>(
|
|
2687
|
+
rows: T[],
|
|
2688
|
+
sessionId: string,
|
|
2689
|
+
usage: SessionView['usage'],
|
|
2690
|
+
): T[] {
|
|
2691
|
+
let changed = false
|
|
2692
|
+
const next = rows.map(row => {
|
|
2693
|
+
if (row.sessionId !== sessionId || sameUsage(row.usage, usage)) return row
|
|
2694
|
+
changed = true
|
|
2695
|
+
return { ...row, usage }
|
|
2696
|
+
})
|
|
2697
|
+
return changed ? next : rows
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
/**
|
|
2701
|
+
* Flip one chat row's `released` flag (release/resume bookkeeping). The
|
|
2702
|
+
* composer's own list (`session/chat-list` rows) has no other live writer —
|
|
2703
|
+
* `session/released` must mark it, `session/reset` must un-mark it, or the
|
|
2704
|
+
* agent tag's halo and the released banner read yesterday's process state.
|
|
2705
|
+
*/
|
|
2706
|
+
function withSessionReleased<T extends { sessionId: string; released?: boolean }>(
|
|
2707
|
+
rows: T[],
|
|
2708
|
+
sessionId: string,
|
|
2709
|
+
released: boolean,
|
|
2710
|
+
): T[] {
|
|
2711
|
+
let changed = false
|
|
2712
|
+
const next = rows.map(row => {
|
|
2713
|
+
if (row.sessionId !== sessionId || (row.released === true) === released) return row
|
|
2714
|
+
changed = true
|
|
2715
|
+
return { ...row, released }
|
|
2716
|
+
})
|
|
2717
|
+
return changed ? next : rows
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
function sameModes(a?: SessionView['modes'], b?: SessionView['modes']): boolean {
|
|
2721
|
+
if (a === b) return true
|
|
2722
|
+
if (!a || !b) return false
|
|
2723
|
+
if (a.currentModeId !== b.currentModeId) return false
|
|
2724
|
+
const aModes = a.availableModes ?? []
|
|
2725
|
+
const bModes = b.availableModes ?? []
|
|
2726
|
+
if (aModes.length !== bModes.length) return false
|
|
2727
|
+
return aModes.every((m, i) => {
|
|
2728
|
+
const o = bModes[i]
|
|
2729
|
+
return m != null && o != null && m.id === o.id && m.name === o.name && m.description === o.description
|
|
2730
|
+
})
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
function withSessionModes<T extends { sessionId: string; modes?: SessionView['modes'] }>(
|
|
2734
|
+
rows: T[],
|
|
2735
|
+
sessionId: string,
|
|
2736
|
+
modes?: SessionView['modes'],
|
|
2737
|
+
): T[] {
|
|
2738
|
+
if (!modes) return rows
|
|
2739
|
+
let changed = false
|
|
2740
|
+
const next = rows.map(row => {
|
|
2741
|
+
if (row.sessionId !== sessionId || sameModes(row.modes, modes)) return row
|
|
2742
|
+
changed = true
|
|
2743
|
+
return { ...row, modes }
|
|
2744
|
+
})
|
|
2745
|
+
return changed ? next : rows
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
function splitModelAgentId(modelId: string): string | undefined {
|
|
2749
|
+
const at = modelId.indexOf('@')
|
|
2750
|
+
const agent = at === -1 ? modelId.trim() : (at === 0 ? '' : modelId.slice(0, at).trim())
|
|
2751
|
+
return agent || undefined
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
/** Last well-formed `usage_update` in one coalesced batch (later ticks win). */
|
|
2755
|
+
function usageOfBatch(items: ReadonlyArray<{ update: unknown }>): SessionView['usage'] | undefined {
|
|
2756
|
+
let usage: SessionView['usage'] | undefined
|
|
2757
|
+
for (const item of items) {
|
|
2758
|
+
const update = item.update as Record<string, unknown>
|
|
2759
|
+
if (update.sessionUpdate !== 'usage_update') continue
|
|
2760
|
+
const parsed = usageFromUpdate(update)
|
|
2761
|
+
if (parsed !== undefined) usage = parsed
|
|
2762
|
+
}
|
|
2763
|
+
return usage
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2368
2766
|
function trimBlocks(blocks: Block[]): void {
|
|
2369
2767
|
if (blocks.length > MAX_BLOCKS) blocks.splice(0, blocks.length - MAX_BLOCKS)
|
|
2370
2768
|
}
|