@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.21

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 (148) hide show
  1. package/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-B4fj9PIe.js} +1 -1
  2. package/dist/assets/{AssistantView-DvoyOGkx.js → AssistantView-C00aiEHK.js} +1 -1
  3. package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BDFeDKu-.js} +39 -39
  4. package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-CUfSFI3e.js} +1 -1
  5. package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-CKKU6fKI.js} +1 -1
  6. package/dist/assets/{Icon-AXqa-fYB.js → Icon-DiIuWOuC.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-jVOkZkyX.js → MarkdownRendererImpl-Cen6E9Lj.js} +2 -2
  8. package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-DILi0tN7.js} +1 -1
  9. package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-PZMRiieI.js} +1 -1
  10. package/dist/assets/MobileSurface-BLYcR0tO.js +2 -0
  11. package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-D_2mWZyv.js} +1 -1
  12. package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-CYeRZznl.js} +1 -1
  13. package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-BgMLtMIq.js} +1 -1
  14. package/dist/assets/SettingsView-CbDBfQp1.js +25 -0
  15. package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-Br7XLcSd.js} +1 -1
  16. package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CM5jRKlj.js} +1 -1
  17. package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-D3U6aTZ4.js} +1 -1
  18. package/dist/assets/appThemeRegistry-CDxBJNNB.js +2 -0
  19. package/dist/assets/assistant-guide-hero-BeUHQcxE.jpg +0 -0
  20. package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
  21. package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-BnElHSnO.js} +1 -1
  22. package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
  23. package/dist/assets/{context-menu-BaxmodD8.js → context-menu-DBOMervd.js} +1 -1
  24. package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-ZK_vkdzL.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-CewM6-g5.js} +1 -1
  26. package/dist/assets/{diagram-CEon0BQs.js → diagram-DAE4CZy2.js} +1 -1
  27. package/dist/assets/{dialog-DjYfj1Sn.js → dialog-COrLBm9O.js} +1 -1
  28. package/dist/assets/{es-5Lhudh0n.js → es-DIVYdoxi.js} +1 -1
  29. package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
  30. package/dist/assets/{fr-Q_UhFmKT.js → fr-CNTlVypq.js} +2 -2
  31. package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BQT3QXD2.js} +1 -1
  32. package/dist/assets/gitApiHttp-BxGAFLTf.js +2 -0
  33. package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
  34. package/dist/assets/insertionBoundaries-9a0RuIDc.js +1 -0
  35. package/dist/assets/{ja-CzMPz7bT.js → ja-C_eW5ZM8.js} +1 -1
  36. package/dist/assets/{ko-B-EslUsR.js → ko-CE3cOv7v.js} +1 -1
  37. package/dist/assets/{main-LDphyc-v.js → main-BGfX81Y9.js} +20 -20
  38. package/dist/assets/{main-CO6z5yRB.js → main-CEqSDnE2.js} +2 -2
  39. package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-BDyY0vt6.js} +1 -1
  40. package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-D-aLQZaM.js} +1 -1
  41. package/dist/assets/{miniChat-BZQwTZpx.js → miniChat-CKucSAhg.js} +2 -2
  42. package/dist/assets/mobile-C4bTWKry.js +2 -0
  43. package/dist/assets/{multirun-ZlwlkIy5.js → multirun-DeNtSkgb.js} +1 -1
  44. package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
  45. package/dist/assets/{pl-Dnea6Ppc.js → pl-BZKZOE99.js} +1 -1
  46. package/dist/assets/projectMeta-BunN9pH5.js +3 -0
  47. package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-DHDgp8S3.js} +1 -1
  48. package/dist/assets/{radio-CJuhbQ5a.js → radio-Bziafaje.js} +1 -1
  49. package/dist/assets/renderElectronMiniChatApp-D8-FDgWE.js +2 -0
  50. package/dist/assets/renderMobileApp-CdU0aJ-e.js +23 -0
  51. package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-BXCt7ZsQ.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-CK1GhIsm.js +145 -0
  53. package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-pWlBorwQ.js} +1 -1
  54. package/dist/assets/src-8kQFmtjw.css +2 -0
  55. package/dist/assets/{store-wa1ersRf.js → store-CUWqzRt7.js} +2 -2
  56. package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-DpiAJlil.js} +1 -1
  57. package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
  58. package/dist/assets/{uk-xGc9Pzge.js → uk-CfQK6cz1.js} +1 -1
  59. package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
  60. package/dist/assets/useAppFontEffects-IEUQDvE8.js +7 -0
  61. package/dist/assets/useDesktopWindowControlsLayout-MlqJX89k.js +1 -0
  62. package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-GbhkdO6m.js} +1 -1
  63. package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-92Qg-98H.js} +1 -1
  64. package/dist/assets/useMobileNavigationStore-DQHF_ETu.js +476 -0
  65. package/dist/assets/useSessionAutoCleanup-BuU_S_Sh.js +1 -0
  66. package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-Ca6OgNHF.js} +1 -1
  67. package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
  68. package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-DvY2fKWp.js} +1 -1
  69. package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-eWe0lm3-.js} +1 -1
  70. package/dist/index.html +8 -8
  71. package/dist/mini-chat.html +8 -8
  72. package/dist/mobile.html +8 -8
  73. package/package.json +1 -1
  74. package/server/index.d.ts +6 -0
  75. package/server/index.js +13 -0
  76. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  77. package/server/lib/config-sync/config-sync.test.js +197 -0
  78. package/server/lib/config-sync/constants.js +39 -0
  79. package/server/lib/config-sync/contract.js +130 -0
  80. package/server/lib/config-sync/credential-auth.js +39 -0
  81. package/server/lib/config-sync/engine.js +108 -0
  82. package/server/lib/config-sync/index.js +67 -0
  83. package/server/lib/config-sync/local-backup.js +171 -0
  84. package/server/lib/config-sync/plan.js +318 -0
  85. package/server/lib/config-sync/receiver.js +330 -0
  86. package/server/lib/config-sync/routes.js +141 -0
  87. package/server/lib/config-sync/routes.test.js +154 -0
  88. package/server/lib/config-sync/scripts.js +264 -0
  89. package/server/lib/config-sync/selections.js +112 -0
  90. package/server/lib/config-sync/tar.js +35 -0
  91. package/server/lib/config-sync/target-id.js +24 -0
  92. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  93. package/server/lib/event-stream/diff-summary.js +39 -11
  94. package/server/lib/event-stream/diff-summary.test.js +73 -6
  95. package/server/lib/event-stream/protocol.test.js +70 -7
  96. package/server/lib/notifications/DOCUMENTATION.md +2 -2
  97. package/server/lib/notifications/runtime.js +68 -27
  98. package/server/lib/notifications/runtime.test.js +176 -0
  99. package/server/lib/opencode/DOCUMENTATION.md +4 -1
  100. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  101. package/server/lib/opencode/core-routes.js +1 -0
  102. package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
  103. package/server/lib/opencode/openchamber-routes.js +7 -0
  104. package/server/lib/opencode/settings-runtime.js +82 -51
  105. package/server/lib/opencode/settings-runtime.test.js +42 -0
  106. package/server/lib/relay/DOCUMENTATION.md +1 -1
  107. package/server/lib/session-index/DOCUMENTATION.md +12 -6
  108. package/server/lib/session-index/routes.js +34 -0
  109. package/server/lib/session-index/routes.test.js +76 -0
  110. package/server/lib/session-index/service.js +53 -4
  111. package/server/lib/session-index/service.test.js +72 -2
  112. package/server/lib/session-title/DOCUMENTATION.md +19 -14
  113. package/server/lib/session-title/runtime.js +8 -8
  114. package/server/lib/session-title/runtime.test.js +231 -0
  115. package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
  116. package/server/lib/session-turn-pages/changes.service.js +156 -0
  117. package/server/lib/session-turn-pages/changes.service.test.js +188 -0
  118. package/server/lib/session-turn-pages/reconcile.routes.test.js +52 -0
  119. package/server/lib/session-turn-pages/reconcile.service.js +3 -1
  120. package/server/lib/session-turn-pages/reconcile.service.test.js +44 -0
  121. package/server/lib/session-turn-pages/routes.js +273 -12
  122. package/server/lib/session-turn-pages/routes.test.js +387 -0
  123. package/server/lib/session-turn-pages/service.js +31 -7
  124. package/server/lib/session-turn-pages/service.test.js +40 -0
  125. package/server/lib/small-model/DOCUMENTATION.md +93 -38
  126. package/server/lib/small-model/catalog.js +10 -2
  127. package/server/lib/small-model/catalog.test.js +3 -1
  128. package/server/lib/small-model/index.js +52 -28
  129. package/server/lib/small-model/opencode-session.js +339 -0
  130. package/server/lib/small-model/opencode-session.test.js +177 -0
  131. package/server/lib/small-model/resolve.js +116 -5
  132. package/server/lib/small-model/resolve.test.js +76 -10
  133. package/server/lib/small-model/summary-settings.test.js +60 -3
  134. package/dist/assets/MobileSurface-htGkf7Ua.js +0 -2
  135. package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
  136. package/dist/assets/appThemeRegistry-CgfOR_0L.js +0 -2
  137. package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
  138. package/dist/assets/insertionBoundaries-DzXMTleb.js +0 -1
  139. package/dist/assets/mobile-BktO3_4R.js +0 -2
  140. package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
  141. package/dist/assets/renderElectronMiniChatApp-Db8uO-6q.js +0 -2
  142. package/dist/assets/renderMobileApp-BCV81Ryl.js +0 -23
  143. package/dist/assets/runtimeEndpointReset-Dg3DMy9v.js +0 -145
  144. package/dist/assets/src-oHrYIqHF.css +0 -2
  145. package/dist/assets/useAppFontEffects-CM0VKsdo.js +0 -7
  146. package/dist/assets/useDesktopWindowControlsLayout-Cq-r4Hcb.js +0 -1
  147. package/dist/assets/useMobileNavigationStore-DnzT4jO2.js +0 -475
  148. package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
