@oar-sh/oar 0.9.7 → 0.9.9

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 (64) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +89 -40
  3. package/bin/oar.js +1023 -135
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  7. package/server/claude-worker/claude-session-process.mjs +196 -28
  8. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  9. package/server/db-schema.mjs +8 -0
  10. package/server/migrations/0008-message-pins.mjs +85 -0
  11. package/server/public/app/activity-replay-state.mjs +68 -5
  12. package/server/public/app/api-client.js +36 -2
  13. package/server/public/app/attachments-view.js +508 -125
  14. package/server/public/app/back-guard.mjs +58 -0
  15. package/server/public/app/bootstrap.js +170 -59
  16. package/server/public/app/context-usage-view.mjs +127 -8
  17. package/server/public/app/conversation-view.js +172 -32
  18. package/server/public/app/external-link-policy.mjs +4 -1
  19. package/server/public/app/file-version.mjs +58 -0
  20. package/server/public/app/journal-view.js +16 -2
  21. package/server/public/app/pinned-messages-view.mjs +253 -0
  22. package/server/public/app/pwa-install.js +6 -1
  23. package/server/public/app/relay-dot-state.mjs +8 -1
  24. package/server/public/app/router.js +8 -2
  25. package/server/public/app/scrollbar-hover.mjs +78 -0
  26. package/server/public/app/settings-modal.js +4 -1
  27. package/server/public/app/socket-handlers.js +8 -1
  28. package/server/public/app/store.js +32 -0
  29. package/server/public/app/transcript-separators.mjs +48 -11
  30. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  31. package/server/public/app/viewer-controls-state.mjs +59 -0
  32. package/server/public/app/viewer-gallery.mjs +46 -0
  33. package/server/public/app/viewer-gestures.mjs +105 -0
  34. package/server/public/app/viewer-history.mjs +43 -0
  35. package/server/public/app/viewer-tap.mjs +112 -0
  36. package/server/public/app-icon-192.png +0 -0
  37. package/server/public/app-icon-512.png +0 -0
  38. package/server/public/app-icon-light-192.png +0 -0
  39. package/server/public/app-icon-light-512.png +0 -0
  40. package/server/public/app-icon-light-maskable-512.png +0 -0
  41. package/server/public/app-icon-light.svg +4 -0
  42. package/server/public/app-icon-maskable-512.png +0 -0
  43. package/server/public/app-icon.svg +3 -3
  44. package/server/public/favicon.ico +0 -0
  45. package/server/public/index.html +487 -67
  46. package/server/public/manifest.webmanifest +9 -3
  47. package/server/public/sw.js +6 -1
  48. package/server/repositories/message-pin-statements.mjs +39 -0
  49. package/server/repositories/message-repository.mjs +5 -0
  50. package/server/routes/messages-routes.mjs +36 -62
  51. package/server/routes/sessions-routes.mjs +107 -26
  52. package/server/server-runtime.mjs +39 -8
  53. package/server/services/claude-context-usage.mjs +51 -3
  54. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  55. package/server/services/file-serving.mjs +155 -0
  56. package/server/services/message-pin-list.mjs +52 -0
  57. package/server/services/oar-cli-helpers.mjs +205 -4
  58. package/server/services/oar-cli-menu.mjs +240 -0
  59. package/server/services/oar-cli-wsl.mjs +129 -0
  60. package/server/services/pwa-manifest-theme.mjs +30 -0
  61. package/server/services/safe-served-content.mjs +61 -11
  62. package/server/services/session-history-refresh-service.mjs +24 -0
  63. package/server/services/session-worker-registry-service.mjs +18 -1
  64. package/server/services/update-install-service.mjs +9 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -50,9 +50,9 @@
50
50
  "test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
51
51
  },
