@oar-sh/oar 0.9.6 → 0.9.8

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 (105) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/README.md +222 -40
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/README.md +174 -7
  6. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  10. package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
  11. package/server/claude-worker/claude-session-process.mjs +225 -24
  12. package/server/claude-worker/claude-session-worker.mjs +6 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  14. package/server/db-schema.mjs +17 -0
  15. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  16. package/server/migrations/0007-claude-cloud.mjs +82 -0
  17. package/server/public/app/activity-replay-state.mjs +68 -5
  18. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  19. package/server/public/app/agent-sessions-settings.mjs +134 -0
  20. package/server/public/app/api-client.js +149 -6
  21. package/server/public/app/attachments-view.js +20 -1
  22. package/server/public/app/attribution-control.mjs +46 -0
  23. package/server/public/app/bootstrap.js +468 -88
  24. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  25. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  26. package/server/public/app/claude-cloud-ui.mjs +611 -0
  27. package/server/public/app/composer-control-state.mjs +11 -0
  28. package/server/public/app/context-menu.mjs +140 -0
  29. package/server/public/app/context-tier-options.mjs +4 -0
  30. package/server/public/app/context-usage-view.mjs +127 -8
  31. package/server/public/app/conversation-origin-ui.js +27 -0
  32. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  33. package/server/public/app/conversation-view.js +145 -11
  34. package/server/public/app/cwd-picker.js +12 -0
  35. package/server/public/app/journal-view.js +698 -28
  36. package/server/public/app/math-dollars.mjs +89 -0
  37. package/server/public/app/model-selector-options.mjs +1 -1
  38. package/server/public/app/plan-usage-view.mjs +36 -8
  39. package/server/public/app/pwa-install.js +5 -0
  40. package/server/public/app/relay-error-ctas.mjs +16 -2
  41. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  42. package/server/public/app/router.js +49 -12
  43. package/server/public/app/scrollbar-hover.mjs +78 -0
  44. package/server/public/app/settings-modal.js +37 -3
  45. package/server/public/app/settings-tabs.js +1 -1
  46. package/server/public/app/socket-handlers.js +53 -1
  47. package/server/public/app/store.js +26 -0
  48. package/server/public/app/transcript-separators.mjs +48 -11
  49. package/server/public/app-icon-192.png +0 -0
  50. package/server/public/app-icon-512.png +0 -0
  51. package/server/public/app-icon-light-192.png +0 -0
  52. package/server/public/app-icon-light-512.png +0 -0
  53. package/server/public/app-icon-light-maskable-512.png +0 -0
  54. package/server/public/app-icon-light.svg +4 -0
  55. package/server/public/app-icon-maskable-512.png +0 -0
  56. package/server/public/app-icon.svg +3 -3
  57. package/server/public/favicon.ico +0 -0
  58. package/server/public/index.html +564 -20
  59. package/server/public/manifest.webmanifest +9 -3
  60. package/server/public/sw.js +6 -1
  61. package/server/relay-tools.md +13 -1
  62. package/server/repositories/remote-relay-repository.mjs +42 -1
  63. package/server/repositories/session-repository.mjs +47 -1
  64. package/server/routes/claude-cloud-routes.mjs +140 -0
  65. package/server/routes/git-routes.mjs +37 -0
  66. package/server/routes/messages-routes.mjs +123 -16
  67. package/server/routes/remote-relay-routes.mjs +15 -1
  68. package/server/routes/sessions-routes.mjs +517 -74
  69. package/server/server-runtime.mjs +268 -33
  70. package/server/services/claude-account-usage-service.mjs +176 -0
  71. package/server/services/claude-cloud-repo-service.mjs +408 -0
  72. package/server/services/claude-cloud-session-service.mjs +326 -0
  73. package/server/services/claude-cloud-settings-service.mjs +269 -0
  74. package/server/services/claude-context-usage.mjs +51 -3
  75. package/server/services/git-remote-service.mjs +115 -0
  76. package/server/services/oar-cli-helpers.mjs +67 -1
  77. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  78. package/server/services/plan-usage-claude.mjs +202 -40
  79. package/server/services/plan-usage-contract.mjs +39 -3
  80. package/server/services/plan-usage-service.mjs +82 -2
  81. package/server/services/provider-reasoning-effort.mjs +3 -0
  82. package/server/services/pwa-manifest-theme.mjs +30 -0
  83. package/server/services/remote-relay-caller-context.mjs +49 -10
  84. package/server/services/remote-relay-client.mjs +9 -0
  85. package/server/services/remote-relay-dispatcher.mjs +383 -35
  86. package/server/services/remote-relay-inbound.mjs +49 -2
  87. package/server/services/remote-relay-loopback.mjs +245 -0
  88. package/server/services/remote-relay-registry-service.mjs +52 -0
  89. package/server/services/session-worker-launch-service.mjs +100 -15
  90. package/server/services/session-worker-process-service.mjs +348 -129
  91. package/server/services/session-worker-registry-service.mjs +18 -1
  92. package/server/services/session-worker-stop-service.mjs +24 -13
  93. package/server/services/session-worker-supervisor-service.mjs +56 -1
  94. package/server/services/update-install-service.mjs +9 -3
  95. package/server/services/usage-limit-pause-service.mjs +3 -1
  96. package/server/services/worker-launch-note-service.mjs +151 -0
  97. package/shared/claude-attribution.mjs +117 -0
  98. package/shared/claude-cloud/api-client.mjs +809 -0
  99. package/shared/claude-cloud/base-url.mjs +47 -0
  100. package/shared/claude-cloud/credentials.mjs +221 -0
  101. package/shared/claude-cloud/repo-url.mjs +111 -0
  102. package/shared/provider-routing.mjs +4 -2
  103. package/shared/remote-relay-contract.mjs +153 -22
  104. package/shared/remote-relay-tool-core.mjs +11 -8
  105. package/shared/steer-settle-failure.mjs +1 -0
