@oar-sh/oar 0.9.19 → 0.9.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 (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -3,6 +3,14 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
6
+ import { normalizeBuildId, readGitCommit, resolveBuildCommit } from '../../shared/build-id.mjs';
7
+ import { isWorkerOnOtherBuild } from './worker-recycle-service.mjs';
8
+
9
+ // The commit readers moved to shared/ so the workers can state their build id
10
+ // too; they are re-exported here so the routes and tests importing them keep
11
+ // their import.
12
+ export { readGitCommit, resolveBuildCommit };
13
+
6
14
  // What the About OAR modal shows: the running build, the providers that are
7
15
  // on, what this relay has stored over its life, and how it is doing now.
8
16
  //
@@ -64,58 +72,6 @@ function readFileText(fsImpl, filePath) {
64
72
  }
65
73
  }
66
74
 
67
- const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
68
-
69
- /**
70
- * The commit a git checkout has checked out, read from its .git files rather
71
- * than from a `git` process: HEAD, then the loose ref, then packed-refs. A
72
- * linked worktree's .git is a file naming its git dir, whose refs live in the
73
- * common dir. Anything unexpected reads as null. `pathImpl` is the host's path
74
- * module; tests pass path.posix and path.win32.
75
- */
76
- export function readGitCommit(root, { fsImpl = fs, pathImpl = path } = {}) {
77
- if (!root) return null;
78
- const dotGit = pathImpl.join(root, '.git');
79
- let gitDir = dotGit;
80
- const pointer = readFileText(fsImpl, dotGit);
81
- if (pointer !== null) {
82
- const match = pointer.match(/^gitdir:\s*(.+)$/m);
83
- if (!match) return null;
84
- gitDir = pathImpl.resolve(root, match[1].trim());
85
- }
86
- const head = readFileText(fsImpl, pathImpl.join(gitDir, 'HEAD'));
87
- if (head === null) return null;
88
- const headText = head.trim();
89
- if (COMMIT_PATTERN.test(headText)) return headText.toLowerCase();
90
- const refMatch = headText.match(/^ref:\s*(\S+)$/);
91
- if (!refMatch) return null;
92
- const ref = refMatch[1];
93
- const commonDirText = readFileText(fsImpl, pathImpl.join(gitDir, 'commondir'));
94
- const commonDir = commonDirText ? pathImpl.resolve(gitDir, commonDirText.trim()) : gitDir;
95
- for (const dir of new Set([gitDir, commonDir])) {
96
- const loose = readFileText(fsImpl, pathImpl.join(dir, ...ref.split('/')));
97
- if (loose && COMMIT_PATTERN.test(loose.trim())) return loose.trim().toLowerCase();
98
- }
99
- const packed = readFileText(fsImpl, pathImpl.join(commonDir, 'packed-refs')) || '';
100
- for (const line of packed.split(/\r?\n/)) {
101
- const [hash, name] = line.trim().split(/\s+/);
102
- if (name === ref && COMMIT_PATTERN.test(hash || '')) return hash.toLowerCase();
103
- }
104
- return null;
105
- }
106
-
107
- /** The commit this build came from: the checkout's HEAD, else a `gitHead` stamp in package.json. */
108
- export function resolveBuildCommit(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
109
- const fromGit = readGitCommit(packageRoot, { fsImpl, pathImpl });
110
- if (fromGit) return fromGit;
111
- try {
112
- const stamp = String(JSON.parse(readFileText(fsImpl, pathImpl.join(packageRoot, 'package.json')) || '{}')?.gitHead || '').trim();
113
- return COMMIT_PATTERN.test(stamp) ? stamp.toLowerCase() : null;
114
- } catch {
115
- return null;
116
- }
117
- }
118
-
119
75
  export function readInstalledPackageVersion(packageRoot, packageName, { fsImpl = fs } = {}) {
120
76
  if (!packageRoot || !packageName) return null;
121
77
  try {
@@ -273,6 +229,42 @@ export function collectUsageTotals(db) {
273
229
  usage['claude-cloud'] = { costUsd: toAmount(row.cost), sessions: toCount(row.sessions) };
274
230
  }
275
231
  }
232
+ // Every provider's tokens per turn, all time (token_usage_days, migration
233
+ // 0036): fresh input, output, cache writes and cache reads apart, each
234
+ // null while no turn reported it, the first day, and whether days copied
235
+ // at the upgrade are in it. An own key's turns (Copilot) are kept apart
236
+ // from the plan's.
237
+ if (tableExists(db, 'token_usage_days')) {
238
+ const rows = db.prepare(`
239
+ SELECT provider, billing, SUM(input_tokens) AS input, SUM(output_tokens) AS output,
240
+ SUM(cache_write_tokens) AS cache_write, SUM(cache_read_tokens) AS cache_read, MIN(day) AS since,
241
+ MIN(CASE WHEN source = 'turns' THEN day END) AS first_turns_day,
242
+ MAX(CASE WHEN source = 'backfill' THEN 1 ELSE 0 END) AS backfilled
243
+ FROM token_usage_days
244
+ GROUP BY provider, billing
245
+ ORDER BY provider, billing
246
+ `).all();
247
+ const nullable = (value) => (value === null || value === undefined ? null : toAmount(value));
248
+ for (const row of rows) {
249
+ const tokens = {
250
+ inputTokens: nullable(row.input),
251
+ outputTokens: nullable(row.output),
252
+ cacheWriteTokens: nullable(row.cache_write),
253
+ cacheReadTokens: nullable(row.cache_read),
254
+ since: row.since || null,
255
+ // Days copied at the upgrade (0036) hold cache writes inside input;
256
+ // the first counted turn's day is where that stops.
257
+ backfilled: Number(row.backfilled) === 1,
258
+ firstTurnsDay: row.first_turns_day || null,
259
+ };
260
+ const entry = usage[row.provider] || (usage[row.provider] = {});
261
+ if (row.billing) {
262
+ (entry.ownKeyTokens || (entry.ownKeyTokens = [])).push({ billing: row.billing, ...tokens });
263
+ } else {
264
+ entry.tokens = tokens;
265
+ }
266
+ }
267
+ }
276
268
  return usage;
277
269
  }
