@oar-sh/oar 0.9.7 → 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 (38) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +46 -18
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  6. package/server/claude-worker/claude-session-process.mjs +196 -28
  7. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  8. package/server/public/app/activity-replay-state.mjs +68 -5
  9. package/server/public/app/api-client.js +6 -2
  10. package/server/public/app/bootstrap.js +124 -57
  11. package/server/public/app/context-usage-view.mjs +127 -8
  12. package/server/public/app/conversation-view.js +62 -7
  13. package/server/public/app/pwa-install.js +5 -0
  14. package/server/public/app/scrollbar-hover.mjs +78 -0
  15. package/server/public/app/settings-modal.js +4 -1
  16. package/server/public/app/socket-handlers.js +2 -1
  17. package/server/public/app/store.js +26 -0
  18. package/server/public/app/transcript-separators.mjs +48 -11
  19. package/server/public/app-icon-192.png +0 -0
  20. package/server/public/app-icon-512.png +0 -0
  21. package/server/public/app-icon-light-192.png +0 -0
  22. package/server/public/app-icon-light-512.png +0 -0
  23. package/server/public/app-icon-light-maskable-512.png +0 -0
  24. package/server/public/app-icon-light.svg +4 -0
  25. package/server/public/app-icon-maskable-512.png +0 -0
  26. package/server/public/app-icon.svg +3 -3
  27. package/server/public/favicon.ico +0 -0
  28. package/server/public/index.html +183 -13
  29. package/server/public/manifest.webmanifest +9 -3
  30. package/server/public/sw.js +6 -1
  31. package/server/routes/messages-routes.mjs +3 -1
  32. package/server/routes/sessions-routes.mjs +15 -2
  33. package/server/server-runtime.mjs +10 -8
  34. package/server/services/claude-context-usage.mjs +51 -3
  35. package/server/services/oar-cli-helpers.mjs +67 -1
  36. package/server/services/pwa-manifest-theme.mjs +30 -0
  37. package/server/services/session-worker-registry-service.mjs +18 -1
  38. package/server/services/update-install-service.mjs +9 -3
