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.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/cordis.patch.yml +17 -1
- package/lib/bridge.js +614 -25
- package/lib/client/acp-ui.js +103 -3
- package/lib/client/acp-view.js +24 -4
- package/lib/client/branch-action.js +133 -0
- package/lib/client/composer-mode.js +48 -13
- package/lib/client/context-meter.js +86 -0
- package/lib/client/index.js +238 -15
- package/lib/client/panel.js +127 -30
- 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 +403 -16
- package/lib/client.js +1267 -70
- package/lib/client.js.map +1 -1
- package/lib/impl.js +54 -1
- package/lib/index.js +7 -0
- package/lib/protocol.js +19 -0
- package/lib/types/bridge.d.ts +230 -7
- package/lib/types/client/acp-ui.d.ts +34 -2
- package/lib/types/client/acp-view.d.ts +13 -2
- package/lib/types/client/branch-action.d.ts +40 -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/released-banner.d.ts +34 -0
- package/lib/types/client/slash-isolation.d.ts +1 -1
- package/lib/types/client/store.d.ts +131 -1
- package/lib/types/impl.d.ts +41 -1
- package/lib/types/protocol.d.ts +130 -0
- package/lib/types/update.d.ts +7 -4
- package/lib/update.js +25 -10
- package/package.json +2 -1
- package/src/bridge.ts +645 -27
- package/src/client/acp-ui.tsx +123 -2
- package/src/client/acp-view.tsx +30 -5
- package/src/client/branch-action.tsx +160 -0
- package/src/client/composer-mode.tsx +59 -13
- package/src/client/context-meter.tsx +128 -0
- package/src/client/index.tsx +265 -14
- package/src/client/panel.tsx +229 -41
- 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 +463 -20
- package/src/impl.ts +84 -2
- package/src/index.ts +8 -0
- package/src/protocol.ts +129 -0
- package/src/update.ts +21 -8
package/lib/client/store.js
CHANGED
|
@@ -17,6 +17,35 @@ export const PERMISSION_DEADLINE_EXPIRED = 'expired';
|
|
|
17
17
|
export function permissionsForSession(permissions, dshSessionId) {
|
|
18
18
|
return permissions.filter(permission => permission.dshSessionId === dshSessionId);
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The branch button's state for one conversation, in the precedence it renders:
|
|
22
|
+
* the host service missing beats everything (nothing can work), then the
|
|
23
|
+
* agent's fork capability, then the session's own state (a parked session
|
|
24
|
+
* before a running one), then the DOM's tail verdict, and only then ready.
|
|
25
|
+
*
|
|
26
|
+
* Every input is a fact someone else owns — the row's `fork` verdict is the
|
|
27
|
+
* host's, `hostFork` is the structural probe of the runtime's `sessions.fork`,
|
|
28
|
+
* `isTail` is read from the host's own `[data-turn-tail]` marker, and
|
|
29
|
+
* `liveRunning` is the SSE-driven running flag (the row's own `running` only
|
|
30
|
+
* refreshes on a chat-list read, so it would answer last turn). `undefined`
|
|
31
|
+
* facts degrade CLOSED: no row and no `fork` verdict both read as not-ready,
|
|
32
|
+
* never as a click-through.
|
|
33
|
+
*/
|
|
34
|
+
export function forkStateOf(row, hostFork, isTail, liveRunning) {
|
|
35
|
+
if (!hostFork)
|
|
36
|
+
return 'hostUnavailable';
|
|
37
|
+
if (row === undefined)
|
|
38
|
+
return 'released';
|
|
39
|
+
if (row.fork === undefined)
|
|
40
|
+
return 'unsupported';
|
|
41
|
+
if (row.fork.supported !== true)
|
|
42
|
+
return row.fork.reason ?? 'unsupported';
|
|
43
|
+
if (liveRunning === true)
|
|
44
|
+
return 'busy';
|
|
45
|
+
if (!isTail)
|
|
46
|
+
return 'notTail';
|
|
47
|
+
return 'ready';
|
|
48
|
+
}
|
|
20
49
|
/**
|
|
21
50
|
* Compact countdown for one ask, or `undefined` when there is nothing to count
|
|
22
51
|
* down (`wait` mode, or a frame without a deadline). Pure so the formatting is
|
|
@@ -39,6 +68,8 @@ export function deadlineRemainingLabel(deadlineAt, now, mode) {
|
|
|
39
68
|
const rest = seconds % 60;
|
|
40
69
|
return rest === 0 ? `${String(minutes)}m` : `${String(minutes)}m ${String(rest)}s`;
|
|
41
70
|
}
|
|
71
|
+
/** Cap on retained runtime-log lines; older entries fall off the front. */
|
|
72
|
+
export const LOG_MAX_ENTRIES = 200;
|
|
42
73
|
const MAX_BLOCKS = 400;
|
|
43
74
|
/** How many observed ACP transcripts stay live at once (insertion-ordered eviction). */
|
|
44
75
|
const MAX_WATCHED = 6;
|
|
@@ -339,6 +370,8 @@ export class AcpStore {
|
|
|
339
370
|
blocks: [],
|
|
340
371
|
permissions: [],
|
|
341
372
|
busy: false,
|
|
373
|
+
logs: [],
|
|
374
|
+
logUnread: 0,
|
|
342
375
|
detectedAgents: [...DEFAULT_KNOWN_PRESETS],
|
|
343
376
|
detecting: false,
|
|
344
377
|
modelCatalogs: {},
|
|
@@ -348,6 +381,7 @@ export class AcpStore {
|
|
|
348
381
|
chatSessions: [],
|
|
349
382
|
tagHalo: { ...DEFAULT_TAG_HALO },
|
|
350
383
|
approvalDeadlines: undefined,
|
|
384
|
+
idleRelease: undefined,
|
|
351
385
|
chatTranscripts: {},
|
|
352
386
|
acpMode: {},
|
|
353
387
|
chatBoundAgent: {},
|
|
@@ -360,11 +394,20 @@ export class AcpStore {
|
|
|
360
394
|
};
|
|
361
395
|
listeners = new Set();
|
|
362
396
|
source;
|
|
397
|
+
/** Monotonic id source for {@link AcpLogEntry}s (never reset by `clearLogs`). */
|
|
398
|
+
logSeq = 0;
|
|
363
399
|
lastSeq = new Map();
|
|
364
400
|
/** Sessions with a finalized turn (dedups the cancel-fallback + late real turn/end pair). */
|
|
365
401
|
openTurns = new Set();
|
|
366
402
|
/** In-flight `session/chat-list` reads, keyed by chat session id. */
|
|
367
403
|
chatSessionReads = new Map();
|
|
404
|
+
/**
|
|
405
|
+
* ACP session ids whose chat-list row was requested mid-turn (one read each).
|
|
406
|
+
* A conversation's first ACP session does not exist when the composer's
|
|
407
|
+
* mount/running read fires, so its first `usage_update` would find no row to
|
|
408
|
+
* patch; `turn/start` asks again once the host has materialized the session.
|
|
409
|
+
*/
|
|
410
|
+
chatSessionRowReads = new Set();
|
|
368
411
|
/**
|
|
369
412
|
* Chat sessions whose list has answered at least once in this page. The
|
|
370
413
|
* FIRST answer only seeds the lock; a lock appearing on a later answer is a
|
|
@@ -445,6 +488,23 @@ export class AcpStore {
|
|
|
445
488
|
for (const listener of [...this.listeners])
|
|
446
489
|
listener();
|
|
447
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* Build the log patch for one line. Does NOT mutate: the caller merges it
|
|
493
|
+
* into the single `mutate` call it was already making for the same event.
|
|
494
|
+
*/
|
|
495
|
+
logPatch(level, text, agentId) {
|
|
496
|
+
const entry = {
|
|
497
|
+
id: ++this.logSeq,
|
|
498
|
+
at: Date.now(),
|
|
499
|
+
level,
|
|
500
|
+
text,
|
|
501
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
502
|
+
};
|
|
503
|
+
return {
|
|
504
|
+
logs: [...this.state.logs, entry].slice(-LOG_MAX_ENTRIES),
|
|
505
|
+
logUnread: this.state.logUnread + 1,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
448
508
|
// ---- Streaming update coalescing ----
|
|
449
509
|
/**
|
|
450
510
|
* Live `session/update` frames waiting to be folded. Agents stream at token
|
|
@@ -471,9 +531,17 @@ export class AcpStore {
|
|
|
471
531
|
list.push(item);
|
|
472
532
|
}
|
|
473
533
|
let chatTranscripts = this.state.chatTranscripts;
|
|
534
|
+
let chatSessions = this.state.chatSessions;
|
|
474
535
|
let blocksChanged = false;
|
|
475
536
|
for (const [sessionId, items] of bySession) {
|
|
476
537
|
const last = items[items.length - 1];
|
|
538
|
+
// `usage_update` is state, not stream content: it never becomes a block,
|
|
539
|
+
// but both observed snapshots that carry usage must move with it —
|
|
540
|
+
// otherwise the ACP view header and the composer's occupancy ring only
|
|
541
|
+
// refresh when some other RPC re-reads the session.
|
|
542
|
+
const usage = usageOfBatch(items);
|
|
543
|
+
if (usage !== undefined)
|
|
544
|
+
chatSessions = withSessionUsage(chatSessions, sessionId, usage);
|
|
477
545
|
// The observed transcript folds every frame (a watched session's cards
|
|
478
546
|
// and prose); blocks are copied only when something actually changed, so
|
|
479
547
|
// a usage-only tick costs one record object, not a re-render payload.
|
|
@@ -484,9 +552,12 @@ export class AcpStore {
|
|
|
484
552
|
if (foldUpdate(record.blocks, item.update))
|
|
485
553
|
changed = true;
|
|
486
554
|
}
|
|
555
|
+
const session = usage !== undefined && !sameUsage(record.session.usage, usage)
|
|
556
|
+
? { ...record.session, usage }
|
|
557
|
+
: record.session;
|
|
487
558
|
chatTranscripts = {
|
|
488
559
|
...chatTranscripts,
|
|
489
|
-
[sessionId]: { ...record, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
|
|
560
|
+
[sessionId]: { ...record, session, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
|
|
490
561
|
};
|
|
491
562
|
}
|
|
492
563
|
if (sessionId !== this.state.activeSessionId) {
|
|
@@ -509,6 +580,8 @@ export class AcpStore {
|
|
|
509
580
|
const patch = {};
|
|
510
581
|
if (chatTranscripts !== this.state.chatTranscripts)
|
|
511
582
|
patch.chatTranscripts = chatTranscripts;
|
|
583
|
+
if (chatSessions !== this.state.chatSessions)
|
|
584
|
+
patch.chatSessions = chatSessions;
|
|
512
585
|
if (blocksChanged)
|
|
513
586
|
patch.blocks = [...this.state.blocks];
|
|
514
587
|
if (Object.keys(patch).length > 0)
|
|
@@ -563,7 +636,7 @@ export class AcpStore {
|
|
|
563
636
|
/** Snapshot refresh used on panel open and after a stream outage. */
|
|
564
637
|
async resync() {
|
|
565
638
|
try {
|
|
566
|
-
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, versionResult] = await Promise.all([
|
|
639
|
+
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult, idleReleaseResult, versionResult] = await Promise.all([
|
|
567
640
|
this.call('agents/list'),
|
|
568
641
|
this.call('sessions/list'),
|
|
569
642
|
// The plugin's OWN default ACP agent — a plugin-private id, not a DSH
|
|
@@ -577,6 +650,9 @@ export class AcpStore {
|
|
|
577
650
|
// `undefined` (older half) hides the deadline card instead of showing a
|
|
578
651
|
// number this browser made up.
|
|
579
652
|
this.call('ui/approval-deadlines').catch(() => ({ deadlines: undefined })),
|
|
653
|
+
// `undefined` (older half) hides the tab instead of showing a
|
|
654
|
+
// number this browser made up.
|
|
655
|
+
this.call('ui/idle-release').catch(() => ({ idleRelease: undefined })),
|
|
580
656
|
// The settings badge's data source; older halves answer `unknown
|
|
581
657
|
// endpoint`, which hides the badge.
|
|
582
658
|
this.call('plugin/version').catch(() => null),
|
|
@@ -594,8 +670,14 @@ export class AcpStore {
|
|
|
594
670
|
preset: presetResult?.preset ?? undefined,
|
|
595
671
|
tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
|
|
596
672
|
approvalDeadlines: deadlinesResult.deadlines,
|
|
673
|
+
idleRelease: idleReleaseResult.idleRelease,
|
|
597
674
|
pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
|
|
598
675
|
});
|
|
676
|
+
// `sessions/list` is the `/acp` RPC face; the composer's chat rows
|
|
677
|
+
// (agent modes, occupancy) are a separate list. A reconnect must refresh
|
|
678
|
+
// that list too or the ring keeps a pre-outage sample until a turn ends.
|
|
679
|
+
if (this.state.chatSessionsFor !== undefined)
|
|
680
|
+
void this.loadChatSessions(this.state.chatSessionsFor);
|
|
599
681
|
const active = this.state.activeSessionId ?? sessions[0]?.sessionId;
|
|
600
682
|
if (active !== undefined)
|
|
601
683
|
await this.loadTranscript(active);
|
|
@@ -1057,9 +1139,9 @@ export class AcpStore {
|
|
|
1057
1139
|
* the frame's authority.
|
|
1058
1140
|
*/
|
|
1059
1141
|
loadChatSessions(dshSessionId, options) {
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1142
|
+
// The composer entries (agent-mode control, ACP cluster, occupancy ring)
|
|
1143
|
+
// read this and re-read when a turn settles; one round-trip answers for
|
|
1144
|
+
// all of them instead of racing reads. A caller arriving mid-read waits
|
|
1063
1145
|
// for THAT read rather than returning immediately: the slash source's
|
|
1064
1146
|
// one-shot "do I have a verdict yet?" ask is only useful if it can
|
|
1065
1147
|
// receive the answer of a read already in flight.
|
|
@@ -1074,7 +1156,7 @@ export class AcpStore {
|
|
|
1074
1156
|
async readChatSessionList(dshSessionId, options, read) {
|
|
1075
1157
|
const settle = options?.settleVerdict === true || !this.acpModeLive.has(dshSessionId);
|
|
1076
1158
|
try {
|
|
1077
|
-
const { sessions, acpMode, boundAgentId, lockedAgentId, choice } = await this.call('session/chat-list', { dshSessionId, refresh: true });
|
|
1159
|
+
const { sessions = [], acpMode, boundAgentId, lockedAgentId, choice } = await this.call('session/chat-list', { dshSessionId, refresh: true });
|
|
1078
1160
|
// A committed `agent-preset/selected` frame may have settled this
|
|
1079
1161
|
// session's verdict while the read was in flight; the host answer then
|
|
1080
1162
|
// describes the log as of BEFORE that switch and must not overwrite it.
|
|
@@ -1229,6 +1311,13 @@ export class AcpStore {
|
|
|
1229
1311
|
* being the one authorized exception. Same no-op-when-unchanged discipline as
|
|
1230
1312
|
* the other accessors, so a frame restating the held verdict costs no
|
|
1231
1313
|
* listener churn.
|
|
1314
|
+
*
|
|
1315
|
+
* The seat gate (`syncAcpMode`) is the SECOND caller: it folds the runtime
|
|
1316
|
+
* row's own `agentPreset` — the same session-log source the frames announce —
|
|
1317
|
+
* through here, in both directions. That hands the row the frame's authority
|
|
1318
|
+
* on purpose (one provenance, one rank): a stale projection is corrected by
|
|
1319
|
+
* the gate's next evaluation with fresher row data, and only HOST READS are
|
|
1320
|
+
* barred from regressing what the runtime itself already said.
|
|
1232
1321
|
*/
|
|
1233
1322
|
noteAcpMode(dshSessionId, acpMode) {
|
|
1234
1323
|
this.acpModeLive.add(dshSessionId);
|
|
@@ -1313,11 +1402,92 @@ export class AcpStore {
|
|
|
1313
1402
|
async setChatSessionMode(dshSessionId, modeId, agentId) {
|
|
1314
1403
|
try {
|
|
1315
1404
|
await this.call('session/chat-mode-set', { dshSessionId, modeId, ...(agentId === undefined ? {} : { agentId }) });
|
|
1405
|
+
const safeAgent = (agentId && agentId.trim()) || undefined;
|
|
1406
|
+
this.writeChoice(dshSessionId, choice => ({ ...choice, mode: modeId }), safeAgent);
|
|
1407
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
1408
|
+
const owner = safeAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
|
|
1409
|
+
let changed = false;
|
|
1410
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
1411
|
+
if (s.agentId !== owner || !s.modes || s.modes.currentModeId === modeId)
|
|
1412
|
+
return s;
|
|
1413
|
+
changed = true;
|
|
1414
|
+
return { ...s, modes: { ...s.modes, currentModeId: modeId } };
|
|
1415
|
+
});
|
|
1416
|
+
if (changed)
|
|
1417
|
+
this.mutate({ chatSessions });
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
this.flashError(error);
|
|
1422
|
+
}
|
|
1423
|
+
await this.loadChatSessions(dshSessionId);
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Bring the conversation's released ACP session back on a fresh process —
|
|
1427
|
+
* the released banner's explicit form of the "next message resumes it"
|
|
1428
|
+
* promise (`session/chat-resume`). A failure is reported through the notice
|
|
1429
|
+
* AND rethrown (the `respondPermission` contract), so the banner can pin
|
|
1430
|
+
* the message beside the button the user just pressed; the binding is
|
|
1431
|
+
* re-read either way so the row follows the host's real answer.
|
|
1432
|
+
*/
|
|
1433
|
+
async resumeChatSession(dshSessionId) {
|
|
1434
|
+
let failure;
|
|
1435
|
+
try {
|
|
1436
|
+
await this.call('session/chat-resume', { dshSessionId });
|
|
1316
1437
|
}
|
|
1317
1438
|
catch (error) {
|
|
1318
1439
|
this.flashError(error);
|
|
1440
|
+
failure = error;
|
|
1319
1441
|
}
|
|
1320
1442
|
await this.loadChatSessions(dshSessionId);
|
|
1443
|
+
if (failure !== undefined)
|
|
1444
|
+
throw failure;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Branch one conversation (the NTR branch button's click): the fixed
|
|
1448
|
+
* two-stage order — the HOST forks the DSH session tree first (copied
|
|
1449
|
+
* history, `(1)` title, workspace), then the plugin adopts the ACP side via
|
|
1450
|
+
* `session/chat-fork-adopt`, which runs the agent's own `session/fork` so
|
|
1451
|
+
* the child actually remembers the parent context. Host first is structural:
|
|
1452
|
+
* ACP fork has no cut-point parameter and nothing to return to if the host
|
|
1453
|
+
* half failed second (see the plan's §D7).
|
|
1454
|
+
*
|
|
1455
|
+
* Resolves `{ childId }` on success, or `{ failure }` with one actionable
|
|
1456
|
+
* line — the reason is ALSO flashed into the notice banner (a user-action
|
|
1457
|
+
* failure, so the banner is its surface), and the local line is what the
|
|
1458
|
+
* conversation renders beside the button (the user is not on the settings
|
|
1459
|
+
* page). A failed adopt leaves the child an ordinary copied-history
|
|
1460
|
+
* conversation (its first message starts a fresh `session/new` — today's
|
|
1461
|
+
* behavior), never a half-bound one, and NOTHING is opened.
|
|
1462
|
+
*
|
|
1463
|
+
* `forkHost` is the runtime's `sessions.fork` face, resolved structurally by
|
|
1464
|
+
* the entry and injected here: the store stays transport-free.
|
|
1465
|
+
*/
|
|
1466
|
+
async forkChatSession(dshSessionId, forkHost) {
|
|
1467
|
+
let childId;
|
|
1468
|
+
try {
|
|
1469
|
+
childId = await forkHost(dshSessionId);
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
const failure = error instanceof Error ? error.message : String(error);
|
|
1473
|
+
this.flashError(failure);
|
|
1474
|
+
return { failure };
|
|
1475
|
+
}
|
|
1476
|
+
if (typeof childId !== 'string' || childId === '') {
|
|
1477
|
+
const failure = this.locale('宿主没有返回分支会话,分支未完成。', 'The host returned no branched session; the branch did not complete.');
|
|
1478
|
+
this.flashError(new Error(failure));
|
|
1479
|
+
return { failure };
|
|
1480
|
+
}
|
|
1481
|
+
try {
|
|
1482
|
+
await this.call('session/chat-fork-adopt', { dshSessionId: childId, parentDshSessionId: dshSessionId });
|
|
1483
|
+
}
|
|
1484
|
+
catch (error) {
|
|
1485
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1486
|
+
const failure = this.locale(`分支会话已创建,但没有接上 agent 的上下文(${reason});在子会话里发送的第一条消息会开启全新会话。`, `The branched session was created but the agent context was not attached (${reason}); its first message will start a fresh session.`);
|
|
1487
|
+
this.flashError(new Error(failure));
|
|
1488
|
+
return { failure };
|
|
1489
|
+
}
|
|
1490
|
+
return { childId };
|
|
1321
1491
|
}
|
|
1322
1492
|
// ---- ACP transcript observation (the conversation's ACP view) ----
|
|
1323
1493
|
/**
|
|
@@ -1489,6 +1659,34 @@ export class AcpStore {
|
|
|
1489
1659
|
async resetApprovalDeadlines() {
|
|
1490
1660
|
await this.writeApprovalDeadlines({ reset: true });
|
|
1491
1661
|
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Write the idle-release window (in ms).
|
|
1664
|
+
*
|
|
1665
|
+
* The host answer — the same route returns the window now in force — is
|
|
1666
|
+
* the authority, so an optimistic write that the host trims self-corrects
|
|
1667
|
+
* instead of drifting from what runs.
|
|
1668
|
+
*/
|
|
1669
|
+
async setIdleRelease(idleTimeoutMs) {
|
|
1670
|
+
await this.writeIdleRelease({ idleTimeoutMs });
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Drop the persisted override: the config file (or the built-in default)
|
|
1674
|
+
* decides again.
|
|
1675
|
+
*/
|
|
1676
|
+
async resetIdleRelease() {
|
|
1677
|
+
await this.writeIdleRelease({ reset: true });
|
|
1678
|
+
}
|
|
1679
|
+
async writeIdleRelease(payload) {
|
|
1680
|
+
const previous = this.state.idleRelease;
|
|
1681
|
+
try {
|
|
1682
|
+
const result = await this.call('ui/idle-release-set', payload);
|
|
1683
|
+
this.mutate({ idleRelease: result.idleRelease });
|
|
1684
|
+
}
|
|
1685
|
+
catch (error) {
|
|
1686
|
+
this.mutate({ idleRelease: previous });
|
|
1687
|
+
this.flashError(error);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1492
1690
|
async writeApprovalDeadlines(payload) {
|
|
1493
1691
|
const previous = this.state.approvalDeadlines;
|
|
1494
1692
|
// Optimistic only where it is safe: the effective numbers and the mode move
|
|
@@ -1542,7 +1740,21 @@ export class AcpStore {
|
|
|
1542
1740
|
// Optimistic for the same reason the binding is: the host recorded the
|
|
1543
1741
|
// pick, and a conversation with no ACP session yet would otherwise show
|
|
1544
1742
|
// the agent's default until its first turn created one.
|
|
1545
|
-
|
|
1743
|
+
const safeAgent = (agentId && agentId.trim()) || undefined;
|
|
1744
|
+
const targetAgent = safeAgent ?? (modelId ? splitModelAgentId(modelId) : undefined);
|
|
1745
|
+
this.writeChoice(dshSessionId, choice => ({ ...choice, modelId }), targetAgent);
|
|
1746
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
1747
|
+
const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
|
|
1748
|
+
let changed = false;
|
|
1749
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
1750
|
+
if (s.agentId !== owner || s.modelId === modelId)
|
|
1751
|
+
return s;
|
|
1752
|
+
changed = true;
|
|
1753
|
+
return { ...s, modelId };
|
|
1754
|
+
});
|
|
1755
|
+
if (changed)
|
|
1756
|
+
this.mutate({ chatSessions });
|
|
1757
|
+
}
|
|
1546
1758
|
}
|
|
1547
1759
|
catch (error) {
|
|
1548
1760
|
this.flashError(error);
|
|
@@ -1557,10 +1769,23 @@ export class AcpStore {
|
|
|
1557
1769
|
...(effort === undefined ? {} : { effort }),
|
|
1558
1770
|
...(agentId === undefined ? {} : { agentId }),
|
|
1559
1771
|
});
|
|
1772
|
+
const targetAgent = (agentId && agentId.trim()) || undefined;
|
|
1560
1773
|
this.writeChoice(dshSessionId, choice => {
|
|
1561
1774
|
const { effort: _dropped, ...rest } = choice;
|
|
1562
1775
|
return effort === undefined ? rest : { ...rest, effort };
|
|
1563
|
-
},
|
|
1776
|
+
}, targetAgent);
|
|
1777
|
+
if (this.state.chatSessionsFor === dshSessionId) {
|
|
1778
|
+
const owner = targetAgent ?? this.state.chatChoice[dshSessionId]?.agentId ?? this.state.chatBoundAgent[dshSessionId] ?? this.state.defaultAgent;
|
|
1779
|
+
let changed = false;
|
|
1780
|
+
const chatSessions = this.state.chatSessions.map(s => {
|
|
1781
|
+
if (s.agentId !== owner || s.reasoningEffort === effort)
|
|
1782
|
+
return s;
|
|
1783
|
+
changed = true;
|
|
1784
|
+
return { ...s, reasoningEffort: effort };
|
|
1785
|
+
});
|
|
1786
|
+
if (changed)
|
|
1787
|
+
this.mutate({ chatSessions });
|
|
1788
|
+
}
|
|
1564
1789
|
}
|
|
1565
1790
|
catch (error) {
|
|
1566
1791
|
this.flashError(error);
|
|
@@ -1628,6 +1853,16 @@ export class AcpStore {
|
|
|
1628
1853
|
clearNotice() {
|
|
1629
1854
|
this.mutate({ notice: undefined });
|
|
1630
1855
|
}
|
|
1856
|
+
/** Drop every retained runtime-log line (the unread counter restarts too). */
|
|
1857
|
+
clearLogs() {
|
|
1858
|
+
this.mutate({ logs: [], logUnread: 0 });
|
|
1859
|
+
}
|
|
1860
|
+
/** The user looked at the log: clear the unread badge. */
|
|
1861
|
+
markLogsRead() {
|
|
1862
|
+
if (this.state.logUnread === 0)
|
|
1863
|
+
return;
|
|
1864
|
+
this.mutate({ logUnread: 0 });
|
|
1865
|
+
}
|
|
1631
1866
|
flashError(error) {
|
|
1632
1867
|
this.mutate({ notice: error instanceof Error ? error.message : String(error) });
|
|
1633
1868
|
}
|
|
@@ -1635,7 +1870,7 @@ export class AcpStore {
|
|
|
1635
1870
|
applyEvent(event) {
|
|
1636
1871
|
switch (event.type) {
|
|
1637
1872
|
case 'agent/notice': {
|
|
1638
|
-
this.mutate(
|
|
1873
|
+
this.mutate(this.logPatch(event.level, event.text, event.agentId));
|
|
1639
1874
|
return Promise.resolve();
|
|
1640
1875
|
}
|
|
1641
1876
|
case 'agent/status': {
|
|
@@ -1658,14 +1893,24 @@ export class AcpStore {
|
|
|
1658
1893
|
return Promise.resolve();
|
|
1659
1894
|
}
|
|
1660
1895
|
case 'session/meta': {
|
|
1661
|
-
// Host re-published the session view after mode/config changes
|
|
1896
|
+
// Host re-published the session view after mode/config changes or a
|
|
1897
|
+
// resume. The composer's occupancy ring reads the chat-list row, so
|
|
1898
|
+
// that row must follow the snapshot's usage too — including an absent
|
|
1899
|
+
// one, which must clear an older reading rather than outlive it.
|
|
1662
1900
|
const sessions = this.state.sessions.some(s => s.sessionId === event.sessionId)
|
|
1663
1901
|
? this.state.sessions.map(s => (s.sessionId === event.sessionId ? event.session : s))
|
|
1664
1902
|
: this.state.sessions;
|
|
1665
|
-
this.
|
|
1903
|
+
let chatSessions = withSessionUsage(this.state.chatSessions, event.sessionId, event.session.usage);
|
|
1904
|
+
chatSessions = withSessionModes(chatSessions, event.sessionId, event.session.modes);
|
|
1905
|
+
this.mutate({
|
|
1906
|
+
sessions,
|
|
1907
|
+
...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
|
|
1908
|
+
...this.watchedSessionPatch(event.sessionId, event.session),
|
|
1909
|
+
});
|
|
1666
1910
|
return Promise.resolve();
|
|
1667
1911
|
}
|
|
1668
1912
|
case 'session/closed': {
|
|
1913
|
+
const closingSession = this.state.sessions.find(s => s.sessionId === event.sessionId);
|
|
1669
1914
|
const sessions = this.state.sessions.filter(s => s.sessionId !== event.sessionId);
|
|
1670
1915
|
const active = this.state.activeSessionId === event.sessionId ? sessions[0]?.sessionId : this.state.activeSessionId;
|
|
1671
1916
|
this.openTurns.delete(event.sessionId);
|
|
@@ -1681,7 +1926,9 @@ export class AcpStore {
|
|
|
1681
1926
|
chatTranscripts: closedTranscripts,
|
|
1682
1927
|
...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
|
|
1683
1928
|
permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
|
|
1684
|
-
...(event.reason === 'agent-died'
|
|
1929
|
+
...(event.reason === 'agent-died'
|
|
1930
|
+
? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
|
|
1931
|
+
: {}),
|
|
1685
1932
|
});
|
|
1686
1933
|
return Promise.resolve();
|
|
1687
1934
|
}
|
|
@@ -1695,9 +1942,23 @@ export class AcpStore {
|
|
|
1695
1942
|
// The composer's own list (`session/chat-list` views) must flip too, or
|
|
1696
1943
|
// the agent tag's live halo keeps breathing for a process that is gone:
|
|
1697
1944
|
// nothing else re-reads this list until the next turn settles.
|
|
1698
|
-
const chatSessions = this.state.chatSessions
|
|
1699
|
-
|
|
1700
|
-
|
|
1945
|
+
const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, true);
|
|
1946
|
+
// One runtime-log line per release cause; `idle` stays silent on
|
|
1947
|
+
// purpose (an expected lifecycle transition, not a fault).
|
|
1948
|
+
const cause = event.cause ?? 'died';
|
|
1949
|
+
let log = {};
|
|
1950
|
+
if (cause === 'workspace') {
|
|
1951
|
+
log = this.logPatch('info', this.locale('agent 为切换工作区重启,会话已挂起;再次发送消息将自动恢复。', 'agent restarted for its workspace; the session is paused and will resume on your next message.'), event.session.agentId);
|
|
1952
|
+
}
|
|
1953
|
+
else if (cause === 'resume-failed') {
|
|
1954
|
+
log = this.logPatch('error', this.locale('会话恢复失败;再次发送消息将重试恢复。', 'session resume failed; your next message will retry it.'), event.session.agentId);
|
|
1955
|
+
}
|
|
1956
|
+
else if (cause === 'stalled') {
|
|
1957
|
+
log = this.logPatch('error', this.locale('agent 未响应卡死的回合,会话已挂起;再次发送消息将自动恢复。', 'agent did not acknowledge the stalled turn; the session is paused and will resume on your next message.'), event.session.agentId);
|
|
1958
|
+
}
|
|
1959
|
+
else if (cause === 'died') {
|
|
1960
|
+
log = this.logPatch('error', this.locale('agent 进程已退出,会话已挂起;再次发送消息将自动恢复。', 'agent process died; the session is paused and will resume on your next message.'), event.session.agentId);
|
|
1961
|
+
}
|
|
1701
1962
|
this.mutate({
|
|
1702
1963
|
sessions,
|
|
1703
1964
|
...(chatSessions !== this.state.chatSessions ? { chatSessions } : {}),
|
|
@@ -1705,7 +1966,7 @@ export class AcpStore {
|
|
|
1705
1966
|
// An observed transcript keeps its buffer but flips to released, so
|
|
1706
1967
|
// the ACP view can say "会恢复" instead of silently looking dead.
|
|
1707
1968
|
...this.watchedSessionPatch(event.sessionId, event.session),
|
|
1708
|
-
|
|
1969
|
+
...log,
|
|
1709
1970
|
});
|
|
1710
1971
|
return Promise.resolve();
|
|
1711
1972
|
}
|
|
@@ -1723,8 +1984,15 @@ export class AcpStore {
|
|
|
1723
1984
|
// an observed transcript must drop both its blocks and its watermark
|
|
1724
1985
|
// or the replay would be read as a gap and refetched forever.
|
|
1725
1986
|
const watched = this.state.chatTranscripts[event.sessionId];
|
|
1987
|
+
// ...and the composer's own list must flip back to live on the same
|
|
1988
|
+
// event (mirror of the `session/released` flip): the released banner
|
|
1989
|
+
// and the tag's halo read that row, and nothing else re-reads it until
|
|
1990
|
+
// a turn settles — a resume would leave the banner claiming a parked
|
|
1991
|
+
// process that is already back.
|
|
1992
|
+
const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, false);
|
|
1726
1993
|
this.mutate({
|
|
1727
1994
|
sessions,
|
|
1995
|
+
...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
|
|
1728
1996
|
...(isActive ? { blocks: [] } : {}),
|
|
1729
1997
|
...(watched === undefined ? {} : {
|
|
1730
1998
|
chatTranscripts: {
|
|
@@ -1836,6 +2104,17 @@ export class AcpStore {
|
|
|
1836
2104
|
case 'turn/start': {
|
|
1837
2105
|
this.openTurns.add(event.sessionId);
|
|
1838
2106
|
this.markWatchedRunning(event.sessionId, true);
|
|
2107
|
+
// The session materializes inside this turn; if the composer's list
|
|
2108
|
+
// has not seen its row yet (the mount/running read beat session/new),
|
|
2109
|
+
// read once now so the occupancy ring has a row for the live usage
|
|
2110
|
+
// ticks. The per-session set bounds this to one extra read.
|
|
2111
|
+
const chatSessionId = this.state.chatSessionsFor;
|
|
2112
|
+
if (chatSessionId !== undefined
|
|
2113
|
+
&& !this.state.chatSessions.some(row => row.sessionId === event.sessionId)
|
|
2114
|
+
&& !this.chatSessionRowReads.has(event.sessionId)) {
|
|
2115
|
+
this.chatSessionRowReads.add(event.sessionId);
|
|
2116
|
+
void this.loadChatSessions(chatSessionId);
|
|
2117
|
+
}
|
|
1839
2118
|
if (event.sessionId === this.state.activeSessionId) {
|
|
1840
2119
|
this.state.blocks.push({ kind: 'user', text: event.text });
|
|
1841
2120
|
trimBlocks(this.state.blocks);
|
|
@@ -2018,6 +2297,114 @@ export const STATE_ONLY_UPDATES = new Set([
|
|
|
2018
2297
|
'usage_update',
|
|
2019
2298
|
'session_info_update',
|
|
2020
2299
|
]);
|
|
2300
|
+
/**
|
|
2301
|
+
* Context occupancy carried by one raw `usage_update` frame, in the shape the
|
|
2302
|
+
* session snapshots already use. A malformed frame is ignored (the last good
|
|
2303
|
+
* sample stays on screen) instead of zeroing the meter.
|
|
2304
|
+
*/
|
|
2305
|
+
export function usageFromUpdate(update) {
|
|
2306
|
+
const used = update.used;
|
|
2307
|
+
const size = update.size;
|
|
2308
|
+
if (typeof used !== 'number' || !Number.isFinite(used))
|
|
2309
|
+
return undefined;
|
|
2310
|
+
if (typeof size !== 'number' || !Number.isFinite(size))
|
|
2311
|
+
return undefined;
|
|
2312
|
+
const cost = update.cost;
|
|
2313
|
+
if (cost !== null && typeof cost === 'object' && !Array.isArray(cost)) {
|
|
2314
|
+
const row = cost;
|
|
2315
|
+
if (typeof row.amount === 'number' && Number.isFinite(row.amount)
|
|
2316
|
+
&& typeof row.currency === 'string' && row.currency !== '') {
|
|
2317
|
+
return { used, size, cost: { amount: row.amount, currency: row.currency } };
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
return { used, size };
|
|
2321
|
+
}
|
|
2322
|
+
function sameUsage(left, right) {
|
|
2323
|
+
if (left === right)
|
|
2324
|
+
return true;
|
|
2325
|
+
if (left === undefined || right === undefined)
|
|
2326
|
+
return false;
|
|
2327
|
+
if (left.used !== right.used || left.size !== right.size)
|
|
2328
|
+
return false;
|
|
2329
|
+
const leftCost = left.cost;
|
|
2330
|
+
const rightCost = right.cost;
|
|
2331
|
+
if (leftCost === undefined || rightCost === undefined)
|
|
2332
|
+
return leftCost === rightCost;
|
|
2333
|
+
return leftCost.amount === rightCost.amount && leftCost.currency === rightCost.currency;
|
|
2334
|
+
}
|
|
2335
|
+
/** Patch one session row's usage; other rows and an unchanged value keep their identities. */
|
|
2336
|
+
function withSessionUsage(rows, sessionId, usage) {
|
|
2337
|
+
let changed = false;
|
|
2338
|
+
const next = rows.map(row => {
|
|
2339
|
+
if (row.sessionId !== sessionId || sameUsage(row.usage, usage))
|
|
2340
|
+
return row;
|
|
2341
|
+
changed = true;
|
|
2342
|
+
return { ...row, usage };
|
|
2343
|
+
});
|
|
2344
|
+
return changed ? next : rows;
|
|
2345
|
+
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Flip one chat row's `released` flag (release/resume bookkeeping). The
|
|
2348
|
+
* composer's own list (`session/chat-list` rows) has no other live writer —
|
|
2349
|
+
* `session/released` must mark it, `session/reset` must un-mark it, or the
|
|
2350
|
+
* agent tag's halo and the released banner read yesterday's process state.
|
|
2351
|
+
*/
|
|
2352
|
+
function withSessionReleased(rows, sessionId, released) {
|
|
2353
|
+
let changed = false;
|
|
2354
|
+
const next = rows.map(row => {
|
|
2355
|
+
if (row.sessionId !== sessionId || (row.released === true) === released)
|
|
2356
|
+
return row;
|
|
2357
|
+
changed = true;
|
|
2358
|
+
return { ...row, released };
|
|
2359
|
+
});
|
|
2360
|
+
return changed ? next : rows;
|
|
2361
|
+
}
|
|
2362
|
+
function sameModes(a, b) {
|
|
2363
|
+
if (a === b)
|
|
2364
|
+
return true;
|
|
2365
|
+
if (!a || !b)
|
|
2366
|
+
return false;
|
|
2367
|
+
if (a.currentModeId !== b.currentModeId)
|
|
2368
|
+
return false;
|
|
2369
|
+
const aModes = a.availableModes ?? [];
|
|
2370
|
+
const bModes = b.availableModes ?? [];
|
|
2371
|
+
if (aModes.length !== bModes.length)
|
|
2372
|
+
return false;
|
|
2373
|
+
return aModes.every((m, i) => {
|
|
2374
|
+
const o = bModes[i];
|
|
2375
|
+
return m != null && o != null && m.id === o.id && m.name === o.name && m.description === o.description;
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
function withSessionModes(rows, sessionId, modes) {
|
|
2379
|
+
if (!modes)
|
|
2380
|
+
return rows;
|
|
2381
|
+
let changed = false;
|
|
2382
|
+
const next = rows.map(row => {
|
|
2383
|
+
if (row.sessionId !== sessionId || sameModes(row.modes, modes))
|
|
2384
|
+
return row;
|
|
2385
|
+
changed = true;
|
|
2386
|
+
return { ...row, modes };
|
|
2387
|
+
});
|
|
2388
|
+
return changed ? next : rows;
|
|
2389
|
+
}
|
|
2390
|
+
function splitModelAgentId(modelId) {
|
|
2391
|
+
const at = modelId.indexOf('@');
|
|
2392
|
+
const agent = at === -1 ? modelId.trim() : (at === 0 ? '' : modelId.slice(0, at).trim());
|
|
2393
|
+
return agent || undefined;
|
|
2394
|
+
}
|
|
2395
|
+
/** Last well-formed `usage_update` in one coalesced batch (later ticks win). */
|
|
2396
|
+
function usageOfBatch(items) {
|
|
2397
|
+
let usage;
|
|
2398
|
+
for (const item of items) {
|
|
2399
|
+
const update = item.update;
|
|
2400
|
+
if (update.sessionUpdate !== 'usage_update')
|
|
2401
|
+
continue;
|
|
2402
|
+
const parsed = usageFromUpdate(update);
|
|
2403
|
+
if (parsed !== undefined)
|
|
2404
|
+
usage = parsed;
|
|
2405
|
+
}
|
|
2406
|
+
return usage;
|
|
2407
|
+
}
|
|
2021
2408
|
function trimBlocks(blocks) {
|
|
2022
2409
|
if (blocks.length > MAX_BLOCKS)
|
|
2023
2410
|
blocks.splice(0, blocks.length - MAX_BLOCKS);
|