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