@@ -5,10 +5,11 @@ conversation's **main subject** (overall feature / goal) with the small model
5
5
  (`lib/small-model`), then PATCHes `title` plus `metadata.openchamber.titleRefresh`.
6
6
 
7
7
  OpenCode only auto-titles once from the first user message
8
- (`SessionPrompt.ensureTitle`). Long sessions often need a refresh; this module
9
- keeps the sidebar title aligned with the durable work being done not the last
10
- wrap-up utterance like "commit and push"throttled to at most once per 5
11
- minutes per session.
8
+ (`SessionPrompt.ensureTitle`). This module auto-refreshes sparsely (first idle
9
+ of a new session, first newly-sent reply on a fork) and on explicit smart-title
10
+ requests, naming the durable work being done not the last wrap-up utterance
11
+ like "commit and push". Background auto refreshes still respect a 5-minute
12
+ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
12
13
 
13
14
  ## Flow
14
15
 
@@ -16,19 +17,23 @@ minutes per session.
16
17
  fan-out (`index.js` → `globalMessageStreamHub.subscribeEvent`), same
17
18
  pattern as session-assist. Purely event-driven — dormant sessions never
18
19
  generate anything.
19
- 2. A newly observed root `session.created` generates its first title immediately
20
- on the first `session.status: idle`. A fork title (`(fork #n)`) waits for its
21
- first newly-created user message; the matching assistant completion triggers
22
- an immediate title refresh that bypasses inherited title metadata and throttle.
23
- Later idle transitions arm a 15-second quiet timer; any `busy`/`retry` status
24
- or a fresh user `message.updated` clears it.
25
- A sidebar smart-title request sets `titleRefresh.requestedAt`; its
26
- `session.updated` event arms the same flow immediately.
20
+ 2. Auto title refresh is intentionally sparse (title stability first). A newly
21
+ observed root `session.created` generates its first title immediately on the
22
+ first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
23
+ newly-created user message; the matching assistant completion triggers an
24
+ immediate title refresh that bypasses inherited title metadata and throttle.
25
+ Ordinary later idle transitions do **not** arm another refresh. Any
26
+ `busy`/`retry` status or a fresh user `message.updated` still clears an
27
+ already-armed timer (so initial/fork timers cancel if the user keeps going).
28
+ A sidebar smart-title request sets `titleRefresh.requestedAt`; its
29
+ `session.updated` event arms the same flow immediately (forced / manual
30
+ refresh is unaffected by the background gate).
27
31
  3. On fire:
