@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.
- package/CHANGELOG.md +113 -0
- package/README.md +46 -18
- package/bin/oar.js +320 -18
- package/package.json +1 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +6 -2
- package/server/public/app/bootstrap.js +124 -57
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +62 -7
- package/server/public/app/pwa-install.js +5 -0
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +2 -1
- package/server/public/app/store.js +26 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +183 -13
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/routes/messages-routes.mjs +3 -1
- package/server/routes/sessions-routes.mjs +15 -2
- package/server/server-runtime.mjs +10 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/oar-cli-helpers.mjs +67 -1
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- 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
|
|
451
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
3445
|
-
// reaches
|
|
3446
|
-
//
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|