52
52
  "dependencies": {
53
- "@anthropic-ai/claude-agent-sdk": "^0.3.283",
54
- "@cursor/sdk": "^1.0.32",
55
- "@github/copilot-sdk": "^1.0.14",
53
+ "@anthropic-ai/claude-agent-sdk": "^0.3.292",
54
+ "@cursor/sdk": "^1.0.36",
55
+ "@github/copilot-sdk": "^1.0.17",
56
56
  "better-sqlite3": "^12.10.0",
57
57
  "express": "^4.18.2",
58
58
  "qrcode-terminal": "^0.12.0",
@@ -63,7 +63,6 @@
63
63
  "zod": "^4.4.3"
64
64
  },
65
65
  "optionalDependencies": {
66
- "cloudflared": "^0.7.1",
67
66
  "tty-console": "^1.0.4"
68
67
  },
69
68
  "devDependencies": {
package/server/README.md CHANGED
@@ -658,6 +658,24 @@ images, calling the OpenAI Images API directly rather than going through a CLI w
658
658
  is never silently lost.
659
659
  - `POST /api/shared/:token/presence` tracks shared-viewer presence, surfaced as a watcher count.
660
660
 
661
+ ## Pinned messages
662
+
663
+ - `PATCH /api/conversation/:id/message/:messageId/pin` with `{ pinned: boolean }` sets or clears
664
+ `messages.pinned_at`. A conversation holds at most 100 pins; the next one is refused with `409`
665
+ and `code: "pin-limit"`. The answer is
666
+ `{ ok, conversationId, messageId, pinned, pins, pinsRevision }`.
667
+ - `GET /api/conversation/:id` carries `pins`: every pinned message of the conversation in
668
+ conversation order, also those outside the requested history page, each as
669
+ `{ messageId, role, preview, timestamp, pinnedAt, attachmentCount, hiddenFromShares }`.
670
+ `preview` is plain text of at most 160 characters. Each message also carries `pinned`.
671
+ - Every change is broadcast as the socket event `conversation_pins_updated` with
672
+ `{ conversationId, pins, revision }`. `revision` (`pinsRevision` in the HTTP answers) grows with
673
+ every change. A conversation load that left the relay before a change can reach the browser
674
+ after the change's own event; the browser keeps the list with the higher revision.
675
+ - Shared views carry neither `pins` nor `pinned`: pins are the owner's bookmarks.
676
+ - Pins belong to message rows. `/compact` starts a new conversation without them (they stay with
677
+ the archived one); re-importing an imported session keeps them, matched like hidden marks.
678
+
661
679
  ## Relay Mode Selection
662
680
 
663
681
  The composer also includes a per-message mode picker:
@@ -973,6 +991,7 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
973
991
  | POST | `/api/subagent-run` | (Worker) Register/update a subagent run for the active turn |
974
992
  | POST | `/api/conversation/:conversationId/subagent/:subagentRunId/cancel` | Request cancellation of one subagent run (unsupported by the Claude runtime) |
975
993
  | PATCH | `/api/conversation/:id/message/:messageId/share-visibility` | Hide or unhide a single message from shared views |
994
+ | PATCH | `/api/conversation/:id/message/:messageId/pin` | `{ pinned }` → pin or unpin a single message; answers with the conversation's pin list and broadcasts `conversation_pins_updated` |
976
995
  | POST | `/api/conversation/:id/share` | Create or update the conversation's read-only share token |
977
996
  | GET | `/api/shared/:token` | Read-only shared conversation view (hidden messages filtered out) |
978
997
  | POST | `/api/shared/:token/presence` | Report shared-viewer presence for the watcher count |
@@ -1381,7 +1400,7 @@ service).
1381
1400
  | `cloudflaredTunnel.enabled` | `false` | Legacy alias for mode (`true` => `managed`) |
1382
1401
  | `cloudflaredTunnel.required` | `false` | Pause dequeue when the Cloudflare tunnel is disconnected in managed mode |
1383
1402
  | `cloudflaredTunnel.token` | — | Tunnel token from the router panel (never logged) |
