@oar-sh/oar 0.9.6 → 0.9.7

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 (81) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +176 -22
  3. package/package.json +1 -1
  4. package/server/README.md +174 -7
  5. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  6. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  10. package/server/claude-worker/claude-session-process.mjs +33 -0
  11. package/server/claude-worker/claude-session-worker.mjs +6 -0
  12. package/server/db-schema.mjs +17 -0
  13. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  14. package/server/migrations/0007-claude-cloud.mjs +82 -0
  15. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  16. package/server/public/app/agent-sessions-settings.mjs +134 -0
  17. package/server/public/app/api-client.js +143 -4
  18. package/server/public/app/attachments-view.js +20 -1
  19. package/server/public/app/attribution-control.mjs +46 -0
  20. package/server/public/app/bootstrap.js +344 -31
  21. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  22. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  23. package/server/public/app/claude-cloud-ui.mjs +611 -0
  24. package/server/public/app/composer-control-state.mjs +11 -0
  25. package/server/public/app/context-menu.mjs +140 -0
  26. package/server/public/app/context-tier-options.mjs +4 -0
  27. package/server/public/app/conversation-origin-ui.js +27 -0
  28. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  29. package/server/public/app/conversation-view.js +83 -4
  30. package/server/public/app/cwd-picker.js +12 -0
  31. package/server/public/app/journal-view.js +698 -28
  32. package/server/public/app/math-dollars.mjs +89 -0
  33. package/server/public/app/model-selector-options.mjs +1 -1
  34. package/server/public/app/plan-usage-view.mjs +36 -8
  35. package/server/public/app/relay-error-ctas.mjs +16 -2
  36. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  37. package/server/public/app/router.js +49 -12
  38. package/server/public/app/settings-modal.js +33 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +51 -0
  41. package/server/public/index.html +381 -7
  42. package/server/relay-tools.md +13 -1
  43. package/server/repositories/remote-relay-repository.mjs +42 -1
  44. package/server/repositories/session-repository.mjs +47 -1
  45. package/server/routes/claude-cloud-routes.mjs +140 -0
  46. package/server/routes/git-routes.mjs +37 -0
  47. package/server/routes/messages-routes.mjs +120 -15
  48. package/server/routes/remote-relay-routes.mjs +15 -1
  49. package/server/routes/sessions-routes.mjs +502 -72
  50. package/server/server-runtime.mjs +258 -25
  51. package/server/services/claude-account-usage-service.mjs +176 -0
  52. package/server/services/claude-cloud-repo-service.mjs +408 -0
  53. package/server/services/claude-cloud-session-service.mjs +326 -0
  54. package/server/services/claude-cloud-settings-service.mjs +269 -0
  55. package/server/services/git-remote-service.mjs +115 -0
  56. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  57. package/server/services/plan-usage-claude.mjs +202 -40
  58. package/server/services/plan-usage-contract.mjs +39 -3
  59. package/server/services/plan-usage-service.mjs +82 -2
  60. package/server/services/provider-reasoning-effort.mjs +3 -0
  61. package/server/services/remote-relay-caller-context.mjs +49 -10
  62. package/server/services/remote-relay-client.mjs +9 -0
  63. package/server/services/remote-relay-dispatcher.mjs +383 -35
  64. package/server/services/remote-relay-inbound.mjs +49 -2
  65. package/server/services/remote-relay-loopback.mjs +245 -0
  66. package/server/services/remote-relay-registry-service.mjs +52 -0
  67. package/server/services/session-worker-launch-service.mjs +100 -15
  68. package/server/services/session-worker-process-service.mjs +348 -129
  69. package/server/services/session-worker-stop-service.mjs +24 -13
  70. package/server/services/session-worker-supervisor-service.mjs +56 -1
  71. package/server/services/usage-limit-pause-service.mjs +3 -1
  72. package/server/services/worker-launch-note-service.mjs +151 -0
  73. package/shared/claude-attribution.mjs +117 -0
  74. package/shared/claude-cloud/api-client.mjs +809 -0
  75. package/shared/claude-cloud/base-url.mjs +47 -0
  76. package/shared/claude-cloud/credentials.mjs +221 -0
  77. package/shared/claude-cloud/repo-url.mjs +111 -0
  78. package/shared/provider-routing.mjs +4 -2
  79. package/shared/remote-relay-contract.mjs +153 -22
  80. package/shared/remote-relay-tool-core.mjs +11 -8
  81. package/shared/steer-settle-failure.mjs +1 -0
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // Claude Cloud session worker: a per-conversation Node process that speaks
3
+ // the same relay contracts as the Claude/Cursor/Grok workers, but runs no
4
+ // agent itself. The agent and its clone live in a Claude Code cloud session;
5
+ // this process sends the user's messages there and follows the session's
6
+ // event stream (claude-cloud-session-process.mjs).
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ import {
11
+ loadTokenFromConfig,
12
+ resolveRelayServerUrl,
13
+ } from '../../shared/worker-runtime/config-loader.mjs';
14
+ import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
15
+ import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
16
+ import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
17
+ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
18
+ import { createControlPoller } from '../../shared/control-poller.mjs';
19
+ import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
20
+ import { createClaudeCloudCredentials } from '../../shared/claude-cloud/credentials.mjs';
21
+ import { createClaudeCloudClient } from '../../shared/claude-cloud/api-client.mjs';
22
+ import { resolveClaudeCloudBaseUrl } from '../../shared/claude-cloud/base-url.mjs';
23
+ import { createClaudeCloudSessionRunner } from './claude-cloud-session-process.mjs';
24
+
25
+ // Windows console launch: the launcher names the worker log, this copies
26
+ // stdout/stderr into it. A no-op wherever the launcher redirects instead.
27
+ installWorkerLogFile();
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+ const HEARTBEAT_MS = 10_000;
31
+
32
+ function parseSessionIdArg(argv = process.argv) {
33
+ const index = argv.indexOf('--session-id');
34
+ if (index !== -1 && argv[index + 1]) return String(argv[index + 1]).trim();
35
+ const inline = argv.find((arg) => String(arg || '').startsWith('--session-id='));
36
+ if (inline) return String(inline.split('=')[1] || '').trim();
37
+ return String(process.env.SESSION_ID || '').trim();
38
+ }
39
+
40
+ function dbg(...parts) {
41
+ const timestamp = new Date().toISOString();
42
+ console.log(`[claude-cloud-worker ${timestamp}]`, ...parts);
43
+ }
44
+
45
+ async function main() {
46
+ const sdkSessionId = parseSessionIdArg();
47
+ if (!sdkSessionId) {
48
+ console.error('claude-cloud-session-worker: missing --session-id');
49
+ process.exit(2);
50
+ }
51
+
52
+ const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
53
+ || path.resolve(__dirname, '..', 'config.json');
54
+ const serverUrl = resolveRelayServerUrl({ configPath });
55
+ const token = loadTokenFromConfig(configPath);
56
+ const defaultModel = String(process.env.CLAUDE_CLOUD_RELAY_MODEL || '').trim();
57
+
58
+ const api = createApiClient({
59
+ serverUrl,
60
+ token,
61
+ getHeaders: () => ({
62
+ 'X-Relay-Process-Pid': String(process.pid),
63
+ 'X-Relay-Parent-Pid': String(process.ppid),
64
+ 'X-Relay-Session-Id': sdkSessionId,
65
+ 'X-Relay-Conversation-Id': sdkSessionId,
66
+ }),
67
+ });
68
+
69
+ // The Claude CLI's login, read by the client's own module (the credentials
70
+ // file, or CLAUDE_CODE_OAUTH_TOKEN where the relay runs on one): this
71
+ // process never logs it. A login that has run out is nudged through the
72
+ // relay, which can run the CLI (`claude auth status` refreshes a login
73
+ // whose refresh token is still good); the file is read again afterwards.
74
+ // The base URL is the Anthropic API unless a test names a fake one on this
75
+ // machine (base-url.mjs takes nothing else).
76
+ const cloud = createClaudeCloudClient({
77
+ credentials: createClaudeCloudCredentials({
78
+ nudge: async () => {
79
+ const answer = await api('POST', '/api/claude-cloud/login-nudge', {});
80
+ dbg('login nudge', answer?.nudged ? 'ran' : 'skipped', answer?.expired ? 'still expired' : `expires ${answer?.expiresAt || '?'}`);
81
+ },
82
+ }),
83
+ baseUrl: resolveClaudeCloudBaseUrl(process.env),
84
+ });
85
+
86
+ const controlPoller = createControlPoller({
87
+ api,
88
+ sdkSessionId,
89
+ abortAckNote: 'cloud turn interrupted',
90
+ dbg,
91
+ });
92
+ const runner = createClaudeCloudSessionRunner({
93
+ api,
94
+ cloud,
95
+ sdkSessionId,
96
+ defaultModel,
97
+ controlPoller,
98
+ dbg,
99
+ });
100
+
101
+ let heartbeatTimer = null;
102
+ const heartbeat = createHeartbeatController({
103
+ api,
104
+ pollMs: HEARTBEAT_MS,
105
+ getSessionReady: () => true,
106
+ getHeartbeatTimer: () => heartbeatTimer,
107
+ setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
108
+ getActiveQueueMessageId: () => runner.getActiveQueueMessageId(),
109
+ });
110
+
111
+ const wsLink = createWorkerWebSocketLink({
112
+ serverUrl,
113
+ token,
114
+ dbg,
115
+ getSessionReady: () => true,
116
+ getSessionId: () => sdkSessionId,
117
+ getPid: () => process.pid,
118
+ onDeliver: async (pending, reason) => {
119
+ dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
120
+ try {
121
+ return await runner.handlePendingPayload(pending);
122
+ } catch (error) {
123
+ dbg('turn handling failed', error?.message || String(error));
124
+ return false;
125
+ }
126
+ },
127
+ });
128
+
129
+ const shutdown = async (signal) => {
130
+ dbg(`shutting down (${signal})`);
131
+ try { wsLink.stop(); } catch {}
132
+ try { heartbeat.stopHeartbeat(); } catch {}
133
+ try { controlPoller.stop(); } catch {}
134
+ // The cloud session is left as it is: a turn in flight keeps running
135
+ // there, and the next worker finds it through the stored sequence number.
136
+ try { await runner.dispose(); } catch {}
137
+ process.exit(0);
138
+ };
139
+ process.on('SIGTERM', () => { shutdown('SIGTERM'); });
140
+ process.on('SIGINT', () => { shutdown('SIGINT'); });
141
+ installWorkerCrashGuard({
142
+ api,
143
+ workerName: 'claude-cloud-session-worker',
144
+ getActiveQueueMessageIds: () => [runner.getActiveQueueAttempt()],
145
+ });
146
+
147
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'}`);
148
+ heartbeat.startHeartbeat();
149
+ wsLink.start();
150
+ }
151
+
152
+ main().catch((error) => {
153
+ console.error('claude-cloud-session-worker fatal:', error?.stack || error?.message || error);
154
+ process.exit(1);
155
+ });
@@ -62,6 +62,8 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
62
62
  provider: choice('provider', REMOTE_RELAY_PROVIDERS),