28
32
  - Skip when Settings → Chat → session title refresh is off.
29
33
  - Skip system-owned sessions whose metadata carries a non-empty
30
- `openchamber.assistant.assistantID` or `openchamber.scheduledTask.taskID`
31
- (fixed Assistant / scheduled titles must not be rewritten). Early
34
+ `openchamber.assistant.assistantID`, `openchamber.scheduledTask.taskID`, or
35
+ `openchamber.smallModel.purpose`
36
+ (fixed Assistant / scheduled / small-model titles must not be rewritten). Early
32
37
  `session.created` and smart-title request extraction skip the same
33
38
  metadata when present so timers are not armed.
34
39
  - Skip sub-agent sessions (`parentID`), multi-run/fusion structural titles,
@@ -1,13 +1,14 @@
1
- // Session title refresh: after a session goes idle, regenerate the sidebar
2
- // title from the conversation's MAIN SUBJECT (overall feature / goal), not
3
- // just the last wrap-up utterance. OpenCode only titles once from the first
4
- // user message; long sessions need refresh, but continuing the same work
1
+ // Session title refresh: regenerate the sidebar title from the conversation's
2
+ // MAIN SUBJECT (overall feature / goal), not just the last wrap-up utterance.
3
+ // OpenCode only titles once from the first user message. Auto refresh is sparse
4
+ // for title stability: first idle of a new session, first newly-sent reply on a
5
+ // fork, plus explicit smart-title / forced refresh. Continuing the same work
5
6
  // should keep naming the thing being done — not "commit and push". This
6
7
  // watcher throttles to at most one refresh per session every TITLE_THROTTLE_MS
7
8
  // and never overwrites a user-renamed title.
8
9
  //
9
- // Purely event-driven: only sessions that transition busy→idle while the
10
- // server is running ever generate anything. No backfill, no session scans.
10
+ // Purely event-driven: only sessions that arm a refresh while the server is
11
+ // running ever generate anything. No backfill, no session scans.
11
12
 
12
13
  import fs from 'fs';
13
14
  import os from 'os';