@@ -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__(?:.+?)__(.+)$/;
@@ -18,10 +18,44 @@ export function normalizeRelayActivityEntry(item) {
18
18
  // A compaction boundary is published as an ordinary activity row carrying
19
19
  // structured metadata. The transcript promotes it to a full-width break row
20
20
  // (and drops it from the bubble's tool-activity list), so both sides ask here.
21
+ // Every compaction entry answers here, whatever its state: the live bubble
22
+ // never shows any of them as prose.
21
23
  export function isCompactBoundaryActivityEntry(item) {
22
24
  return normalizeRelayActivityEntry(item)?.metadata?.kind === 'compact_boundary';
23
25
  }
24
26
 
27
+ // The live list of one turn keeps its newest ordinary lines only, but every
28
+ // structured row a line of the transcript is built from: the turn's
29
+ // compaction entries and the session-restart note would otherwise scroll out
30
+ // of a busy turn and take their line with them until the next reload.
31
+ export const LIVE_ACTIVITY_CAP = 24;
32
+ function isKeptLiveActivityEntry(item) {
33
+ const kind = normalizeRelayActivityEntry(item)?.metadata?.kind;
34
+ return kind === 'compact_boundary' || kind === 'compact_window_respawn';
35
+ }
36
+ export function capLiveActivityEntries(items, cap = LIVE_ACTIVITY_CAP) {
37
+ const list = Array.isArray(items) ? items : [];
38
+ let ordinary = list.reduce((count, item) => count + (isKeptLiveActivityEntry(item) ? 0 : 1), 0);
39
+ if (ordinary <= cap) return list;
40
+ return list.filter((item) => {
41
+ if (isKeptLiveActivityEntry(item)) return true;
42
+ ordinary -= 1;
43
+ return ordinary < cap;
44
+ });
45
+ }
46
+
47
+ // A compaction is published in up to two steps on the same message: a
48
+ // `pending` entry when it starts, then either the boundary (no `state`, token
49
+ // counts) or a `cancelled` entry when it ended without a result.
50
+ // → 'pending' | 'cancelled' | 'boundary' | null (not a compaction entry).
51
+ export function compactionEntryState(item) {
52
+ const metadata = normalizeRelayActivityEntry(item)?.metadata;
53
+ if (metadata?.kind !== 'compact_boundary') return null;
54
+ const state = String(metadata.state || '').trim().toLowerCase();
55
+ if (state === 'pending' || state === 'cancelled') return state;
56
+ return 'boundary';
57
+ }
58
+
25
59
  function toTokenCount(value) {
26
60
  // `Number(null)` is 0, not NaN: without this an omitted post_tokens (every
27
61
  // real auto-compaction) would read as a compaction down to zero tokens.
@@ -34,19 +68,36 @@ function toTokenCount(value) {
34
68
  // than once only the LAST boundary is promoted. This returns that entry by
35
69
  // identity, so the render path can keep the others visible as prose instead
36
70
  // of dropping them (they would otherwise vanish from the transcript).
37
- export function promotedCompactBoundaryEntry(items) {
71
+ //
72
+ // The last compaction entry decides: a `pending` one is promoted only while
73
+ // the message is still `processing` (a finished or failed message never shows
74
+ // a dangling start). A dangling start or a `cancelled` end draws no line of
75
+ // its own, so the line falls back to an earlier boundary of the same message
76
+ // when there is one.
77
+ export function promotedCompactBoundaryEntry(items, { processing = false } = {}) {
38
78
  const list = Array.isArray(items) ? items : [];
79
+ let sawLast = false;
39
80
  for (let index = list.length - 1; index >= 0; index -= 1) {
40
- if (isCompactBoundaryActivityEntry(list[index])) return list[index];
81
+ const state = compactionEntryState(list[index]);
82
+ if (!state) continue;
83
+ if (!sawLast) {
84
+ sawLast = true;
85
+ if (state === 'pending' && processing) return list[index];
86
+ }
87
+ if (state === 'boundary') return list[index];
41
88
  }
42
89
  return null;
43
90
  }
44
91
 
45
92
  // The last compaction recorded against one message's activities, as
46
- // { preTokens, postTokens } (either may be null when the SDK omitted it).
47
- export function compactBoundaryFromActivities(items) {
48
- const promoted = promotedCompactBoundaryEntry(items);
93
+ // { preTokens, postTokens } (either may be null when the SDK omitted it), or
94
+ // { pending: true, preTokens: null, postTokens: null } while it runs.
95
+ export function compactBoundaryFromActivities(items, options = {}) {
96
+ const promoted = promotedCompactBoundaryEntry(items, options);
49
97
  if (!promoted) return null;
98
+ if (compactionEntryState(promoted) === 'pending') {
99
+ return { pending: true, preTokens: null, postTokens: null };
100
+ }
50
101
  const metadata = normalizeRelayActivityEntry(promoted).metadata;
51
102
  return {
52
103
  preTokens: toTokenCount(metadata.preTokens),
@@ -54,6 +105,18 @@ export function compactBoundaryFromActivities(items) {
54
105
  };
55
106
  }
56
107
 
108
+ // Activity rows the message bubble lists as prose: everything except the
109
+ // promoted boundary (it is the break row) and every pending/cancelled step
110
+ // (transient state, never prose).
111
+ export function visibleActivityEntries(items, promoted) {
112
+ const list = Array.isArray(items) ? items : [];
113
+ return list.filter((item) => {
114
+ if (item === promoted) return false;
115
+ const state = compactionEntryState(item);
116
+ return state !== 'pending' && state !== 'cancelled';
117
+ });
118
+ }
119
+
57
120
  // Head-cap that never drops structured rows. Activity lists are capped from
58
121
  // the front (the start of a turn is the interesting part), but a
59
122
  // metadata-bearing row carries transcript structure rather than prose, and a
@@ -46,7 +46,9 @@ export function areNetworkRequestsEnabled() {
46
46
  }
47
47
 
48
48
  export async function apiFetch(url, opts = {}) {
49
- if (!networkRequestsEnabled) return null;
49
+ // A keepalive request is a page's last word; it goes out even when the
50
+ // page's regular network work is already suspended.
51
+ if (!networkRequestsEnabled && !opts.keepalive) return null;
50
52
  try {
51
53
  const response = await fetch(`${BASE}${url}`, {
52
54
  signal: requestTimeoutSignal(),
@@ -924,12 +926,14 @@ export async function updateWorkspaceRootAttribution(path, attributionMode) {
924
926
  });
925
927
  }
926
928
 
927
- export async function updateConversationPreferences(id, preferences = {}) {
929
+ // `keepalive` lets the request outlive the page (a save made while it closes).
930
+ export async function updateConversationPreferences(id, preferences = {}, { keepalive = false } = {}) {
928
931
  const convId = String(id || '').trim();
929
932
  if (!convId) return null;
930
933
  return apiFetch(`/api/conversation/${convId}/preferences`, {
931
934
  method: 'PATCH',
932
935
  body: JSON.stringify(preferences || {}),
936
+ ...(keepalive ? { keepalive: true } : {}),
933
937
  });
934
938
  }
935
939