63
63
  model: text('model'),
64
64
  cwd: text('cwd'),
65
+ repo: text('repo'),
66
+ branch: text('branch'),
65
67
  mode: choice('mode', REMOTE_RELAY_MODES),
66
68
  effort: text('effort'),
67
69
  title: text('title'),
@@ -111,7 +111,7 @@ export function normalizeAutoCompactWindow(value) {
111
111
  * spreads of `settings` in the options literal would silently clobber each
112
112
  * other. Returns null when nothing needs to be set.
113
113
  */
114
- export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = null, thinkingEnabled = null } = {}) {
114
+ export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = null, thinkingEnabled = null, attribution = null } = {}) {
115
115
  const window = normalizeAutoCompactWindow(autoCompactWindow);
116
116
  const thinking = strictThinkingEnabled(thinkingEnabled);
117
117
  const settings = {
@@ -122,10 +122,25 @@ export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = nul
122
122
  // present; the omit path exists for direct callers that mean to say
123
123
  // nothing.
124
124
  ...(thinking !== null ? { alwaysThinkingEnabled: thinking } : {}),
125
+ // What commits and PRs say (shared/claude-attribution.mjs). null is
126
+ // vanilla: the key is left out and the CLI uses its own.
127
+ ...(attribution && typeof attribution === 'object' ? { attribution } : {}),
125
128
  };
126
129
  return Object.keys(settings).length ? settings : null;
127
130
  }