@@ -5,6 +5,8 @@ import { randomUUID } from 'crypto';
5
5
  import { buildClaudeUserContent } from './claude-attachments.mjs';
6
6
  import {
7
7
  compactBoundaryActivityAction,
8
+ compactCancelledActivityAction,
9
+ compactPendingActivityAction,
8
10
  createSdkMessageNormalizer,
9
11
  displayToolName,
10
12
  formatApiRetryNotice,
@@ -20,13 +22,15 @@ import {
20
22
  readPlanUsage,
21
23
  normalizeClaudeEffort,
22
24
  claudeUltracodeFlagSettings,
23
- claudeAutoCompactFlagSettings,
25
+ claudeAttributionFlagSettings,
24
26
  claudeThinkingFlagSettings,
25
27
  applyThinkingDisplay,
26
28
  normalizeAutoCompactWindow,
27
29
  permissionModeForRelayMode,
28
30
  } from './claude-sdk-adapter.mjs';
29
31
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
32
+ import { sameClaudeAttribution } from '../../shared/claude-attribution.mjs';
33
+ import { formatAutoCompactWindowLabel } from '../../shared/auto-compact-window.mjs';
30
34
  import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
31
35
  import {
32
36
  attemptFields,
@@ -445,9 +449,14 @@ export function createClaudeSessionRunner({
445
449
  idleShutdownMs = 10 * 60_000,
446
450
  getBackgroundTaskTimeoutMs = () => 0,
447
451
  // The per-conversation auto-compact window (token count, null = Auto). Read
448
- // per turn rather than captured, so a slider change picked up on the next
449
- // delivery reaches a process that is already running.
452
+ // per turn rather than captured: the CLI only takes it at spawn, so a
453
+ // slider change picked up on the next delivery respawns an idle process
454
+ // (adaptProcess).
450
455
  getAutoCompactWindow = () => null,
456
+ // What this session's commits say (shared/claude-attribution.mjs); null
457
+ // leaves the CLI's own attribution. Piggybacked on deliveries like the
458
+ // window above.
459
+ getAttribution = () => null,
451
460
  // The per-conversation thinking state ({enabled, display}); read per turn
452
461
  // like the window, so a settings change reaches a running process on its
453
462
  // next delivery.
@@ -577,6 +586,10 @@ export function createClaudeSessionRunner({
577
586
  // the same misattribution as a continuation whose init follows the push.
578
587
  let settingsReinitsOwed = 0;
579
588
  let settingsWindowOpen = false;
589
+ // Row publishes that the process-level bookkeeping of one SDK message
590
+ // decided on (the compaction entries); handleSdkMessage drains them right
591
+ // after it, before the message's own actions.
592
+ const queuedProcessActions = [];
580
593
  let lastReportedDeliveryReady = true;
581
594
  // Rows whose prompt the CLI consumed and whose settle marker is being saved
582
595
  // (id → relay message). Runner-level, not per process: a process dying
@@ -795,6 +808,15 @@ export function createClaudeSessionRunner({
795
808
  // Orphan/settled-task notification lines that arrived between turns belong
796
809
  // to the turn they triggered.
797
810
  const carried = proc.pendingActivities.splice(0);
811
+ // A compaction's buffered boundary lands on this turn. Shown as running
812
+ // on another row (a delivery still waiting), it is cancelled there.
813
+ if (proc.compactionNotice?.boundaryCarried) {
814
+ if (proc.compactionNotice.ctx === ctx) proc.compactionNotice = null;
815
+ else {
816
+ const cancel = takeCompactionCancel();
817
+ if (cancel) void publishProcessAction(cancel);
818
+ }
819
+ }
798
820
  if (carried.length) {
799
821
  const actions = carried.map((entry) => (typeof entry === 'string'
800
822
  ? { channel: 'activity', payload: { text: entry, subagentRunId: null } }
@@ -2087,6 +2109,54 @@ export function createClaudeSessionRunner({
2087
2109
  return Boolean(proc.activeCtx) && !proc.activeCtx.discarded;
2088
2110
  }
2089
2111
 
2112
+ /**
2113
+ * The row a running compaction is shown on: the active turn's, else the
2114
+ * delivered message waiting for the turn the CLI compacts before. A
2115
+ * compaction between turns with nothing waiting has no row to show it on.
2116
+ */
2117
+ function compactionNoticeContext() {
2118
+ if (activeContextPublishes()) return proc.activeCtx;
2119
+ return proc.pendingDelivered.find((entry) => !entry.steered)?.ctx || null;
2120
+ }
2121
+
2122
+ /**
2123
+ * `status: 'compacting'` puts a `pending` compaction entry on that row, at
2124
+ * most once per compaction. Queued, not published here: handleSdkMessage
2125
+ * publishes it ahead of the frame's own actions, so it stays in stream
2126
+ * order with the boundary that replaces it.
2127
+ */
2128
+ function noteCompactionStarted() {
2129
+ if (proc.compactionNotice && !proc.compactionNotice.boundaryCarried) return;
2130
+ // An earlier compaction whose boundary still waits for a turn: its line
2131
+ // gives way to this one.
2132
+ const superseded = takeCompactionCancel();
2133
+ if (superseded) queuedProcessActions.push(superseded);
2134
+ const ctx = compactionNoticeContext();
2135
+ if (!ctx) return;
2136
+ proc.compactionNotice = { ctx };
2137
+ queuedProcessActions.push({ ctx, action: compactPendingActivityAction() });
2138
+ }
2139
+
2140
+ /**
2141
+ * A compaction that ended without a boundary (failed, compacted nothing,
2142
+ * went stale, or its process died): the row that shows it as running gets a
2143
+ * `cancelled` entry, which takes the line away again. Nothing when no
2144
+ * pending entry was published, or when the boundary already followed it.
2145
+ */
2146
+ function takeCompactionCancel(processRef = proc) {
2147
+ const notice = processRef?.compactionNotice;
2148
+ if (!notice) return null;
2149
+ processRef.compactionNotice = null;
2150
+ if (notice.ctx.finalized || notice.ctx.discarded) return null;
2151
+ return { ctx: notice.ctx, action: compactCancelledActivityAction() };
2152
+ }
2153
+
2154
+ async function publishProcessAction({ ctx, action }) {
2155
+ await dispatchToContext(ctx, action).catch((error) => {
2156
+ dbg('compaction entry publish failed', error?.message || String(error));
2157
+ });
2158
+ }
2159
+
2090
2160
  /**
2091
2161
  * The CLI is compacting, or just did: a delivered turn is still coming.
2092
2162
  * Armed only while a delivered entry is actually waiting; which turn the
@@ -2210,8 +2280,14 @@ export function createClaudeSessionRunner({
2210
2280
  if (String(sdkMessage.status || '') === 'compacting') {
2211
2281
  proc.compactingSince = Date.now();
2212
2282
  armCompactReplayAdoption();
2283
+ noteCompactionStarted();
2213
2284
  } else if (sdkMessage.permissionMode === undefined) {
2214
2285
  proc.compactingSince = 0;
2286
+ // The boundary (which clears the notice) comes BEFORE this terminator
2287
+ // when the compaction produced one; a notice still open here means
2288
+ // it did not (a boundary buffered for the next turn keeps its notice).
2289
+ const cancel = proc.compactionNotice?.boundaryCarried ? null : takeCompactionCancel();
2290
+ if (cancel) queuedProcessActions.push(cancel);
2215
2291
  // A compaction that failed produces no boundary and no replay: the
2216
2292
  // turn just carries on uncompacted and attaches the normal way, so
2217
2293
  // leaving the adoption window open would only widen the chance of a
@@ -2225,6 +2301,23 @@ export function createClaudeSessionRunner({
2225
2301
  }
2226
2302
  if (subtype === 'compact_boundary') {
2227
2303
  proc.compactingSince = 0;
2304
+ // The boundary entry supersedes the pending one on the row it lands on.
2305
+ // A pending entry on another row (a waiting delivery, while the
2306
+ // boundary goes to the turn the CLI opened itself) would show that row
2307
+ // as compacting for its whole turn, so it is cancelled there.
2308
+ // A buffered boundary has no row yet: the notice waits for the turn
2309
+ // that takes it (activateContext).
2310
+ if (proc.compactionNotice) {
2311
+ if (!activeContextPublishes()) {
2312
+ proc.compactionNotice.boundaryCarried = true;
2313
+ } else {
2314
+ if (proc.compactionNotice.ctx !== proc.activeCtx) {
2315
+ const cancel = takeCompactionCancel();
2316
+ if (cancel) queuedProcessActions.push(cancel);
2317
+ }
2318
+ proc.compactionNotice = null;
2319
+ }
2320
+ }
2228
2321
  armCompactReplayAdoption();
2229
2322
  // The boundary is a per-turn normalizer action, but a compaction at
2230
2323
  // resume — exactly what lowering the window causes — lands with no
@@ -2593,6 +2686,7 @@ export function createClaudeSessionRunner({
2593
2686
  const suppressed = maybeSuppressStopFollowUp(sdkMessage);
2594
2687
  if (suppressed === 'swallow') return;
2595
2688
  observeProcessLevel(sdkMessage);
2689
+ for (const queued of queuedProcessActions.splice(0)) await publishProcessAction(queued);
2596
2690
  if (suppressed === 'observe-only') return;
2597
2691
  // Its traffic opens a continuation of its own instead of attaching to the
2598
2692
  // stopped steer waiting in pendingDelivered (which still settles stopped).
@@ -3036,6 +3130,12 @@ export function createClaudeSessionRunner({
3036
3130
  // answered instead of failing over minutes later.
3037
3131
  reapOvertakenDeliveredEntries(now);
3038
3132
  reapStalePendingDelivered(now);
3133
+ // A compaction past the staleness cap is no longer held for; neither is
3134
+ // the line that shows it running.
3135
+ if (proc.compactionNotice && !proc.compactionNotice.boundaryCarried && !isCompacting()) {
3136
+ const cancel = takeCompactionCancel();
3137
+ if (cancel) void publishProcessAction(cancel);
3138
+ }
3039
3139
  if (hasLiveWork()) {
3040
3140
  // Track how long background tasks alone have held the process, for the
3041
3141
  // user-configurable timeout (0 = unlimited).
@@ -3107,6 +3207,10 @@ export function createClaudeSessionRunner({
3107
3207
  processRef.taskPublishTimer = null;
3108
3208
  }
3109
3209
  stopWorkflowPoller(processRef);
3210
+ // A compaction the process died in never ends with a boundary. Published
3211
+ // before the contexts below are settled, so the entry lands on a row that
3212
+ // is still open.
3213
+ const compactionCancel = takeCompactionCancel(processRef);
3110
3214
  // The process took its background tasks with it; clear the panel — but
3111
3215
  // only when this process still owns it. A superseded process clearing the
3112
3216
  // panel would blank the replacement's live task set.
@@ -3128,6 +3232,7 @@ export function createClaudeSessionRunner({
3128
3232
  for (const entry of stoppedSteers) claimSettling(entry.ctx.message, 'stopped');
3129
3233
  processRef.activeCtx = null;
3130
3234
  if (proc === processRef) proc = null;
3235
+ if (compactionCancel) await publishProcessAction(compactionCancel);
3131
3236
  for (const ctx of openContexts) {
3132
3237
  if (ctx.finalized) continue;
3133
3238
  if (streamError && !processRef.aborted && !ctx.interrupted) {
@@ -3162,7 +3267,7 @@ export function createClaudeSessionRunner({
3162
3267
  // ---------------------------------------------------------------------------
3163
3268
  // Spawn / adapt
3164
3269
 
3165
- function spawnProcess(message) {
3270
+ function spawnProcess(message, { resume: resumeOverride = '' } = {}) {
3166
3271
  const relayMode = message.relayMode || 'agent';
3167
3272
  const model = resolvePerTurnModel(message);
3168
3273
  const effort = normalizeClaudeEffort(message.reasoningEffort);
@@ -3170,6 +3275,7 @@ export function createClaudeSessionRunner({
3170
3275
  const thinkingState = getThinking() || {};
3171
3276
  const thinkingEnabled = parseThinkingEnabled(thinkingState.enabled);
3172
3277
  const thinkingDisplay = parseThinkingDisplay(thinkingState.display);
3278
+ const attribution = getAttribution() || null;
3173
3279
  const abortController = new AbortController();
3174
3280
  const processRef = {
3175
3281
  turn: null,
@@ -3178,6 +3284,7 @@ export function createClaudeSessionRunner({
3178
3284
  relayMode,
3179
3285
  effort,
3180
3286
  autoCompactWindow,
3287
+ attribution,
3181
3288
  // Seeded from what the process is SPAWNED with, so the adapt diff is
3182
3289
  // against reality rather than against a delivered setting the CLI may
3183
3290
  // never have honored.
@@ -3219,6 +3326,9 @@ export function createClaudeSessionRunner({
3219
3326
  // delivered entry may still adopt the turn the CLI re-opens afterwards.
3220
3327
  compactingSince: 0,
3221
3328
  compactReplayUntil: 0,
3329
+ // { ctx } of the row a `pending` compaction entry was published on,
3330
+ // until the boundary or a `cancelled` entry closes it.
3331
+ compactionNotice: null,
3222
3332
  // A non-spawn init that opened a turn while a delivered row waited: the
3223
3333
  // turn is the CLI's own, so no compaction inside it may adopt that row.
3224
3334
  continuationInitPending: false,
@@ -3318,7 +3428,7 @@ export function createClaudeSessionRunner({
3318
3428
  }
3319
3429
  };
3320
3430
 
3321
- const resume = String(message.claudeNativeSessionId || claudeNativeSessionId || '').trim();
3431
+ const resume = String(resumeOverride || message.claudeNativeSessionId || claudeNativeSessionId || '').trim();
3322
3432
  processRef.nativeSessionId = resume;
3323
3433
  // The CLI resolves `resume` inside the project directory for *this* CWD, so
3324
3434
  // a session whose workspace root changed has to bring its transcript along
@@ -3335,6 +3445,7 @@ export function createClaudeSessionRunner({
3335
3445
  autoCompactWindow,
3336
3446
  thinkingEnabled,
3337
3447
  thinkingDisplay,
3448
+ attribution,
3338
3449
  abortController,
3339
3450
  canUseTool,
3340
3451
  pathToClaudeCodeExecutable,
@@ -3378,7 +3489,40 @@ export function createClaudeSessionRunner({
3378
3489
  settingsWindowOpen = true;
3379
3490
  }
3380
3491
 
3381
- async function adaptProcess(message) {
3492
+ /**
3493
+ * Whether the process may be released for a fresh one right now: nothing
3494
+ * would die with it or be cut off by it — no turn, no delivery in flight, no
3495
+ * open question, no compaction, no adoption window, no background agents or
3496
+ * shells (nor a continuation one of them just announced), no Stop guard.
3497
+ */
3498
+ function canReleaseForRespawn() {
3499
+ if (!proc || proc.closing || proc.aborted) return false;
3500
+ if (hasLiveWork() || proc.liveTasks.size) return false;
3501
+ if (Date.now() < proc.compactReplayUntil) return false;
3502
+ return !proc.suppressingTurn;
3503
+ }
3504
+
3505
+ /**
3506
+ * The running CLI's compaction window (the one it was spawned with) and
3507
+ * whether background work would block the respawn that applies another
3508
+ * one. Rides the steering snapshot; the relay compares it with the stored
3509
+ * window to tell the modal when a change applies. Null with no process.
3510
+ */
3511
+ function autoCompactWindowState() {
3512
+ if (!proc || proc.closing || proc.aborted) return null;
3513
+ return {
3514
+ active: proc.autoCompactWindow,
3515
+ backgroundWork: proc.liveTasks.size > 0 || isContinuationImminent(),
3516
+ };
3517
+ }
3518
+
3519
+ /**
3520
+ * Prepare a live process for a turn-opening delivery. Returns null when the
3521
+ * process was released instead (the caller cold-starts a fresh one).
3522
+ * `delivery` is the delivery's own record: a window respawn is noted on it,
3523
+ * and at most one is taken per delivery.
3524
+ */
3525
+ async function adaptProcess(message, delivery = {}) {
3382
3526
  const relayMode = message.relayMode || 'agent';
3383
3527
  const model = resolvePerTurnModel(message);
3384
3528
  const effort = normalizeClaudeEffort(message.reasoningEffort);
@@ -3393,6 +3537,25 @@ export function createClaudeSessionRunner({
3393
3537
  return null;
3394
3538
  }
3395
3539
 
3540
+ // The window is spawn-only: a running CLI ignores a flag-layer
3541
+ // autoCompactWindow in either direction (probe 2026-10-03). A changed
3542
+ // window is applied by releasing the process the way an idle release does
3543
+ // and letting this delivery cold-start a fresh one on the same session.
3544
+ // Nothing else is set on the old process first — the new one is spawned
3545
+ // with every current setting. Blocked (background work), the delivery
3546
+ // runs on the old process and the next turn-opening one tries again.
3547
+ const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
3548
+ if (autoCompactWindow !== proc.autoCompactWindow && !delivery.windowRespawn && canReleaseForRespawn()) {
3549
+ delivery.windowRespawn = {
3550
+ from: proc.autoCompactWindow,
3551
+ to: autoCompactWindow,
3552
+ resume: String(proc.nativeSessionId || '').trim(),
3553
+ };
3554
+ gracefulShutdown('compact-window');
3555
+ await drainClosingProcess(10_000);
3556
+ return null;
3557
+ }
3558
+
3396
3559
  const permissionMode = permissionModeForRelayMode(relayMode);
3397
3560
  if (permissionMode !== proc.permissionMode) {
3398
3561
  expectSettingsReinit();
@@ -3432,18 +3595,31 @@ export function createClaudeSessionRunner({
3432
3595
  });
3433
3596
  proc.effort = effort;
3434
3597
  }
3435
- // The window is a delivery-scoped setting, not a per-message one: it only
3436
- // reaches a live process through the flag layer, and `null` (Auto) has to
3437
- // be pushed too or clearing the slider would never take effect.
3438
- const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
3439
- if (autoCompactWindow !== proc.autoCompactWindow) {
3440
- expectSettingsReinit();
3441
- await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAutoCompactFlagSettings(autoCompactWindow))).catch((error) => {
3442
- dbg('applyFlagSettings autoCompactWindow failed', error?.message || String(error));
3443
- });
3444
- proc.autoCompactWindow = autoCompactWindow;
3598
+ // Attribution: a changed value (a settings change, a model switch that
3599
+ // renames the trailer) reaches the live CLI through the flag layer, before
3600
+ // the turn that may commit.
3601
+ const attribution = getAttribution() || null;
3602
+ if (!sameClaudeAttribution(attribution, proc.attribution)) {
3603
+ if (attribution === null) {
3604
+ // Back to the CLI's own attribution. A value the process was spawned
3605
+ // with cannot be unset through the flag layer (probe 2026-10-01), so
3606
+ // the honest way is a fresh process without the key — taken when
3607
+ // nothing lives in the old one; a busy process keeps the old text and
3608
+ // the next delivery tries again.
3609
+ if (!hasLiveWork() && !proc.liveTasks.size) {
3610
+ gracefulShutdown('attribution-vanilla');
3611
+ await drainClosingProcess(10_000);
3612
+ return null;
3613
+ }
3614
+ } else {
3615
+ expectSettingsReinit();
3616
+ await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAttributionFlagSettings(attribution))).catch((error) => {
3617
+ dbg('applyFlagSettings attribution failed', error?.message || String(error));
3618
+ });
3619
+ proc.attribution = attribution;
3620
+ }
3445
3621
  }
3446
- // Thinking is NOT a symmetric copy of the window block: the probe
3622
+ // Thinking is not symmetric either: the probe
3447
3623
  // (2026-08-26, docs/plans/claude-thinking-control.md) showed the two
3448
3624
  // directions of the on/off axis are not equally reachable mid-session.
3449
3625
  const thinkingState = getThinking() || {};
@@ -3582,7 +3758,9 @@ export function createClaudeSessionRunner({
3582
3758
  // A closing/aborted process is about to die — nothing about its last turn
3583
3759
  // should hold the composer of the worker that replaces it.
3584
3760
  if (!proc || proc.closing || proc.aborted) {
3585
- return { turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true };
3761
+ return {
3762
+ turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true, autoCompactWindow: null,
3763
+ };
3586
3764
  }
3587
3765
  const ctx = proc.activeCtx || null;
3588
3766
  const ctxLive = Boolean(ctx && !ctx.finalized && !ctx.discarded && !ctx.interrupted);
@@ -3592,6 +3770,7 @@ export function createClaudeSessionRunner({
3592
3770
  const openingDelivery = proc.pendingDelivered.find((entry) => !entry.steered) || null;
3593
3771
  const turnActive = ctxLive || Boolean(openingDelivery);
3594
3772
  const canSteer = canAcceptSteering();
3773
+ const autoCompactWindow = autoCompactWindowState();
3595
3774
  let holdReason = null;
3596
3775
  if (turnActive && !canSteer) {
3597
3776
  if (proc.pendingControlRequests > 0) holdReason = 'question';
@@ -3608,7 +3787,7 @@ export function createClaudeSessionRunner({
3608
3787
  // `supported` is the worker-advertised opt-in the client's composer reads
3609
3788
  // (the same flag the Copilot SDK worker publishes) — its only steering
3610
3789
  // gate, so every snapshot carries it, idle ones included.
3611
- return { turnActive, canSteer, holdReason, messageId, supported: true };
3790
+ return { turnActive, canSteer, holdReason, messageId, supported: true, autoCompactWindow };
3612
3791
  }
3613
3792
 
3614
3793
  /**
@@ -3649,6 +3828,22 @@ export function createClaudeSessionRunner({
3649
3828
  syncDeliveryReadiness();
3650
3829
  };
3651
3830
  syncDeliveryReadiness();
3831
+ // This delivery's own record: a window respawn taken for it (one at most).
3832
+ const delivery = { windowRespawn: null };
3833
+ // Its note goes on this message's row, once, whichever delivery's spawn
3834
+ // the message then runs on.
3835
+ let respawnNoted = false;
3836
+ const noteWindowRespawn = () => {
3837
+ if (!delivery.windowRespawn || respawnNoted) return;
3838
+ respawnNoted = true;
3839
+ const { from, to } = delivery.windowRespawn;
3840
+ publisher.postActivity(
3841
+ message,
3842
+ `Restarted the session to apply the compaction window (${formatAutoCompactWindowLabel(from)} → ${formatAutoCompactWindowLabel(to)})`,
3843
+ null,
3844
+ { kind: 'compact_window_respawn', from, to },
3845
+ );
3846
+ };
3652
3847
  try {
3653
3848
  // A steered delivery (a turn is live) must not run the settings diffs:
3654
3849
  // setModel/effort/mode against a mid-flight turn would disturb the very
@@ -3658,7 +3853,7 @@ export function createClaudeSessionRunner({
3658
3853
  // lost — the steered message's own model/mode intent applies from the
3659
3854
  // next turn on, same as Claude Code.
3660
3855
  if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) {
3661
- await adaptProcess(message);
3856
+ await adaptProcess(message, delivery);
3662
3857
  }
3663
3858
  if (!proc || proc.closing || proc.aborted) {
3664
3859
  // Awaited only when there is one: a cold spawn with the decision
@@ -3671,20 +3866,26 @@ export function createClaudeSessionRunner({
3671
3866
  if (!remoteRelayGate.isSettled?.()) await remoteRelayGate.ready();
3672
3867
  // Another delivery spawned while this one waited: join that process
3673
3868
  // like any delivery (between turns: adapt; mid-turn: steer below).
3674
- if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) await adaptProcess(message);
3869
+ if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) await adaptProcess(message, delivery);
3675
3870
  // What it joined may be gone by now: that adapt can recycle the
3676
3871
  // process (a mode change), and a process can wind down under any of
3677
3872
  // these waits. Only a live one takes the push below.
3678
3873
  if (proc && (proc.closing || proc.aborted)) await drainClosingProcess();
3679
- if (!proc) spawnProcess(message);
3874
+ // A window respawn resumes the session the released process ran.
3875
+ const respawnResume = delivery.windowRespawn?.resume || '';
3876
+ if (!proc) spawnProcess(message, { resume: respawnResume });
3877
+ noteWindowRespawn();
3680
3878
  // A cold start is the one moment a turn can sit silent for many
3681
3879
  // seconds (transcript load + first request); say so instead of
3682
3880
  // showing bare dots. Posted once here — not in spawnProcess — so a
3683
3881
  // push-race respawn cannot duplicate the line.
3684
- publisher.postActivity(message, (message.claudeNativeSessionId || claudeNativeSessionId)
3882
+ publisher.postActivity(message, (respawnResume || message.claudeNativeSessionId || claudeNativeSessionId)
3685
3883
  ? 'Starting the Claude CLI (cold start — resuming the session transcript)…'
3686
3884
  : 'Starting the Claude CLI (cold start)…');
3687
3885
  }
3886
+ // Released for the window, then joined a process another delivery
3887
+ // spawned in the meantime.
3888
+ noteWindowRespawn();
3688
3889
  const content = buildClaudeUserContent(message);
3689
3890
  for (let attempt = 0; attempt < 2; attempt += 1) {
3690
3891
  const procRef = proc;
@@ -3730,7 +3931,7 @@ export function createClaudeSessionRunner({
3730
3931
  // Belt and braces: the stream was already ending, but make sure the
3731
3932
  // superseded CLI cannot linger next to its replacement.
3732
3933
  try { procRef.turn.close?.(); } catch {}
3733
- spawnProcess(message);
3934
+ spawnProcess(message, { resume: delivery.windowRespawn?.resume || '' });
3734
3935
  continue;
3735
3936
  }
3736
3937
  // Pushed: the pending entry now holds the gate on its own.
@@ -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) {
@@ -75,6 +75,34 @@ export function compactBoundaryActivityAction(sdkMessage) {
75
75
  };
76
76
  }
77
77
 
78
+ /**
79
+ * The compaction's start and its end without a result, as entries of the same
80
+ * kind as the boundary: the transcript reads the LAST compaction entry of a
81
+ * message, so a `pending` one shows the running compaction, the boundary
82
+ * replaces it with the result, and `cancelled` takes the line away again.
83
+ */
84
+ export function compactPendingActivityAction() {
85
+ return {
86
+ channel: 'activity',
87
+ payload: {
88
+ text: 'Compacting context…',
89
+ subagentRunId: null,
90
+ metadata: { kind: 'compact_boundary', state: 'pending' },
91
+ },
92
+ };
93
+ }
94
+
95
+ export function compactCancelledActivityAction() {
96
+ return {
97
+ channel: 'activity',
98
+ payload: {
99
+ text: 'Compaction ended without a result',
100
+ subagentRunId: null,
101
+ metadata: { kind: 'compact_boundary', state: 'cancelled' },
102
+ },
103
+ };
104
+ }
105
+
78
106
  // MCP tools reach the transcript namespaced as `mcp__<server>__<tool>`; which
79
107
  // server carries a tool is relay plumbing, so the chip shows the bare name.
80
108
  const MCP_TOOL_NAME = /^mcp__(?:.+?)__(.+)$/;
@@ -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
+ }