dsh-ntr 0.3.1 → 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 (48) 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 +607 -18
  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 +96 -29
  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 +348 -13
  16. package/lib/client.js +1138 -67
  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 +229 -6
  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 +98 -1
  30. package/lib/types/impl.d.ts +41 -1
  31. package/lib/types/protocol.d.ts +123 -0
  32. package/package.json +2 -1
  33. package/src/bridge.ts +638 -20
  34. package/src/client/acp-ui.tsx +123 -2
  35. package/src/client/acp-view.tsx +30 -5
  36. package/src/client/branch-action.tsx +160 -0
  37. package/src/client/composer-mode.tsx +59 -13
  38. package/src/client/context-meter.tsx +128 -0
  39. package/src/client/index.tsx +265 -14
  40. package/src/client/panel.tsx +157 -40
  41. package/src/client/primitives.d.ts +27 -0
  42. package/src/client/released-banner.tsx +97 -0
  43. package/src/client/session-controls.tsx +34 -11
  44. package/src/client/slash-isolation.ts +2 -1
  45. package/src/client/store.ts +375 -14
  46. package/src/impl.ts +84 -2
  47. package/src/index.ts +8 -0
  48. package/src/protocol.ts +121 -0
@@ -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
@@ -352,6 +381,7 @@ export class AcpStore {
352
381
  chatSessions: [],
353
382
  tagHalo: { ...DEFAULT_TAG_HALO },
354
383
  approvalDeadlines: undefined,
384
+ idleRelease: undefined,
355
385
  chatTranscripts: {},
356
386
  acpMode: {},
357
387
  chatBoundAgent: {},
@@ -371,6 +401,13 @@ export class AcpStore {
371
401
  openTurns = new Set();
372
402
  /** In-flight `session/chat-list` reads, keyed by chat session id. */
373
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();
374
411
  /**
375
412
  * Chat sessions whose list has answered at least once in this page. The
376
413
  * FIRST answer only seeds the lock; a lock appearing on a later answer is a
@@ -494,9 +531,17 @@ export class AcpStore {
494
531
  list.push(item);
495
532
  }
496
533
  let chatTranscripts = this.state.chatTranscripts;
534
+ let chatSessions = this.state.chatSessions;
497
535
  let blocksChanged = false;
498
536
  for (const [sessionId, items] of bySession) {
499
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);
500
545
  // The observed transcript folds every frame (a watched session's cards
501
546
  // and prose); blocks are copied only when something actually changed, so
502
547
  // a usage-only tick costs one record object, not a re-render payload.
@@ -507,9 +552,12 @@ export class AcpStore {
507
552
  if (foldUpdate(record.blocks, item.update))
508
553
  changed = true;
509
554
  }
555
+ const session = usage !== undefined && !sameUsage(record.session.usage, usage)
556
+ ? { ...record.session, usage }
557
+ : record.session;
510
558
  chatTranscripts = {
511
559
  ...chatTranscripts,
512
- [sessionId]: { ...record, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
560
+ [sessionId]: { ...record, session, seq: last.seq, ...(changed ? { blocks: [...record.blocks] } : {}) },
513
561
  };
514
562
  }
515
563
  if (sessionId !== this.state.activeSessionId) {
@@ -532,6 +580,8 @@ export class AcpStore {
532
580
  const patch = {};
533
581
  if (chatTranscripts !== this.state.chatTranscripts)
534
582
  patch.chatTranscripts = chatTranscripts;
583
+ if (chatSessions !== this.state.chatSessions)
584
+ patch.chatSessions = chatSessions;
535
585
  if (blocksChanged)
536
586
  patch.blocks = [...this.state.blocks];
537
587
  if (Object.keys(patch).length > 0)
@@ -586,7 +636,7 @@ export class AcpStore {
586
636
  /** Snapshot refresh used on panel open and after a stream outage. */
587
637
  async resync() {
588
638
  try {
589
- 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([
590
640
  this.call('agents/list'),
591
641
  this.call('sessions/list'),
592
642
  // The plugin's OWN default ACP agent — a plugin-private id, not a DSH
@@ -600,6 +650,9 @@ export class AcpStore {
600
650
  // `undefined` (older half) hides the deadline card instead of showing a
601
651
  // number this browser made up.
602
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 })),
603
656
  // The settings badge's data source; older halves answer `unknown
604
657
  // endpoint`, which hides the badge.
605
658
  this.call('plugin/version').catch(() => null),
@@ -617,8 +670,14 @@ export class AcpStore {
617
670
  preset: presetResult?.preset ?? undefined,
618
671
  tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
619
672
  approvalDeadlines: deadlinesResult.deadlines,
673
+ idleRelease: idleReleaseResult.idleRelease,
620
674
  pluginVersion: versionResult?.version !== undefined && versionResult.version !== '' ? versionResult.version : undefined,
621
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);
622
681
  const active = this.state.activeSessionId ?? sessions[0]?.sessionId;
623
682
  if (active !== undefined)
624
683
  await this.loadTranscript(active);
@@ -1080,9 +1139,9 @@ export class AcpStore {
1080
1139
  * the frame's authority.
1081
1140
  */
1082
1141
  loadChatSessions(dshSessionId, options) {
1083
- // Two composer entries read this (the agent-mode control and the ACP
1084
- // cluster) and both re-read when a turn settles; one round-trip answers
1085
- // for both instead of two racing ones. A caller arriving mid-read waits
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
1086
1145
  // for THAT read rather than returning immediately: the slash source's
1087
1146
  // one-shot "do I have a verdict yet?" ask is only useful if it can
1088
1147
  // receive the answer of a read already in flight.
@@ -1097,7 +1156,7 @@ export class AcpStore {
1097
1156
  async readChatSessionList(dshSessionId, options, read) {
1098
1157
  const settle = options?.settleVerdict === true || !this.acpModeLive.has(dshSessionId);
1099
1158
  try {
1100
- 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 });
1101
1160
  // A committed `agent-preset/selected` frame may have settled this
1102
1161
  // session's verdict while the read was in flight; the host answer then
1103
1162
  // describes the log as of BEFORE that switch and must not overwrite it.
@@ -1252,6 +1311,13 @@ export class AcpStore {
1252
1311
  * being the one authorized exception. Same no-op-when-unchanged discipline as
1253
1312
  * the other accessors, so a frame restating the held verdict costs no
1254
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.
1255
1321
  */
1256
1322
  noteAcpMode(dshSessionId, acpMode) {
1257
1323
  this.acpModeLive.add(dshSessionId);
@@ -1336,11 +1402,92 @@ export class AcpStore {
1336
1402
  async setChatSessionMode(dshSessionId, modeId, agentId) {
1337
1403
  try {
1338
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 });
1339
1437
  }
1340
1438
  catch (error) {
1341
1439
  this.flashError(error);
1440
+ failure = error;
1342
1441
  }
1343
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 };
1344
1491
  }
1345
1492
  // ---- ACP transcript observation (the conversation's ACP view) ----
1346
1493
  /**
@@ -1512,6 +1659,34 @@ export class AcpStore {
1512
1659
  async resetApprovalDeadlines() {
1513
1660
  await this.writeApprovalDeadlines({ reset: true });
1514
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
+ }
1515
1690
  async writeApprovalDeadlines(payload) {
1516
1691
  const previous = this.state.approvalDeadlines;
1517
1692
  // Optimistic only where it is safe: the effective numbers and the mode move
@@ -1565,7 +1740,21 @@ export class AcpStore {
1565
1740
  // Optimistic for the same reason the binding is: the host recorded the
1566
1741
  // pick, and a conversation with no ACP session yet would otherwise show
1567
1742
  // the agent's default until its first turn created one.
1568
- this.writeChoice(dshSessionId, choice => ({ ...choice, modelId }), agentId);
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
+ }
1569
1758
  }
1570
1759
  catch (error) {
1571
1760
  this.flashError(error);
@@ -1580,10 +1769,23 @@ export class AcpStore {
1580
1769
  ...(effort === undefined ? {} : { effort }),
1581
1770
  ...(agentId === undefined ? {} : { agentId }),
1582
1771
  });
1772
+ const targetAgent = (agentId && agentId.trim()) || undefined;
1583
1773
  this.writeChoice(dshSessionId, choice => {
1584
1774
  const { effort: _dropped, ...rest } = choice;
1585
1775
  return effort === undefined ? rest : { ...rest, effort };
1586
- }, agentId);
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
+ }
1587
1789
  }
1588
1790
  catch (error) {
1589
1791
  this.flashError(error);
@@ -1691,11 +1893,20 @@ export class AcpStore {
1691
1893
  return Promise.resolve();
1692
1894
  }
1693
1895
  case 'session/meta': {
1694
- // 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.
1695
1900
  const sessions = this.state.sessions.some(s => s.sessionId === event.sessionId)
1696
1901
  ? this.state.sessions.map(s => (s.sessionId === event.sessionId ? event.session : s))
1697
1902
  : this.state.sessions;
1698
- this.mutate({ sessions, ...this.watchedSessionPatch(event.sessionId, event.session) });
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
+ });
1699
1910
  return Promise.resolve();
1700
1911
  }
1701
1912
  case 'session/closed': {
@@ -1731,9 +1942,7 @@ export class AcpStore {
1731
1942
  // The composer's own list (`session/chat-list` views) must flip too, or
1732
1943
  // the agent tag's live halo keeps breathing for a process that is gone:
1733
1944
  // nothing else re-reads this list until the next turn settles.
1734
- const chatSessions = this.state.chatSessions.some(s => s.sessionId === event.sessionId)
1735
- ? this.state.chatSessions.map(s => (s.sessionId === event.sessionId ? { ...s, released: true } : s))
1736
- : this.state.chatSessions;
1945
+ const chatSessions = withSessionReleased(this.state.chatSessions, event.sessionId, true);
1737
1946
  // One runtime-log line per release cause; `idle` stays silent on
1738
1947
  // purpose (an expected lifecycle transition, not a fault).
1739
1948
  const cause = event.cause ?? 'died';
@@ -1775,8 +1984,15 @@ export class AcpStore {
1775
1984
  // an observed transcript must drop both its blocks and its watermark
1776
1985
  // or the replay would be read as a gap and refetched forever.
1777
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);
1778
1993
  this.mutate({
1779
1994
  sessions,
1995
+ ...(chatSessions === this.state.chatSessions ? {} : { chatSessions }),
1780
1996
  ...(isActive ? { blocks: [] } : {}),
1781
1997
  ...(watched === undefined ? {} : {
1782
1998
  chatTranscripts: {
@@ -1888,6 +2104,17 @@ export class AcpStore {
1888
2104
  case 'turn/start': {
1889
2105
  this.openTurns.add(event.sessionId);
1890
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
+ }
1891
2118
  if (event.sessionId === this.state.activeSessionId) {
1892
2119
  this.state.blocks.push({ kind: 'user', text: event.text });
1893
2120
  trimBlocks(this.state.blocks);
@@ -2070,6 +2297,114 @@ export const STATE_ONLY_UPDATES = new Set([
2070
2297
  'usage_update',
2071
2298
  'session_info_update',
2072
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
+ }
2073
2408
  function trimBlocks(blocks) {
2074
2409
  if (blocks.length > MAX_BLOCKS)
2075
2410
  blocks.splice(0, blocks.length - MAX_BLOCKS);