128
131
 
132
+ /**
133
+ * Live twin of the attribution spawn setting, for a value (OAR or off). A
134
+ * running CLI takes a new text through the flag layer (it arrives as a fresh
135
+ * system-reminder on the next user turn) but cannot UNSET a value it was
136
+ * spawned with — `{}`, `null` and `true` all leave the spawn-time text in
137
+ * force (wire-logged probe, 2026-10-01). Back to vanilla therefore means a
138
+ * fresh process without the key; the session runner does that.
139
+ */
140
+ export function claudeAttributionFlagSettings(attribution) {
141
+ return { attribution: attribution && typeof attribution === 'object' ? attribution : { commit: '', pr: '', sessionUrl: false } };
142
+ }
143
+
129
144
  export function permissionModeForRelayMode(relayMode) {
130
145
  const mode = String(relayMode || 'agent').trim().toLowerCase();
131
146
  if (mode === 'plan') return 'plan';
@@ -228,6 +243,7 @@ export function startClaudeSession({
228
243
  reasoningEffort = '',
229
244
  autoCompactWindow = null,
230
245
  thinkingEnabled = null,
246
+ attribution = null,
231
247
  thinkingDisplay = '',
232
248
  abortController,
233
249
  canUseTool,
@@ -248,7 +264,7 @@ export function startClaudeSession({
248
264
  // only way to hand a fresh CLI the session-scoped ultracode flag, the
249
265
  // auto-compact window and the thinking pin, and all three share one
250
266
  // `settings` object.
251
- const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled });
267
+ const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled, attribution });
252
268
  // Relay tools need the worker's authenticated API helper; a caller that
253
269
  // passes none (tests, probes) gets a session without them.
254
270
  const relayMcpServer = typeof api === 'function'
@@ -21,12 +21,14 @@ import {
21
21
  normalizeClaudeEffort,
22
22
  claudeUltracodeFlagSettings,
23
23
  claudeAutoCompactFlagSettings,
24
+ claudeAttributionFlagSettings,
24
25
  claudeThinkingFlagSettings,
25
26
  applyThinkingDisplay,
26
27
  normalizeAutoCompactWindow,
27
28
  permissionModeForRelayMode,
28
29
  } from './claude-sdk-adapter.mjs';
29
30
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
31
+ import { sameClaudeAttribution } from '../../shared/claude-attribution.mjs';
30
32
  import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
31
33
  import {
32
34
  attemptFields,
@@ -448,6 +450,10 @@ export function createClaudeSessionRunner({
448
450
  // per turn rather than captured, so a slider change picked up on the next
449
451
  // delivery reaches a process that is already running.
450
452
  getAutoCompactWindow = () => null,
453
+ // What this session's commits say (shared/claude-attribution.mjs); null
454
+ // leaves the CLI's own attribution. Piggybacked on deliveries like the
455
+ // window above.
456
+ getAttribution = () => null,
451
457
  // The per-conversation thinking state ({enabled, display}); read per turn
452
458
  // like the window, so a settings change reaches a running process on its
453
459
  // next delivery.
@@ -3170,6 +3176,7 @@ export function createClaudeSessionRunner({
3170
3176
  const thinkingState = getThinking() || {};
3171
3177
  const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3172
3178
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3179
+ const attribution = getAttribution() || null;
3173
3180
  const abortController = new AbortController();
3174
3181
  const processRef = {
3175
3182
  turn: null,
@@ -3178,6 +3185,7 @@ export function createClaudeSessionRunner({
3178
3185
  relayMode,
3179
3186
  effort,
3180
3187
  autoCompactWindow,
3188
+ attribution,
3181
3189
  // Seeded from what the process is SPAWNED with, so the adapt diff is
3182
3190
  // against reality rather than against a delivered setting the CLI may
3183
3191
  // never have honored.
@@ -3335,6 +3343,7 @@ export function createClaudeSessionRunner({
3335
3343
  autoCompactWindow,
3336
3344
  thinkingEnabled,
3337
3345
  thinkingDisplay,
3346
+ attribution,
3338
3347
  abortController,
3339
3348
  canUseTool,
3340
3349
  pathToClaudeCodeExecutable,
@@ -3443,6 +3452,30 @@ export function createClaudeSessionRunner({
3443
3452
  });
3444
3453
  proc.autoCompactWindow = autoCompactWindow;
3445
3454
  }
3455
+ // Attribution follows the window's pattern: a changed value (a settings
3456
+ // change, a model switch that renames the trailer) reaches the live CLI
3457
+ // through the flag layer, before the turn that may commit.
3458
+ const attribution = getAttribution() || null;
3459
+ if (!sameClaudeAttribution(attribution, proc.attribution)) {
3460
+ if (attribution === null) {
3461
+ // Back to the CLI's own attribution. A value the process was spawned
3462
+ // with cannot be unset through the flag layer (probe 2026-10-01), so
3463
+ // the honest way is a fresh process without the key — taken when
3464
+ // nothing lives in the old one; a busy process keeps the old text and
3465
+ // the next delivery tries again.
3466
+ if (!hasLiveWork() && !proc.liveTasks.size) {
3467
+ gracefulShutdown('attribution-vanilla');
3468
+ await drainClosingProcess(10_000);
3469
+ return null;
3470
+ }
3471
+ } else {
3472
+ expectSettingsReinit();
3473
+ await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAttributionFlagSettings(attribution))).catch((error) => {
3474
+ dbg('applyFlagSettings attribution failed', error?.message || String(error));
3475
+ });
3476
+ proc.attribution = attribution;
3477
+ }
3478
+ }
3446
3479
  // Thinking is NOT a symmetric copy of the window block: the probe
3447
3480
  // (2026-08-26, docs/plans/claude-thinking-control.md) showed the two
3448
3481
  // directions of the on/off axis are not equally reachable mid-session.
@@ -17,6 +17,7 @@ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-fil
17
17
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
18
  import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
19
19
  import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
20
+ import { resolveDeliveredAttribution } from '../../shared/claude-attribution.mjs';
20
21
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
21
22
  import { createClaudeSessionRunner } from './claude-session-process.mjs';
22
23
  import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
@@ -66,6 +67,9 @@ async function main() {
66
67
  // piggyback. enabled null = host default; display defaults to 'summarized'
67
68
  // (today's behavior — visible thought bubbles).
68
69
  let thinking = resolveDeliveredThinking(null, null);
70
+ // What this session's commits and PRs say; same piggyback. null = the
71
+ // CLI's own attribution (vanilla).
72
+ let attribution = null;
69
73
 
70
74
  const api = createApiClient({
71
75
  serverUrl,
@@ -111,6 +115,7 @@ async function main() {
111
115
  getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
112
116
  getAutoCompactWindow: () => autoCompactWindow,
113
117
  getThinking: () => thinking,
118
+ getAttribution: () => attribution,
114
119
  // A readiness flip is a hold starting or ending (question card,
115
120
  // compaction, a turn-opening delivery): the steering snapshot changed
116
121
  // with it, so it goes out now rather than on the next 10 s heartbeat.
@@ -176,6 +181,7 @@ async function main() {
176
181
  // keep the last known state; an explicit null on thinkingEnabled is the
177
182
  // user choosing Host default.
178
183
  thinking = resolveDeliveredThinking(thinking, pending?.settings);
184
+ attribution = resolveDeliveredAttribution(attribution, pending?.settings);
179
185
  try {
180
186
  return await turnRunner.handlePendingPayload(pending);
181
187
  } catch (error) {
@@ -11,6 +11,8 @@ import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-works
11
11
  import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
12
12
  import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
13
13
  import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
14
+ import { migrateWorkspaceRootSettings } from './migrations/0006-workspace-root-settings.mjs';
15
+ import { migrateClaudeCloud } from './migrations/0007-claude-cloud.mjs';
14
16
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
15
17
 
16
18
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -717,6 +719,21 @@ try {
717
719
  } catch (error) {
718
720
  console.warn(`[remote-relays] migration 0005 failed; retrying on next boot: ${error?.message || error}`);
719
721
  }
722
+ // Per-folder settings (commit attribution override). Readers tolerate the
723
+ // table's absence, so a failure is logged and retried next boot like 0005.
724
+ try {
725
+ migrateWorkspaceRootSettings(db);
726
+ } catch (error) {
727
+ console.warn(`[workspace-root-settings] migration 0006 failed; retrying on next boot: ${error?.message || error}`);
728
+ }
729
+ // Claude Cloud conversations: the cloud session binding and what it clones.
730
+ // Additive columns whose readers tolerate their absence, so a failure is
731
+ // logged and retried next boot like 0006.
732
+ try {
733
+ migrateClaudeCloud(db);
734
+ } catch (error) {
735
+ console.warn(`[claude-cloud] migration 0007 failed; retrying on next boot: ${error?.message || error}`);
736
+ }
720
737
 
721
738
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
722
739
  if (runtimeSessionColumns.length) {
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: per-folder settings (`workspace_root_settings`).
5
+ *
6
+ * The first setting a repo folder can carry is the commit attribution mode
7
+ * of its Claude sessions (shared/claude-attribution.mjs): a repo that forbids
8
+ * co-author trailers, or wants its own, overrides the provider setting for
9
+ * every session that runs in it. Keyed like `recent_workspace_roots`
10
+ * (`normalizeWorkspaceRootKey`), but its own table: the MRU list is pruned.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot.
13
+ *
14
+ * Usage: node server/migrations/0006-workspace-root-settings.mjs [path/to/copilot.db]
15
+ */
16
+
17
+ import { fileURLToPath } from 'url';
18
+ import path from 'path';
19
+ import process from 'process';
20
+ import Database from 'better-sqlite3';
21
+
22
+ function tableExists(db, name) {
23
+ return !!db
24
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
25
+ .get(name);
26
+ }
27
+
28
+ export function migrateWorkspaceRootSettings(db) {
29
+ const existed = tableExists(db, 'workspace_root_settings');
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS workspace_root_settings (
32
+ path_key TEXT PRIMARY KEY,
33
+ path TEXT NOT NULL,
34
+ attribution_mode TEXT,
35
+ updated_at TEXT NOT NULL
36
+ )
37
+ `);
38
+ return { applied: !existed, tableCreated: !existed };
39
+ }
40
+
41
+ function defaultDbPath() {
42
+ const here = path.dirname(fileURLToPath(import.meta.url));
43
+ return path.resolve(here, '..', 'data', 'copilot.db');
44
+ }
45
+
46
+ export function migrate(dbPath, options = {}) {
47
+ const db = new Database(dbPath, options);
48
+ try {
49
+ return migrateWorkspaceRootSettings(db);
50
+ } finally {
51
+ db.close();
52
+ }
53
+ }
54
+
55
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
56
+ if (isMain) {
57
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
58
+ try {
59
+ const result = migrate(dbPath);
60
+ console.log(`Migration 0006 on ${dbPath}`);
61
+ console.log(result.applied ? ' workspace_root_settings table created' : ' skipped: nothing to change');
62
+ process.exit(0);
63
+ } catch (err) {
64
+ console.error(`Migration 0006 failed: ${err?.message || err}`);
65
+ process.exit(1);
66
+ }
67
+ }
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: storage for Claude Cloud conversations (provider `claude-cloud`).
5
+ *
6
+ * 1. `runtime_sessions.claude_cloud_session_id` — the cloud session a
7
+ * conversation runs in, reported by its worker after the first message
8
+ * (the per-provider native id, next to `claude_native_session_id`,
9
+ * `cursor_agent_id` and `grok_native_session_id`).
10
+ * 2. `runtime_sessions.claude_cloud_last_sequence` — the last event sequence
11
+ * the worker has handled: where it resumes the event stream after a restart.
12
+ * 3. `runtime_sessions.claude_cloud_cost_usd` — what the session has cost so
13
+ * far, as the cloud reports it.
14
+ * 4. `conversations.cloud_source_json` — what the session clones and where it
15
+ * is shown: `{ repoUrl, branch, environmentId, sessionUrl,
16
+ * pushedBranches: [{ branch, at }] }`.
17
+ *
18
+ * Additive and idempotent. Safe to run on every boot.
19
+ *
20
+ * Usage: node server/migrations/0007-claude-cloud.mjs [path/to/copilot.db]
21
+ */
22
+
23
+ import { fileURLToPath } from 'url';
24
+ import path from 'path';
25
+ import process from 'process';
26
+ import Database from 'better-sqlite3';
27
+
28
+ const COLUMNS = Object.freeze([
29
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_session_id', type: 'TEXT' }),
30
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_last_sequence', type: 'TEXT' }),
31
+ Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_cost_usd', type: 'REAL' }),
32
+ Object.freeze({ table: 'conversations', name: 'cloud_source_json', type: 'TEXT' }),
33
+ ]);
34
+
35
+ function tableExists(db, name) {
36
+ return !!db
37
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
38
+ .get(name);
39
+ }
40
+
41
+ function columnNames(db, table) {
42
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
43
+ }
44
+
45
+ export function migrateClaudeCloud(db) {
46
+ let columnsAdded = 0;
47
+ for (const column of COLUMNS) {
48
+ if (!tableExists(db, column.table)) continue;
49
+ if (columnNames(db, column.table).has(column.name)) continue;
50
+ db.exec(`ALTER TABLE ${column.table} ADD COLUMN ${column.name} ${column.type}`);
51
+ columnsAdded += 1;
52
+ }
53
+ return { applied: columnsAdded > 0, columnsAdded };
54
+ }
55
+
56
+ function defaultDbPath() {
57
+ const here = path.dirname(fileURLToPath(import.meta.url));
58
+ return path.resolve(here, '..', 'data', 'copilot.db');
59
+ }
60
+
61
+ export function migrate(dbPath, options = {}) {
62
+ const db = new Database(dbPath, options);
63
+ try {
64
+ return migrateClaudeCloud(db);
65
+ } finally {
66
+ db.close();
67
+ }
68
+ }
69
+
70
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
71
+ if (isMain) {
72
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
73
+ try {
74
+ const result = migrate(dbPath);
75
+ console.log(`Migration 0007 on ${dbPath}`);
76
+ console.log(result.applied ? ` claude cloud columns added: ${result.columnsAdded}` : ' skipped: nothing to change');
77
+ process.exit(0);
78
+ } catch (err) {
79
+ console.error(`Migration 0007 failed: ${err?.message || err}`);
80
+ process.exit(1);
81
+ }
82
+ }