@@ -164,6 +165,7 @@ export const isSystemOwnedSession = (sessionOrInfo) => {
164
165
  if (!openchamber || typeof openchamber !== 'object') return false;
165
166
  if (typeof openchamber.assistant?.assistantID === 'string' && openchamber.assistant.assistantID) return true;
166
167
  if (typeof openchamber.scheduledTask?.taskID === 'string' && openchamber.scheduledTask.taskID) return true;
168
+ if (typeof openchamber.smallModel?.purpose === 'string' && openchamber.smallModel.purpose) return true;
167
169
  return false;
168
170
  };
169
171
 
@@ -742,8 +744,6 @@ export const createSessionTitleRuntime = ({
742
744
  if (newSessions.delete(status.sessionId)) {
743
745
  initialRefreshes.add(status.sessionId);
744
746
  armTimer(status.sessionId, status.directory || directoryHint, 0);
745
- } else {
746
- armTimer(status.sessionId, status.directory || directoryHint, quietMs);
747
747
  }
748
748
  } else {
749
749
  clearTimer(status.sessionId);
@@ -232,10 +232,16 @@ describe('session-title helpers', () => {
232
232
  expect(isSystemOwnedSession({
233
233
  metadata: { openchamber: { scheduledTask: { taskID: 'task_1' } } },
234
234
  })).toBe(true);
235
+ expect(isSystemOwnedSession({
236
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
237
+ })).toBe(true);
235
238
  expect(isSystemOwnedSession({
236
239
  title: '[Assistant] Looks system',
237
240
  metadata: { openchamber: { assistant: { name: 'no-id' } } },
238
241
  })).toBe(false);
242
+ expect(isSystemOwnedSession({
243
+ metadata: { openchamber: { smallModel: { purpose: '' } } },
244
+ })).toBe(false);
239
245
  expect(isSystemOwnedSession({ title: 'Ordinary' })).toBe(false);
240
246
  });
241
247
 
@@ -403,4 +409,229 @@ describe('session-title helpers', () => {
403
409
  globalThis.fetch = originalFetch;
404
410
  }
405
411
  });
412
+
413
+ it('refreshes a new session on first idle, then ignores later idle transitions', async () => {
414
+ const originalFetch = globalThis.fetch;
415
+ const session = {
416
+ id: 'ses-new',
417
+ title: 'New session - 2026-07-10T12:00:00.000Z',
418
+ metadata: {},
419
+ };
420
+ const messages = [
421
+ { info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Add rate limiting' }] },
422
+ { info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Implemented rate limit' }] },
423
+ ];
424
+ let generationCalls = 0;
425
+ let patchedTitle = session.title;
426
+
427
+ globalThis.fetch = async (input, init = {}) => {
428
+ const url = String(input);
429
+ if (url.includes('/message')) {
430
+ return new Response(JSON.stringify(messages), { status: 200 });
431
+ }
432
+ if (init.method === 'PATCH') {
433
+ const body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
434
+ if (typeof body?.title === 'string') {
435
+ patchedTitle = body.title;
436
+ session.title = body.title;
437
+ session.metadata = body.metadata || session.metadata;
438
+ } else if (body?.metadata) {
439
+ session.metadata = body.metadata;
440
+ }
441
+ return new Response(JSON.stringify(session), { status: 200 });
442
+ }
443
+ return new Response(JSON.stringify({ ...session, title: patchedTitle }), { status: 200 });
444
+ };
445
+
446
+ try {
447
+ const runtime = createSessionTitleRuntime({
448
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
449
+ getOpenCodeAuthHeaders: () => ({}),
450
+ getSmallModelService: async () => ({
451
+ generateSmallModelText: async () => {
452
+ generationCalls += 1;
453
+ return { text: 'Rate limiting', providerID: 'test', modelID: 'test' };
454
+ },
455
+ }),
456
+ now: () => 1_000,
457
+ isTitleRefreshEnabled: () => true,
458
+ });
459
+
460
+ runtime.processPayload({
461
+ type: 'session.created',
462
+ properties: {
463
+ directory: '/repo',
464
+ info: { id: 'ses-new', title: session.title, time: { created: 100 } },
465
+ },
466
+ });
467
+ runtime.processPayload({
468
+ type: 'session.status',
469
+ properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'idle' } },
470
+ });
471
+ await new Promise((resolve) => setTimeout(resolve, 25));
472
+ expect(generationCalls).toBe(1);
473
+
474
+ // Later conversation progress + idle must not auto-refresh again.
475
+ messages.push(
476
+ { info: { id: 'u2', role: 'user' }, parts: [{ type: 'text', text: 'Also add retries' }] },
477
+ { info: { id: 'a2', role: 'assistant' }, parts: [{ type: 'text', text: 'Retries added' }] },
478
+ );
479
+ runtime.processPayload({
480
+ type: 'message.updated',
481
+ properties: { directory: '/repo', info: { sessionID: 'ses-new', role: 'user', time: { created: 200 } } },
482
+ });
483
+ runtime.processPayload({
484
+ type: 'session.status',
485
+ properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'busy' } },
486
+ });
487
+ runtime.processPayload({
488
+ type: 'session.status',
489
+ properties: { sessionID: 'ses-new', directory: '/repo', status: { type: 'idle' } },
490
+ });
491
+ await new Promise((resolve) => setTimeout(resolve, 25));
492
+ expect(generationCalls).toBe(1);
493
+
494
+ runtime.stop();
495
+ } finally {
496
+ globalThis.fetch = originalFetch;
497
+ }
498
+ });
499
+
500
+ it('still runs an explicit smart-title forced refresh after initial title', async () => {
501
+ const originalFetch = globalThis.fetch;
502
+ const session = {
503
+ id: 'ses-forced',
504
+ title: 'Rate limiting',
505
+ metadata: {
506
+ openchamber: {
507
+ titleRefresh: {
508
+ lastAutoTitle: 'Rate limiting',
509
+ generatedAt: 500,
510
+ forMessageID: 'a1',
511
+ },
512
+ },
513
+ },
514
+ };
515
+ const messages = [
516
+ { info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Add rate limiting' }] },
517
+ { info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Implemented rate limit' }] },
518
+ { info: { id: 'u2', role: 'user' }, parts: [{ type: 'text', text: 'Switch to token bucket' }] },
519
+ { info: { id: 'a2', role: 'assistant' }, parts: [{ type: 'text', text: 'Token bucket ready' }] },
520
+ ];
521
+ let generationCalls = 0;
522
+
523
+ globalThis.fetch = async (input, init = {}) => {
524
+ const url = String(input);
525
+ if (url.includes('/message')) {
526
+ return new Response(JSON.stringify(messages), { status: 200 });
527
+ }
528
+ if (init.method === 'PATCH') {
529
+ return new Response(JSON.stringify(session), { status: 200 });
530
+ }
531
+ return new Response(JSON.stringify(session), { status: 200 });
532
+ };
533
+
534
+ try {
535
+ const runtime = createSessionTitleRuntime({
536
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
537
+ getOpenCodeAuthHeaders: () => ({}),
538
+ getSmallModelService: async () => ({
539
+ generateSmallModelText: async () => {
540
+ generationCalls += 1;
541
+ return { text: 'Token bucket rate limiting', providerID: 'test', modelID: 'test' };
542
+ },
543
+ }),
544
+ now: () => 1_000,
545
+ isTitleRefreshEnabled: () => true,
546
+ });
547
+
548
+ runtime.processPayload({
549
+ type: 'session.status',
550
+ properties: { sessionID: 'ses-forced', directory: '/repo', status: { type: 'idle' } },
551
+ });
552
+ await new Promise((resolve) => setTimeout(resolve, 25));
553
+ expect(generationCalls).toBe(0);
554
+
555
+ runtime.processPayload({
556
+ type: 'session.updated',
557
+ properties: {
558
+ directory: '/repo',
559
+ info: {
560
+ id: 'ses-forced',
561
+ title: session.title,
562
+ metadata: {
563
+ openchamber: {
564
+ titleRefresh: {
565
+ ...session.metadata.openchamber.titleRefresh,
566
+ requestedAt: 1_000,
567
+ },
568
+ },
569
+ },
570
+ },
571
+ },
572
+ });
573
+ await new Promise((resolve) => setTimeout(resolve, 25));
574
+ expect(generationCalls).toBe(1);
575
+
576
+ runtime.stop();
577
+ } finally {
578
+ globalThis.fetch = originalFetch;
579
+ }
580
+ });
581
+
582
+ it('skips automatic initial refresh when session title refresh is disabled', async () => {
583
+ const originalFetch = globalThis.fetch;
584
+ const session = {
585
+ id: 'ses-disabled',
586
+ title: 'New session - 2026-07-10T12:00:00.000Z',
587
+ metadata: {},
588
+ };
589
+ let generationCalls = 0;
590
+
591
+ globalThis.fetch = async (input, init = {}) => {
592
+ if (String(input).includes('/message')) {
593
+ return new Response(JSON.stringify([
594
+ { info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'hello' }] },
595
+ { info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'world' }] },
596
+ ]), { status: 200 });
597
+ }
598
+ if (init.method === 'PATCH') {
599
+ return new Response(JSON.stringify(session), { status: 200 });
600
+ }
601
+ return new Response(JSON.stringify(session), { status: 200 });
602
+ };
603
+
604
+ try {
605
+ const runtime = createSessionTitleRuntime({
606
+ buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
607
+ getOpenCodeAuthHeaders: () => ({}),
608
+ getSmallModelService: async () => ({
609
+ generateSmallModelText: async () => {
610
+ generationCalls += 1;
611
+ return { text: 'Should not run', providerID: 'test', modelID: 'test' };
612
+ },
613
+ }),
614
+ now: () => 1_000,
615
+ isTitleRefreshEnabled: () => false,
616
+ });
617
+
618
+ runtime.processPayload({
619
+ type: 'session.created',
620
+ properties: {
621
+ directory: '/repo',
622
+ info: { id: 'ses-disabled', title: session.title, time: { created: 100 } },
623
+ },
624
+ });
625
+ runtime.processPayload({
626
+ type: 'session.status',
627
+ properties: { sessionID: 'ses-disabled', directory: '/repo', status: { type: 'idle' } },
628
+ });
629
+ await new Promise((resolve) => setTimeout(resolve, 25));
630
+ expect(generationCalls).toBe(0);
631
+
632
+ runtime.stop();
633
+ } finally {
634
+ globalThis.fetch = originalFetch;
635
+ }
636
+ });
406
637
  });