278
270
 
@@ -302,6 +294,7 @@ async function fileSize(fsImpl, filePath) {
302
294
  * @param {string|null} [deps.dbPath] the database file; its -wal file is counted too
303
295
  * @param {string} deps.version
304
296
  * @param {string|null} [deps.commit]
297
+ * @param {string|null} [deps.buildId] the relay's build id (shared/build-id.mjs), to tell the workers on other code
305
298
  * @param {string} [deps.installMethod]
306
299
  * @param {string} deps.startedAt ISO time this relay process started
307
300
  * @param {() => object} [deps.getProviderSettings] `{ github: { enabled, engine }, claude: { enabled }, … }`
@@ -310,12 +303,15 @@ async function fileSize(fsImpl, filePath) {
310
303
  * @param {() => object[]} [deps.listWorkers] the session worker registry's entries
311
304
  * @param {() => object|null} [deps.getUpdateState] buildUpdateStatePayload()
312
305
  * @param {object} [deps.sdkVersions] package version by provider id; read from node_modules when absent
306
+ * @param {() => object|null} [deps.describeBackups] the database backups (db-backup-service.mjs describeBackups)
313
307
  */
314
308
  export function createAboutService({
315
309
  db,
316
310
  dbPath = null,
311
+ describeBackups = null,
317
312
  version = '0.0.0',
318
313
  commit = null,
314
+ buildId = null,
319
315
  installMethod = null,
320
316
  startedAt,
321
317
  packageRoot = null,
@@ -359,20 +355,30 @@ export function createAboutService({
359
355
  return totalsInFlight;
360
356
  }
361
357
 
358
+ // The running workers, by provider and by build: after a deploy the
359
+ // workers keep the code they started with until the recycle replaces them,
360
+ // and About shows how many still do. A relay that does not know its own
361
+ // build id cannot tell, so both build counts are null then.
362
362
  function runningWorkersByProvider() {
363
363
  const counts = new Map();
364
364
  let total = 0;
365
+ const relayBuildId = normalizeBuildId(buildId);
366
+ let onOtherBuild = 0;
365
367
  for (const worker of listWorkers() || []) {
366
368
  const status = String(worker?.status || '').trim().toLowerCase();
367
369
  if (!ACTIVE_WORKER_STATUSES.has(status) || !isPidAlive(worker?.pid)) continue;
368
370
  total += 1;
371
+ if (isWorkerOnOtherBuild(worker, relayBuildId)) onOtherBuild += 1;
369
372
  const conversationId = String(worker?.conversationId || '').trim();
370
373
  const provider = conversationId
371
374
  ? normalizeProviderId(providerForConversation.get(conversationId)?.provider_type)
372
375
  : 'unassigned';
373
376
  counts.set(provider, (counts.get(provider) || 0) + 1);
374
377
  }
375
- return { total, counts };
378
+ const builds = relayBuildId
379
+ ? { total, onThisBuild: total - onOtherBuild, onOtherBuild }
380
+ : { total, onThisBuild: null, onOtherBuild: null };
381
+ return { total, counts, builds };
376
382
  }
377
383
 
378
384
  function describeProviders(workerCounts) {
@@ -436,6 +442,7 @@ export function createAboutService({
436
442
  startedAt: startedAt || null,
437
443
  uptimeSeconds,
438
444
  providers: describeProviders(workers.counts),
445
+ workers: workers.builds,
439
446
  stats: {
440
447
  conversations: totals.conversations.total,
441
448
  archivedConversations: totals.conversations.archived,
@@ -451,14 +458,35 @@ export function createAboutService({
451
458
  usage: totals.usage,
452
459
  relay: {
453
460
  uptimeSeconds,
461
+ // The full path: which of the two database names this relay runs on.
462
+ dbPath: dbPath || null,
454
463
  dbBytes: dbFileBytes + walBytes,
455
464
  dbFileBytes,
456
465
  walBytes,
457
466
  runningWorkers: workers.total,
458
467
  },
459
468
  update: describeUpdate(),
469
+ backups: readBackups(),
460
470
  };
461
471
  }
462
472
 
473
+ // The last backup and how many are kept, for one row; never the listing.
474
+ function readBackups() {
475
+ if (!describeBackups) return null;
476
+ try {
477
+ const backups = describeBackups();
478
+ return {
479
+ enabled: backups.enabled,
480
+ keep: backups.keep,
481
+ dir: backups.dir,
482
+ problem: backups.problem,
483
+ kept: backups.own.length,
484
+ lastResult: backups.lastResult,
485
+ };
486
+ } catch {
487
+ return null;
488
+ }
489
+ }
490
+
463
491
  return { describe };
464
492
  }
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ import { forgetTurnTokenRows } from './token-usage-service.mjs';
4
+
3
5
  function normalizeId(value) {
4
6
  return String(value || '').trim();
5
7
  }
@@ -99,6 +101,8 @@ export function createCacheRebuildService({
99
101
  db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(sid);
100
102
  db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(sid);
101
103
  db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(sid);
104
+ // Its turns' tokens go too; the day sums stay.
105
+ forgetTurnTokenRows(db, [sid]);
102
106
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(sid);
103
107
  })();
104
108
  closePreviews(sid);
@@ -164,6 +168,9 @@ export function createCacheRebuildService({
164
168
  db.prepare(`DELETE FROM queue`).run();
165
169
  db.prepare(`DELETE FROM messages`).run();
166
170
  db.prepare(`DELETE FROM runtime_sessions`).run();
171
+ // The turn tokens of the chats removed for good; a hand-titled chat
172
+ // stays, and keeps them.
173
+ forgetTurnTokenRows(db, db.prepare(`SELECT id FROM conversations WHERE status = 'deleted' OR title_source != 'manual'`).all().map((row) => row.id));
167
174
  db.prepare(`DELETE FROM conversations WHERE status = 'deleted' OR title_source != 'manual'`).run();
168
175
  for (const conversationId of manualTitleConversations) {
169
176
  db.prepare(`
@@ -28,29 +28,16 @@
28
28
  * the fence lets them through, and the waiting state ages out.
29
29
  */
30
30
 
31
+ import { workerReleaseHold } from './worker-replacement.mjs';
32
+
31
33
  export const CLAUDE_BILLING_SWAP_WAIT_TTL_MS = 15_000;
32
34
 
33
35
  /**
34
- * What keeps a worker from being replaced now: null when nothing does, else
35
- * 'turn', 'question', 'compaction' or 'background'. From the steering
36
- * snapshot its heartbeat sends; a worker too old to say `releasable` is read
37
- * from the rest of it.
36
+ * What keeps a worker from being replaced now (worker-replacement.mjs, which
37
+ * the worker recycle shares). Kept under its old name for the importers that
38
+ * know it by that.
38
39
  */
39
- export function claudeWorkerReleaseHold(worker) {
40
- if (!worker) return null;
41
- const steering = worker.steering || null;
42
- if (steering && typeof steering.releasable === 'boolean') {
43
- return steering.releasable ? null : (steering.releaseHold || 'turn');
44
- }
45
- if (steering) {
46
- if (steering.holdReason === 'question') return 'question';
47
- if (steering.holdReason === 'compaction') return 'compaction';
48
- if (steering.turnActive) return 'turn';
49
- if (steering.autoCompactWindow?.backgroundWork) return 'background';
50
- return null;
51
- }
52
- return String(worker.status || '') === 'processing' ? 'turn' : null;
53
- }
40
+ export const claudeWorkerReleaseHold = workerReleaseHold;
54
41
 
55
42
  export function createClaudeBillingSwapService({
56
43
  // sid → the registry's entry for the session's worker, or null.
@@ -208,7 +195,7 @@ export function createClaudeBillingSwapService({
208
195
  running = true;
209
196
  noteProbeFailure(sid, state, fence, error);
210
197
  }
211
- const hold = running ? 'turn' : claudeWorkerReleaseHold(worker);
198
+ const hold = running ? 'turn' : workerReleaseHold(worker);
212
199
  if (hold) {
213
200
  setPending(sid, state, {
214
201
  to: fence.rowBilling,
@@ -157,12 +157,9 @@ export function buildClaudeCloudDelivery({ conversation = null, runtimeSession =
157
157
  if (!isClaudeCloudProviderType(runtimeSession?.provider_type)) return null;
158
158
  const source = parseCloudSourceJson(conversation?.cloud_source_json);
159
159
  if (!source) return null;
160
- const sessionId = toText(runtimeSession?.claude_cloud_session_id);
161
- const lastSequence = toText(runtimeSession?.claude_cloud_last_sequence);
162
160
  const fallback = toText(fallbackEnvironmentId);
163
161
  return {
164
- sessionId: isSafeClaudeCloudId(sessionId) ? sessionId : null,
165
- lastSequence: SEQUENCE_PATTERN.test(lastSequence) ? lastSequence : null,
162
+ ...readStoredClaudeCloudBinding(runtimeSession),
166
163
  repoUrl: source.repoUrl,
167
164
  branch: source.branch,
168
165
  environmentId: source.environmentId || (isSafeClaudeCloudId(fallback) ? fallback : null),
@@ -170,6 +167,20 @@ export function buildClaudeCloudDelivery({ conversation = null, runtimeSession =
170
167
  };
171
168
  }
172
169
 
170
+ /**
171
+ * The cloud session a runtime session row is bound to and the cursor its
172
+ * worker reads on from: `{ sessionId, lastSequence }`, each null when absent
173
+ * or not in the shape the worker may put into a request path.
174
+ */
175
+ export function readStoredClaudeCloudBinding(runtimeSession = null) {
176
+ const sessionId = toText(runtimeSession?.claude_cloud_session_id);
177
+ const lastSequence = toText(runtimeSession?.claude_cloud_last_sequence);
178
+ return {
179
+ sessionId: isSafeClaudeCloudId(sessionId) ? sessionId : null,
180
+ lastSequence: SEQUENCE_PATTERN.test(lastSequence) ? lastSequence : null,
181
+ };
182
+ }
183
+
173
184
  /** The larger of two sequence numbers (strings of digits), so a late report never moves the cursor back. */
174
185
  function laterSequence(stored, reported) {
175
186
  const a = SEQUENCE_PATTERN.test(toText(stored)) ? toText(stored) : null;
@@ -264,6 +275,31 @@ export function createClaudeCloudSessionService({
264
275
  return { ok: true, cloud };
265
276
  }
266
277
 
278
+ /**
279
+ * GET /api/claude-cloud-session: the binding a starting worker attaches to
280
+ * before any delivery (so it follows the turns the session starts by
281
+ * itself, as the worker it replaces did). `id` is the conversation id or
282
+ * the runtime session's sdk session id, which is all a worker knows of its
283
+ * conversation. A known conversation that has no cloud session (none yet,
284
+ * or another provider) answers `sessionId: null`; only an unknown id is
285
+ * 404. Returns `{ ok, statusCode, error }` or `{ ok: true, sessionId,
286
+ * lastSequence }`.
287
+ */
288
+ function describeStoredBinding(id = '') {
289
+ const key = toText(id);
290
+ if (!key) return { ok: false, statusCode: 400, error: 'Missing conversationId' };
291
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(key)
292
+ || stmts.getRuntimeSessionBySdkSessionId?.get?.(key)
293
+ || null;
294
+ if (!runtimeSession && !stmts.getConvAnyStatus?.get?.(key)) {
295
+ return { ok: false, statusCode: 404, error: 'Conversation not found' };
296
+ }
297
+ if (!runtimeSession || !isClaudeCloudProviderType(runtimeSession.provider_type)) {
298
+ return { ok: true, sessionId: null, lastSequence: null };
299
+ }
300
+ return { ok: true, ...readStoredClaudeCloudBinding(runtimeSession) };
301
+ }
302
+
267
303
  /**
268
304
  * Archives the cloud session of a conversation that is being deleted or
269
305
  * archived here. Best effort: it returns at once (true when an archive was
@@ -322,5 +358,11 @@ export function createClaudeCloudSessionService({
322
358
  return true;
323
359
  }
324
360
 
325
- return { cloudPayloadFor, recordWorkerReport, archiveSessionInBackground, unarchiveSessionInBackground };
361
+ return {
362
+ cloudPayloadFor,
363
+ recordWorkerReport,
364
+ describeStoredBinding,
365
+ archiveSessionInBackground,
366
+ unarchiveSessionInBackground,
367
+ };
326
368
  }
@@ -44,6 +44,9 @@ export function persistConversationPreferences({
44
44
  // The owner's "agents in this conversation may start sessions" (a boolean;
45
45
  // `undefined` leaves it alone). The route decides who may write it.
46
46
  mayCreateSessions = undefined,
47
+ // Plan-only: agents cannot take the session out of plan mode (a boolean;
48
+ // `undefined` leaves it alone). The routes decide who may write it.
49
+ planOnly = undefined,
47
50
  updatedAt = new Date().toISOString(),
48
51
  createIfMissing = false,
49
52
  createTitle = 'Session',
@@ -60,6 +63,7 @@ export function persistConversationPreferences({
60
63
  const writesThinkingDisplay = thinkingDisplay !== undefined;
61
64
  const display = writesThinkingDisplay ? parseThinkingDisplay(thinkingDisplay) : null;
62
65
  const writesMayCreateSessions = mayCreateSessions !== undefined;
66
+ const writesPlanOnly = planOnly !== undefined;
63
67
  if (!db || !stmts || !convId || !mode) {
64
68
  return {
65
69
  ok: false,
@@ -71,6 +75,7 @@ export function persistConversationPreferences({
71
75
  thinkingEnabled: DEFAULT_THINKING_ENABLED,
72
76
  thinkingDisplay: DEFAULT_THINKING_DISPLAY,
73
77
  mayCreateSessions: false,
78
+ planOnly: false,
74
79
  updatedAt,
75
80
  };
76
81
  }
@@ -108,6 +113,10 @@ export function persistConversationPreferences({
108
113
  extraSets.push('may_create_sessions = ?');
109
114
  extraValues.push(mayCreateSessions === true ? 1 : 0);
110
115
  }
116
+ if (writesPlanOnly) {
117
+ extraSets.push('plan_only = ?');
118
+ extraValues.push(planOnly === true ? 1 : 0);
119
+ }
111
120
  try {
112
121
  if (extraSets.length) {
113
122
  db.prepare(`
@@ -148,6 +157,9 @@ export function persistConversationPreferences({
148
157
  mayCreateSessions: writesMayCreateSessions
149
158
  ? mayCreateSessions === true
150
159
  : readMayCreateSessions(existing),
160
+ planOnly: writesPlanOnly
161
+ ? planOnly === true
162
+ : readPlanOnly(existing),
151
163
  updatedAt,
152
164
  };
153
165
  });
@@ -159,6 +171,25 @@ export function readMayCreateSessions(row) {
159
171
  return Number(row?.may_create_sessions) === 1;
160
172
  }
161
173
 
174
+ /** Whether a conversations row is plan-only (migration 0035; NULL is not). */
175
+ export function readPlanOnly(row) {
176
+ return Number(row?.plan_only) === 1;
177
+ }
178
+
179
+ /**
180
+ * A compaction's new conversation keeps a plan-only source's lock and the
181
+ * mode it is saved in, so the work goes on as locked as it was. Nothing for
182
+ * any other source. Returns whether it carried the lock.
183
+ */
184
+ export function carryPlanOnlyToCompacted({ db, stmts, source, targetConversationId, updatedAt = new Date().toISOString() } = {}) {
185
+ if (!db || !stmts?.conversationsHavePlanOnly || !readPlanOnly(source)) return false;
186
+ const target = String(targetConversationId || '').trim();
187
+ if (!target) return false;
188
+ const mode = String(source?.preferred_relay_mode || '').trim() || 'plan';
189
+ db.prepare(`UPDATE conversations SET plan_only = 1, preferred_relay_mode = ?, updated_at = ? WHERE id = ?`).run(mode, updatedAt, target);
190
+ return true;
191
+ }
192
+
162
193
  export function persistConversationModelPreference({
163
194
  db,
164
195
  stmts,