1384
- | `cloudflaredTunnel.binary` | *(auto)* | `cloudflared` path; falls back to the optional npm package, then `PATH` |
1403
+ | `cloudflaredTunnel.binary` | `cloudflared` | Path or command name of the `cloudflared` you installed (OAR ships none); defaults to `cloudflared` on `PATH`. Without one the tunnel stays off and the status says how to install it |
1385
1404
  | `cloudflaredTunnel.extraArgs` | `[]` | Extra arguments appended to `cloudflared tunnel run` |
1386
1405
  | `tunnelMarkerHeaders` | `[]` | Extra edge-injected header names that mark tunnel traffic for the session-worker path guard |
1387
1406
 
@@ -55,17 +55,6 @@ export function claudeUltracodeFlagSettings(effort) {
55
55
  return { ultracode: null, enableWorkflows: null, effortLevel: effort || null };
56
56
  }
57
57
 
58
- /**
59
- * The flag-settings payload that moves a live session onto another auto-compact
60
- * window. `null` clears the flag layer back to the CLI's model-tuned default,
61
- * which is exactly what "Auto" means — leaving the previous window pinned would
62
- * make the setting one-way.
63
- */
64
- export function claudeAutoCompactFlagSettings(autoCompactWindow) {
65
- const window = normalizeAutoCompactWindow(autoCompactWindow);
66
- return { autoCompactWindow: window };
67
- }
68
-
69
58
  /**
70
59
  * The flag-settings payload that pins (or clears) `alwaysThinkingEnabled` on a
71
60
  * live session.
@@ -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,7 +22,6 @@ import {
20
22
  readPlanUsage,
21
23
  normalizeClaudeEffort,
22
24
  claudeUltracodeFlagSettings,
23
- claudeAutoCompactFlagSettings,
24
25
  claudeAttributionFlagSettings,
25
26
  claudeThinkingFlagSettings,
26
27
  applyThinkingDisplay,
@@ -29,6 +30,7 @@ import {
29
30
  } from './claude-sdk-adapter.mjs';
30
31
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
31
32
  import { sameClaudeAttribution } from '../../shared/claude-attribution.mjs';
33
+ import { formatAutoCompactWindowLabel } from '../../shared/auto-compact-window.mjs';
32
34
  import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
33
35
  import {
34
36
  attemptFields,
@@ -447,8 +449,9 @@ export function createClaudeSessionRunner({
447
449
  idleShutdownMs = 10 * 60_000,
448
450
  getBackgroundTaskTimeoutMs = () => 0,
449
451
  // The per-conversation auto-compact window (token count, null = Auto). Read
450
- // per turn rather than captured, so a slider change picked up on the next
451
- // 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).
452
455
  getAutoCompactWindow = () => null,
453
456
  // What this session's commits say (shared/claude-attribution.mjs); null
454
457
  // leaves the CLI's own attribution. Piggybacked on deliveries like the
@@ -583,6 +586,10 @@ export function createClaudeSessionRunner({
583
586
  // the same misattribution as a continuation whose init follows the push.
584
587
  let settingsReinitsOwed = 0;
585
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 = [];
586
593
  let lastReportedDeliveryReady = true;
587
594
  // Rows whose prompt the CLI consumed and whose settle marker is being saved
588
595
  // (id → relay message). Runner-level, not per process: a process dying
@@ -801,6 +808,15 @@ export function createClaudeSessionRunner({
801
808
  // Orphan/settled-task notification lines that arrived between turns belong
802
809
  // to the turn they triggered.
803
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
+ }
804
820
  if (carried.length) {
805
821
  const actions = carried.map((entry) => (typeof entry === 'string'
806
822
  ? { channel: 'activity', payload: { text: entry, subagentRunId: null } }
@@ -2093,6 +2109,54 @@ export function createClaudeSessionRunner({
2093
2109
  return Boolean(proc.activeCtx) && !proc.activeCtx.discarded;
2094
2110
  }
2095
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
+
2096
2160
  /**
2097
2161
  * The CLI is compacting, or just did: a delivered turn is still coming.
2098
2162
  * Armed only while a delivered entry is actually waiting; which turn the
@@ -2216,8 +2280,14 @@ export function createClaudeSessionRunner({
2216
2280
  if (String(sdkMessage.status || '') === 'compacting') {
2217
2281
  proc.compactingSince = Date.now();
2218
2282
  armCompactReplayAdoption();
2283
+ noteCompactionStarted();
2219
2284
  } else if (sdkMessage.permissionMode === undefined) {
2220
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);
2221
2291
  // A compaction that failed produces no boundary and no replay: the
2222
2292
  // turn just carries on uncompacted and attaches the normal way, so
2223
2293
  // leaving the adoption window open would only widen the chance of a
@@ -2231,6 +2301,23 @@ export function createClaudeSessionRunner({
2231
2301
  }
2232
2302
  if (subtype === 'compact_boundary') {
2233
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
+ }
2234
2321
  armCompactReplayAdoption();
2235
2322
  // The boundary is a per-turn normalizer action, but a compaction at
2236
2323
  // resume — exactly what lowering the window causes — lands with no
@@ -2599,6 +2686,7 @@ export function createClaudeSessionRunner({
2599
2686
  const suppressed = maybeSuppressStopFollowUp(sdkMessage);
2600
2687
  if (suppressed === 'swallow') return;
2601
2688
  observeProcessLevel(sdkMessage);
2689
+ for (const queued of queuedProcessActions.splice(0)) await publishProcessAction(queued);
2602
2690
  if (suppressed === 'observe-only') return;
2603
2691
  // Its traffic opens a continuation of its own instead of attaching to the
2604
2692
  // stopped steer waiting in pendingDelivered (which still settles stopped).
@@ -3042,6 +3130,12 @@ export function createClaudeSessionRunner({
3042
3130
  // answered instead of failing over minutes later.
3043
3131
  reapOvertakenDeliveredEntries(now);
3044
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
+ }
3045
3139
  if (hasLiveWork()) {
3046
3140
  // Track how long background tasks alone have held the process, for the
3047
3141
  // user-configurable timeout (0 = unlimited).
@@ -3113,6 +3207,10 @@ export function createClaudeSessionRunner({
3113
3207
  processRef.taskPublishTimer = null;
3114
3208
  }
3115
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);
3116
3214
  // The process took its background tasks with it; clear the panel — but
3117
3215
  // only when this process still owns it. A superseded process clearing the
3118
3216
  // panel would blank the replacement's live task set.
@@ -3134,6 +3232,7 @@ export function createClaudeSessionRunner({
3134
3232
  for (const entry of stoppedSteers) claimSettling(entry.ctx.message, 'stopped');
3135
3233
  processRef.activeCtx = null;
3136
3234
  if (proc === processRef) proc = null;
3235
+ if (compactionCancel) await publishProcessAction(compactionCancel);
3137
3236
  for (const ctx of openContexts) {
3138
3237
  if (ctx.finalized) continue;
3139
3238
  if (streamError && !processRef.aborted && !ctx.interrupted) {
@@ -3168,7 +3267,7 @@ export function createClaudeSessionRunner({
3168
3267
  // ---------------------------------------------------------------------------
3169
3268
  // Spawn / adapt
3170
3269
 
3171
- function spawnProcess(message) {
3270
+ function spawnProcess(message, { resume: resumeOverride = '' } = {}) {
3172
3271
  const relayMode = message.relayMode || 'agent';
3173
3272
  const model = resolvePerTurnModel(message);
3174
3273
  const effort = normalizeClaudeEffort(message.reasoningEffort);
@@ -3227,6 +3326,9 @@ export function createClaudeSessionRunner({
3227
3326
  // delivered entry may still adopt the turn the CLI re-opens afterwards.
3228
3327
  compactingSince: 0,
3229
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,
3230
3332
  // A non-spawn init that opened a turn while a delivered row waited: the
3231
3333
  // turn is the CLI's own, so no compaction inside it may adopt that row.
3232
3334
  continuationInitPending: false,
@@ -3326,7 +3428,7 @@ export function createClaudeSessionRunner({
3326
3428
  }
3327
3429
  };
3328
3430
 
3329
- const resume = String(message.claudeNativeSessionId || claudeNativeSessionId || '').trim();
3431
+ const resume = String(resumeOverride || message.claudeNativeSessionId || claudeNativeSessionId || '').trim();
3330
3432
  processRef.nativeSessionId = resume;
3331
3433
  // The CLI resolves `resume` inside the project directory for *this* CWD, so
3332
3434
  // a session whose workspace root changed has to bring its transcript along
@@ -3387,7 +3489,40 @@ export function createClaudeSessionRunner({
3387
3489
  settingsWindowOpen = true;
3388
3490
  }
3389
3491
 
3390
- 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 = {}) {
3391
3526
  const relayMode = message.relayMode || 'agent';
3392
3527
  const model = resolvePerTurnModel(message);
3393
3528
  const effort = normalizeClaudeEffort(message.reasoningEffort);
@@ -3402,6 +3537,25 @@ export function createClaudeSessionRunner({
3402
3537
  return null;
3403
3538
  }
3404
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
+
3405
3559
  const permissionMode = permissionModeForRelayMode(relayMode);
3406
3560
  if (permissionMode !== proc.permissionMode) {
3407
3561
  expectSettingsReinit();
@@ -3441,20 +3595,9 @@ export function createClaudeSessionRunner({
3441
3595
  });
3442
3596
  proc.effort = effort;
3443
3597
  }
3444
- // The window is a delivery-scoped setting, not a per-message one: it only
3445
- // reaches a live process through the flag layer, and `null` (Auto) has to
3446
- // be pushed too or clearing the slider would never take effect.
3447
- const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
3448
- if (autoCompactWindow !== proc.autoCompactWindow) {
3449
- expectSettingsReinit();
3450
- await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAutoCompactFlagSettings(autoCompactWindow))).catch((error) => {
3451
- dbg('applyFlagSettings autoCompactWindow failed', error?.message || String(error));
3452
- });
3453
- proc.autoCompactWindow = autoCompactWindow;
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.
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.
3458
3601
  const attribution = getAttribution() || null;
3459
3602
  if (!sameClaudeAttribution(attribution, proc.attribution)) {
3460
3603
  if (attribution === null) {
@@ -3476,7 +3619,7 @@ export function createClaudeSessionRunner({
3476
3619
  proc.attribution = attribution;
3477
3620
  }
3478
3621
  }
3479
- // Thinking is NOT a symmetric copy of the window block: the probe
3622
+ // Thinking is not symmetric either: the probe
3480
3623
  // (2026-08-26, docs/plans/claude-thinking-control.md) showed the two
3481
3624
  // directions of the on/off axis are not equally reachable mid-session.
3482
3625
  const thinkingState = getThinking() || {};
@@ -3615,7 +3758,9 @@ export function createClaudeSessionRunner({
3615
3758
  // A closing/aborted process is about to die — nothing about its last turn
3616
3759
  // should hold the composer of the worker that replaces it.
3617
3760
  if (!proc || proc.closing || proc.aborted) {
3618
- 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
+ };
3619
3764
  }
3620
3765
  const ctx = proc.activeCtx || null;
3621
3766
  const ctxLive = Boolean(ctx && !ctx.finalized && !ctx.discarded && !ctx.interrupted);
@@ -3625,6 +3770,7 @@ export function createClaudeSessionRunner({
3625
3770
  const openingDelivery = proc.pendingDelivered.find((entry) => !entry.steered) || null;
3626
3771
  const turnActive = ctxLive || Boolean(openingDelivery);
3627
3772
  const canSteer = canAcceptSteering();
3773
+ const autoCompactWindow = autoCompactWindowState();
3628
3774
  let holdReason = null;
3629
3775
  if (turnActive && !canSteer) {
3630
3776
  if (proc.pendingControlRequests > 0) holdReason = 'question';
@@ -3641,7 +3787,7 @@ export function createClaudeSessionRunner({
3641
3787
  // `supported` is the worker-advertised opt-in the client's composer reads
3642
3788
  // (the same flag the Copilot SDK worker publishes) — its only steering
3643
3789
  // gate, so every snapshot carries it, idle ones included.
3644
- return { turnActive, canSteer, holdReason, messageId, supported: true };
3790
+ return { turnActive, canSteer, holdReason, messageId, supported: true, autoCompactWindow };
3645
3791
  }
3646
3792
 
3647
3793
  /**
@@ -3682,6 +3828,22 @@ export function createClaudeSessionRunner({
3682
3828
  syncDeliveryReadiness();
3683
3829
  };
3684
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
+ };
3685
3847
  try {
3686
3848
  // A steered delivery (a turn is live) must not run the settings diffs:
3687
3849
  // setModel/effort/mode against a mid-flight turn would disturb the very
@@ -3691,7 +3853,7 @@ export function createClaudeSessionRunner({
3691
3853
  // lost — the steered message's own model/mode intent applies from the
3692
3854
  // next turn on, same as Claude Code.
3693
3855
  if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) {
3694
- await adaptProcess(message);
3856
+ await adaptProcess(message, delivery);
3695
3857
  }
3696
3858
  if (!proc || proc.closing || proc.aborted) {
3697
3859
  // Awaited only when there is one: a cold spawn with the decision
@@ -3704,20 +3866,26 @@ export function createClaudeSessionRunner({
3704
3866
  if (!remoteRelayGate.isSettled?.()) await remoteRelayGate.ready();
3705
3867
  // Another delivery spawned while this one waited: join that process
3706
3868
  // like any delivery (between turns: adapt; mid-turn: steer below).
3707
- if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) await adaptProcess(message);
3869
+ if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) await adaptProcess(message, delivery);
3708
3870
  // What it joined may be gone by now: that adapt can recycle the
3709
3871
  // process (a mode change), and a process can wind down under any of
3710
3872
  // these waits. Only a live one takes the push below.
3711
3873
  if (proc && (proc.closing || proc.aborted)) await drainClosingProcess();
3712
- 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();
3713
3878
  // A cold start is the one moment a turn can sit silent for many
3714
3879
  // seconds (transcript load + first request); say so instead of
3715
3880
  // showing bare dots. Posted once here — not in spawnProcess — so a
3716
3881
  // push-race respawn cannot duplicate the line.
3717
- publisher.postActivity(message, (message.claudeNativeSessionId || claudeNativeSessionId)
3882
+ publisher.postActivity(message, (respawnResume || message.claudeNativeSessionId || claudeNativeSessionId)
3718
3883
  ? 'Starting the Claude CLI (cold start — resuming the session transcript)…'
3719
3884
  : 'Starting the Claude CLI (cold start)…');
3720
3885
  }
3886
+ // Released for the window, then joined a process another delivery
3887
+ // spawned in the meantime.
3888
+ noteWindowRespawn();
3721
3889
  const content = buildClaudeUserContent(message);
3722
3890
  for (let attempt = 0; attempt < 2; attempt += 1) {
3723
3891
  const procRef = proc;
@@ -3763,7 +3931,7 @@ export function createClaudeSessionRunner({
3763
3931
  // Belt and braces: the stream was already ending, but make sure the
3764
3932
  // superseded CLI cannot linger next to its replacement.
3765
3933
  try { procRef.turn.close?.(); } catch {}
3766
- spawnProcess(message);
3934
+ spawnProcess(message, { resume: delivery.windowRespawn?.resume || '' });
3767
3935
  continue;
3768
3936
  }
3769
3937
  // Pushed: the pending entry now holds the gate on its own.
@@ -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__(?:.+?)__(.+)$/;
@@ -13,6 +13,7 @@ import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-marker
13
13
  import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
14
14
  import { migrateWorkspaceRootSettings } from './migrations/0006-workspace-root-settings.mjs';
15
15
  import { migrateClaudeCloud } from './migrations/0007-claude-cloud.mjs';
16
+ import { migrateMessagePins } from './migrations/0008-message-pins.mjs';
16
17
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
17
18
 
18
19
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -734,6 +735,13 @@ try {
734
735
  } catch (error) {
735
736
  console.warn(`[claude-cloud] migration 0007 failed; retrying on next boot: ${error?.message || error}`);
736
737
  }
738
+ // Pinned messages: one additive column and its partial index. Readers tolerate
739
+ // their absence (no pins), so a failure is logged and retried next boot like 0007.
740
+ try {
741
+ migrateMessagePins(db);
742
+ } catch (error) {
743
+ console.warn(`[message-pins] migration 0008 failed; retrying on next boot: ${error?.message || error}`);
744
+ }
737
745
 
738
746
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
739
747
  if (runtimeSessionColumns.length) {
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: pinned messages.
5
+ *
6
+ * 1. `messages.pinned_at` — when the user pinned the message (ISO time); NULL
7
+ * means not pinned. A pin is a property of the message row, like
8
+ * `hidden_from_shares`, so it goes away with the row.
9
+ * 2. `idx_messages_pinned` — a partial index over the pinned rows only: the
10
+ * pin list travels with every conversation load.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot.
13
+ *
14
+ * Usage: node server/migrations/0008-message-pins.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
+ function indexExists(db, name) {
29
+ return !!db
30
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
31
+ .get(name);
32
+ }
33
+
34
+ function columnNames(db, table) {
35
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
36
+ }
37
+
38
+ export function migrateMessagePins(db) {
39
+ if (!tableExists(db, 'messages')) return { applied: false, columnsAdded: 0, indexesAdded: 0 };
40
+ let columnsAdded = 0;
41
+ let indexesAdded = 0;
42
+ if (!columnNames(db, 'messages').has('pinned_at')) {
43
+ db.exec(`ALTER TABLE messages ADD COLUMN pinned_at TEXT`);
44
+ columnsAdded += 1;
45
+ }
46
+ if (!indexExists(db, 'idx_messages_pinned')) {
47
+ db.exec(`
48
+ CREATE INDEX IF NOT EXISTS idx_messages_pinned
49
+ ON messages(conversation_id, timestamp)
50
+ WHERE pinned_at IS NOT NULL
51
+ `);
52
+ indexesAdded += 1;
53
+ }
54
+ return { applied: columnsAdded + indexesAdded > 0, columnsAdded, indexesAdded };
55
+ }
56
+
57
+ function defaultDbPath() {
58
+ const here = path.dirname(fileURLToPath(import.meta.url));
59
+ return path.resolve(here, '..', 'data', 'copilot.db');
60
+ }
61
+
62
+ export function migrate(dbPath, options = {}) {
63
+ const db = new Database(dbPath, options);
64
+ try {
65
+ return migrateMessagePins(db);
66
+ } finally {
67
+ db.close();
68
+ }
69
+ }
70
+
71
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
72
+ if (isMain) {
73
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
74
+ try {
75
+ const result = migrate(dbPath);
76
+ console.log(`Migration 0008 on ${dbPath}`);
77
+ console.log(result.applied
78
+ ? ` message pins: columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
79
+ : ' skipped: nothing to change');
80
+ process.exit(0);
81
+ } catch (err) {
82
+ console.error(`Migration 0008 failed: ${err?.message || err}`);
83
+ process.exit(1);
84
+ }
85
+ }