@@ -11,6 +11,8 @@ Exposed as:
11
11
 
12
12
  - `GET /api/openchamber/sessions/:sessionID/messages`
13
13
  - `GET /api/openchamber/sessions/:sessionID/messages/reconcile`
14
+ - `GET /api/openchamber/sessions/:sessionID/changes` (L2 file list / L3 single-file patch)
15
+ - `GET /api/session/:sessionID/message/:messageID` (exact message with L1 summary projection; registered before the generic OpenCode proxy)
14
16
 
15
17
  ## Why this exists
16
18
 
@@ -69,9 +71,22 @@ Success `200`:
69
71
  - `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, `complete` stays `false` with a `cursor` so the client can fetch the trimmed history.
70
72
  - `partsProjection`: `"slim-v1"` on every turn-page response (first packet and prepend). Names the projection applied to `records`; see below. Reconcile responses omit this field and keep full parts.
71
73
 
72
- ### Turn-page parts projection
74
+ ### Turn-page payload projection
75
+
76
+ Every turn-page response summarizes `tool`, `reasoning`, and `file` parts plus message `summary.diffs` so it does not have to carry the bodies of a long tool-heavy turn, inline attachment data URLs, or full file patches. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
77
+
78
+ ### Changes Host contract (L1 / L2 / L3)
79
+
80
+ | Layer | Surface | Payload |
81
+ |---|---|---|
82
+ | **L1** | Turn pages, reconcile response pages, message/session SSE fan-out, exact `GET /api/session/:sessionID/message/:messageID` | Any `info.summary.diffs` **array** is projected to a **slim file list** `[{ file, status?, additions, deletions }]` plus additive markers `summary.diffCount: number` and `summary.hasDiffs: boolean`. Patch/before/after/from/to and other heavy bodies are stripped. Other lightweight summary fields are preserved. `diffCount` equals the slim array length; `hasDiffs === diffCount > 0`. Consumers may use either the slim array or the markers. |
83
+ | **L2** | `GET /api/openchamber/sessions/:sessionID/changes?messageID=` (no `file`) | Host reads official `session.message` locally, returns only `{ files: [{ file, status?, additions, deletions }] }`. Never returns patch/before/after/from/to or the full message envelope. |
84
+ | **L3** | Same route with `file=` | Host calls official `session.diff({ sessionID, messageID, directory })`, returns `{ diff: SnapshotFileDiff }` for the exact `file` string match (includes that file's patch). No match → HTTP 404 `{ error: 'change file not found', code: 'change_not_found' }`. |
85
+
86
+ Exact message GET keeps the official `{ info, parts }` response shape and only applies L1 summary projection so materialize/recovery never relays raw multi-megabyte patch bodies through the generic proxy. Abort/45s timeout semantics match the turn-page routes. Global `/api` auth still applies.
87
+
88
+ Changes query guards: `sessionID` / `messageID` length `1..512`; optional `file` length `1..4096` and rejects control characters / NUL. Failures: invalid params → 400, abort → 499, upstream/malformed → 502. Logs carry only safe flags and ids — never paths, patch bodies, or tokens.
73
89
 
74
- Every turn-page response summarizes `tool`, `reasoning`, and `file` parts so it does not have to carry the bodies of a long tool-heavy turn or inline attachment data URLs. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
75
90
 
76
91
  | Kept | Dropped |
77
92
  |---|---|
@@ -85,6 +100,7 @@ Rules:
85
100
  - Slim tool input keeps only short locator and task-identity fields (path, pattern, query, command, `subagent_type`, `description`, skill `name` / `id`). Slim metadata keeps `sessionId`, skill `name`, plus edit `additions`/`deletions`. Projection still drops result bodies, task prompts, patches, and large write payloads.
86
101
  - Assistant `text` stays intact. User rows are no longer wholesale pass-through: their `file` parts are projected; other user parts stay as-is.
87
102
  - `file` projection keeps already-present size/width/height (top-level or `metadata`). `byteSize` is derived from a data URL when present. PNG/GIF/JPEG/WebP headers may fill missing `width`/`height`. Unknown dimensions omit those fields.
103
+ - Message `info.summary.diffs` is L1-projected to a slim file list plus `diffCount` / `hasDiffs` (never patch bodies). Reconcile preserves full parts and applies this same L1 summary projection to response-page byte budgeting and serialization; raw upstream scan budgets retain raw bytes. Clients that need a dedicated file-list envelope or per-file patches use the Changes L2/L3 API.
88
104
  - A slim `file` part is a reference, not renderable content. Clients must hydrate the full message (`session.message` by `messageID`) before painting or editing the attachment. Do not treat slim file metadata as a complete part.
89
105
  - Records that gain nothing from projection keep their original object reference, so identity-based change detection downstream still works.
90
106
  - Projection applies to every turn-page response, including first packet, prepend, and explicit cursors (`before` present). Reconcile routes return full parts, unchanged.
@@ -242,10 +258,11 @@ When the per-page budget fills before the gap is complete, the host returns `con
242
258
 
243
259
  ## Files
244
260
 
245
- - `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
261
+ - `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `projectMessageDiffSummaries` (L1), `projectSlimParts`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
246
262
  - `reconcile.service.js` — `encodeReconcileContinuation`, `decodeReconcileContinuation`, `createSessionReconcileService({ fetchPage, runtimeKey?, page/total budgets?, continuationSecret?, clock?, continuationTtlMs? })`
247
- - `routes.js` — Express registration for both turn-page and reconcile; optional injected `sessionTurnPageService` / `sessionReconcileService` for tests; default wires SDK `session.messages` via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`
248
- - `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` unit contracts
263
+ - `changes.service.js` — `createSessionChangesService({ fetchMessage, fetchDiff })` for L2/L3; injectable for unit tests
264
+ - `routes.js` — Express registration for turn-page, reconcile, Changes, and exact message GET; optional injected `sessionTurnPageService` / `sessionReconcileService` / `sessionChangesService` / `fetchExactMessage` for tests; default wires SDK `session.messages` / `session.message` / `session.diff` via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`
265
+ - `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` / `changes.service.test.js` — unit contracts
249
266
 
250
267
  ## Registration
251
268
 
@@ -257,14 +274,17 @@ registerSessionTurnPageRoutes(app, {
257
274
  });
258
275
  ```
259
276
 
260
- Reconcile is registered on the more-specific path **before** the generic messages path inside `registerSessionTurnPageRoutes`.
277
+ Inside `registerSessionTurnPageRoutes`, registration order is: reconcile turn-page messages → exact `GET /api/session/:sessionID/message/:messageID` → Changes. All of these register before the generic OpenCode proxy in `feature-routes-runtime.js`. Electron reuses this same Host composition in-process.
261
278
 
262
279
  ## Error HTTP map
263
280
 
264
281
  | Condition | Status |
265
282
  |---|---|
266
283
  | Invalid turns / scanLimit / sessionID / cursor | 400 |
284
+ | Invalid messageID / file (Changes / exact message) | 400 |
267
285
  | Invalid anchor / continuation / reconcile params | 400 |
286
+ | Change file not found (`change_not_found`) | 404 |
287
+ | Exact message not found | 404 |
268
288
  | max_scan_pages / max_scan_messages | 413 |
269
289
  | Client abort | 499 |
270
290
  | Server exception (reconcile) | 500 (stack logged; safe client body) |
@@ -0,0 +1,156 @@
1
+ /**
2
+ * OpenChamber-owned session Changes API (L2 file list / L3 single-file patch).
3
+ *
4
+ * L2 (no `file`): read official `session.message`, project summary.diffs to
5
+ * `{ files: [{ file, status?, additions, deletions }] }` — never patch bodies
6
+ * or the full message envelope.
7
+ * L3 (with `file`): call official `session.diff({ sessionID, messageID })`,
8
+ * return `{ diff: SnapshotFileDiff }` for the exact file string match.
9
+ *
10
+ * Dependency-injected for unit tests (`fetchMessage`, `fetchDiff`).
11
+ */
12
+
13
+ /**
14
+ * Project a raw summary.diffs entry to L2 file-list scalars.
15
+ * @param {unknown} entry
16
+ * @returns {{ file: string, status?: string, additions: number, deletions: number } | null}
17
+ */
18
+ export const projectChangeFileEntry = (entry) => {
19
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
20
+ return null;
21
+ }
22
+ const file = typeof entry.file === 'string' ? entry.file : null;
23
+ if (!file || file.length === 0) {
24
+ return null;
25
+ }
26
+
27
+ /** Built from allowlist only — patch/before/after/from/to never copied. */
28
+ const out = { file };
29
+ if (typeof entry.status === 'string') {
30
+ out.status = entry.status;
31
+ }
32
+ out.additions = typeof entry.additions === 'number' && Number.isFinite(entry.additions)
33
+ ? entry.additions
34
+ : 0;
35
+ out.deletions = typeof entry.deletions === 'number' && Number.isFinite(entry.deletions)
36
+ ? entry.deletions
37
+ : 0;
38
+ return out;
39
+ };
40
+
41
+ /**
42
+ * Extract L2 file list from a session.message response shape.
43
+ * Accepts `{ info, parts }`, `{ data: { info, parts } }`, or bare info.
44
+ * @param {unknown} messagePayload
45
+ * @returns {{ files: Array<{ file: string, status?: string, additions: number, deletions: number }> }}
46
+ */
47
+ export const projectChangeFileList = (messagePayload) => {
48
+ const record = messagePayload && typeof messagePayload === 'object' && !Array.isArray(messagePayload)
49
+ ? messagePayload
50
+ : null;
51
+ const info = record?.info && typeof record.info === 'object' && !Array.isArray(record.info)
52
+ ? record.info
53
+ : (record?.summary ? record : null);
54
+ const summary = info?.summary && typeof info.summary === 'object' && !Array.isArray(info.summary)
55
+ ? info.summary
56
+ : null;
57
+ const diffs = Array.isArray(summary?.diffs) ? summary.diffs : [];
58
+
59
+ const files = [];
60
+ for (const entry of diffs) {
61
+ const projected = projectChangeFileEntry(entry);
62
+ if (projected) files.push(projected);
63
+ }
64
+ return { files };
65
+ };
66
+
67
+ /**
68
+ * Find exact file match in a session.diff array.
69
+ * @param {unknown} diffs
70
+ * @param {string} file
71
+ * @returns {unknown | null}
72
+ */
73
+ export const findChangeFileDiff = (diffs, file) => {
74
+ if (!Array.isArray(diffs) || typeof file !== 'string') {
75
+ return null;
76
+ }
77
+ for (const entry of diffs) {
78
+ if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.file === file) {
79
+ return entry;
80
+ }
81
+ }
82
+ return null;
83
+ };
84
+
85
+ /**
86
+ * @param {{
87
+ * fetchMessage: (args: { sessionID: string, messageID: string, directory?: string, signal?: AbortSignal }) => Promise<unknown>,
88
+ * fetchDiff: (args: { sessionID: string, messageID: string, directory?: string, signal?: AbortSignal }) => Promise<unknown>,
89
+ * logger?: { warn?: Function },
90
+ * }} deps
91
+ */
92
+ export const createSessionChangesService = (deps) => {
93
+ const {
94
+ fetchMessage,
95
+ fetchDiff,
96
+ logger = console,
97
+ } = deps;
98
+
99
+ if (typeof fetchMessage !== 'function' || typeof fetchDiff !== 'function') {
100
+ throw new Error('createSessionChangesService requires fetchMessage and fetchDiff');
101
+ }
102
+
103
+ /**
104
+ * @param {{
105
+ * sessionID: string,
106
+ * messageID: string,
107
+ * directory?: string,
108
+ * file?: string,
109
+ * signal?: AbortSignal,
110
+ * }} input
111
+ */
112
+ const loadChanges = async (input) => {
113
+ const sessionID = typeof input?.sessionID === 'string' ? input.sessionID : '';
114
+ const messageID = typeof input?.messageID === 'string' ? input.messageID : '';
115
+ const directory = typeof input?.directory === 'string' && input.directory.length > 0
116
+ ? input.directory
117
+ : undefined;
118
+ const file = typeof input?.file === 'string' && input.file.length > 0
119
+ ? input.file
120
+ : undefined;
121
+ const signal = input?.signal;
122
+
123
+ if (!sessionID || !messageID) {
124
+ return { ok: false, error: 'invalid_params' };
125
+ }
126
+
127
+ try {
128
+ if (!file) {
129
+ const payload = await fetchMessage({ sessionID, messageID, directory, signal });
130
+ return { ok: true, body: projectChangeFileList(payload) };
131
+ }
132
+
133
+ const diffs = await fetchDiff({ sessionID, messageID, directory, signal });
134
+ if (!Array.isArray(diffs)) {
135
+ logger?.warn?.('[session-changes] session.diff malformed payload');
136
+ return { ok: false, error: 'upstream' };
137
+ }
138
+ const match = findChangeFileDiff(diffs, file);
139
+ if (!match) {
140
+ return { ok: false, error: 'change_not_found' };
141
+ }
142
+ return { ok: true, body: { diff: match } };
143
+ } catch (error) {
144
+ if (error?.name === 'AbortError' || error?.code === 'aborted') {
145
+ return { ok: false, error: 'aborted' };
146
+ }
147
+ if (typeof error?.code === 'string' && error.code.length > 0) {
148
+ return { ok: false, error: error.code };
149
+ }
150
+ logger?.warn?.('[session-changes] upstream failure');
151
+ return { ok: false, error: 'upstream' };
152
+ }
153
+ };
154
+
155
+ return { loadChanges };
156
+ };