fix(sessions): act on the dual review of the reboot-restore route

Fifteen findings from two independent reviews of #442, three of them
blocking. Every one is addressed here.

The three blockers all sat in the restore route. A rebuild that threw after
addSession left a registered session with no pane behind it, visible on the
board, holding a layout slot and written to state.json, with its plan entry
already spent; the catch now cleans the session up and puts the entry back.
The loop checked neither the global nor the per-user session cap, so one
click could take a board past a documented limit; capacity is now re-checked
per iteration, because the loop is itself creating the sessions it counts.
Worst of the three, a rebuilt session carried none of the state its
constructor has no parameter for and then persisted itself over the record
that held it, zeroing token and cost totals and dropping the pin. The pin
matters most: pruning keeps a record only while it is pinned, so discarding
it handed the record to the next stale sweep. A new
reapplyPersistedSessionState() on the session port restores the pin, the
token totals, auto-compact, auto-clear, auto-resume, nice priority, the
flicker filter and the custom-model selection, and it runs before both
startInteractive and the first persist.

The rest, in the order they bite a user. Every rebuild failure was reported
as workspace-missing, so the banner told users their repo was gone when the
agent had simply failed to start; there are now distinct reasons, and the
toast names each one. The client read restored and skipped off the outer
response object rather than through the uniform envelope, so every count
came back zero and neither toast ever fired. A board left open across the
reboot never learned an offer existed, because the banner was seeded only on
the page-load path; it now re-reads on every SSE init. The workspace check
was existence-only, skipping the multi-user confinement that the create
route applies, so a withdrawn grant would not be noticed. The banner had no
phone breakpoint while its text was nowrap and its buttons could not shrink.

Smaller: a missing workspace is now re-offered rather than dropped, while an
already-open conversation is dropped rather than re-offered forever; a throw
anywhere in the route returns the unspent entries instead of discarding the
plan; the single flight is keyed by owner, since take() already stops two
callers receiving one entry; the env clamp's header no longer claims a
protection it cannot provide on this path today, and names the check that
does bite; the three endpoints are documented in docs/api-reference.md; and
the module header now says that os.uptime() reads the host's clock, so the
feature is effectively off inside a container.

The review also explained why the tests missed all of this: they proved the
construction claim through their own copy of the construction rather than
through the route, and the route tests used workspaces that did not exist,
so no Session was ever built. test/routes/reboot-restore-rebuild-failure.ts
mocks the Session module to drive the route's real path, and covers the
cleanup, the reason reported, the re-application ordering, the broadcast and
the caps. The mock route context gains the port method and the mux call the
route needs.

Refs #411

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-16 11:44:37 +02:00
co-authored by Claude Opus 5
parent da933d70be
commit fbede5cd2a
13 changed files with 632 additions and 121 deletions
+18 -2
View File
@@ -57,6 +57,12 @@ export interface RebootEvidence {
*
* This heuristic decides whether to ASK, never whether to act. A wrong yes costs
* the user a banner they dismiss, because the restore itself waits for a click.
*
* ⚠️ `os.uptime()` reports the HOST's uptime, which a container shares. A Codeman
* running in Docker therefore sees a long uptime after its own container restarts,
* the boot test fails, and no banner appears. The feature is effectively off for
* containerized installs. That is the safe direction to fail in, and fixing it
* needs a boot signal the container actually owns rather than a wider heuristic.
*/
export function looksLikeHostReboot(evidence: RebootEvidence): boolean {
if (evidence.deadSessionCount === 0) return false;
@@ -80,7 +86,14 @@ export function resolveResumeConversationId(state: SessionState): string {
return chainTail || state.resumeSessionId || state.id;
}
/** Why one session was passed over. Reported for logging and assertions. */
/**
* Why one session was passed over. Reported for logging and shown to the user.
*
* The first six are decided before anything is built. `capacity-reached` and
* `rebuild-failed` can only happen once a click is spending the plan, and they
* are the two the banner must not confuse with a missing workspace: one means
* "try again after closing something", the other means the CLI would not start.
*/
export interface RebootRestoreRejection {
sessionId: string;
reason:
@@ -91,7 +104,10 @@ export interface RebootRestoreRejection {
| 'unsupported-mode'
| 'no-working-dir'
| 'workspace-missing'
| 'already-live';
| 'workspace-forbidden'
| 'already-live'
| 'capacity-reached'
| 'rebuild-failed';
}
/** One restorable session, as the banner shows it and the rebuild replays it. */
+10 -4
View File
@@ -3,10 +3,16 @@
*
* A session's `envOverrides` can hand back privilege that the per-CLI config
* clamp removed, so a non-granted owner's overrides get the privileged keys
* stripped before the session is built. Two callers need that today. The create
* and resume routes clamp what a request asked for, and the reboot-restore route
* clamps what a persisted record carried, because a record written while its
* owner held a grant must not replay that grant after the grant is gone.
* stripped before the session is built. The create and resume routes are what
* this bites on: they clamp what a request asked for.
*
* The reboot-restore route calls it as defence in depth, and today it can strip
* nothing. `Session.getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*` and
* `CLAUDE_CONFIG_DIR` out of a session's overrides, claude's `privilegedEnvKeys`
* are the five `ANTHROPIC_*` names, and that pass admits claude alone — so a
* persisted record cannot carry a clamped key. The call is there for the day the
* persisted set widens. The grant re-resolution that does bite on that path is
* `resolveClaudeModeForUsername`, which recomputes the permission mode.
*
* This lives outside `web/routes` on purpose. The question it answers is about
* session privilege rather than about HTTP, and `cron/cron-service.ts` sets the
+12
View File
@@ -4,6 +4,7 @@
*/
import type { Session } from '../../session.js';
import type { SessionState } from '../../types.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
@@ -12,5 +13,16 @@ export interface SessionPort {
setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void;
persistSessionStateNow(session: Session): void;
/**
* Re-apply the persisted state a freshly CONSTRUCTED session does not carry:
* the pin, token and cost totals, auto-compact, auto-clear, auto-resume, nice
* priority, the flicker filter and the custom-model selection.
*
* A `Session` built from a record holds only what its constructor takes, so
* persisting it would otherwise REPLACE the fuller record with the reduced one.
* Call this before the first persist, and before `startInteractive()`, because
* the custom-model selection has to reach the pane's environment.
*/
reapplyPersistedSessionState(session: Session, saved: SessionState): Promise<void>;
getSessionStateWithRespawn(session: Session): unknown;
}
+9 -1
View File
@@ -957,7 +957,9 @@ class CodemanApp {
this.registerServiceWorker();
// Fetch tunnel status for header indicator (desktop only)
this.loadTunnelStatus();
// Ask whether a host reboot left sessions worth rebuilding (banner, never automatic)
// Ask whether a host reboot left sessions worth rebuilding (banner, never
// automatic). handleInit() re-reads it on every SSE init; this covers the
// path where that event never arrives.
this.initRebootRestoreBanner?.();
// Share a single settings fetch between both consumers
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
@@ -3761,6 +3763,12 @@ class CodemanApp {
// a fresh load / reconnect (authoritative; wins over the localStorage restore).
if (data.planUsage) this.updatePlanUsageChip(data.planUsage);
// A board left open across a host reboot reconnects HERE, to a server that came
// back with an empty session list. The reboot-restore offer is built at boot,
// before any client could be listening, so re-read it on every init rather than
// only on the page-load path.
this.refreshRebootRestoreBanner?.();
// Update version displays (header and toolbar)
if (data.version) {
const versionEl = this.$('versionDisplay');
+35
View File
@@ -3240,6 +3240,41 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
already reserves that space), so it needs the same safe-area padding as the
other banners. The overlay is fixed and handles its own insets.
============================================================================ */
@media (max-width: 599px) {
/* Reboot-restore banner: the same treatment as the offline banner below. Its
text and note are nowrap and the two buttons cannot shrink, so without this
the actions are pushed off a phone-width viewport and become unreachable. */
.reboot-restore-banner {
padding: 0.4rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.7rem;
gap: 0.4rem;
}
/* The session names and the scrollback note are the first things to go. The
count plus the two buttons carry the message on their own, and the note
survives as the accept button's title. */
.reboot-restore-banner-detail,
.reboot-restore-banner-note {
display: none;
}
.reboot-restore-banner-text {
overflow: hidden;
text-overflow: ellipsis;
}
.reboot-restore-banner-accept,
.reboot-restore-banner-dismiss {
padding: 0.25rem 0.5rem;
}
.reboot-restore-banner-accept {
margin-left: auto;
}
}
@media (max-width: 599px) {
.offline-banner {
padding: 0.4rem 0.5rem;
+39 -4
View File
@@ -8,7 +8,9 @@
* reboot guess is a heuristic and a wrong automatic restore would spawn CLI
* processes nobody asked for.
*
* Seeded once from `GET /api/reboot-restore` on init. Restore posts to
* Seeded from `GET /api/reboot-restore` on init and again on every SSE reconnect,
* because the tab most likely to want this is one that was open across the reboot
* and reconnects to a server that came back up with an empty board. Restore posts to
* `POST /api/reboot-restore/restore`, Dismiss posts to
* `POST /api/reboot-restore/dismiss`, and either way the banner goes away. The
* restored sessions arrive as ordinary `session:created` events, so no extra
@@ -25,6 +27,24 @@
* @loadorder 11.7 of 17, after approvals-ui.js
*/
/** Plain-language wording for one skip reason, for the toast after a restore. */
function rebootSkipReason(reason) {
switch (reason) {
case 'workspace-missing':
return 'workspace is gone';
case 'workspace-forbidden':
return 'workspace is outside your space';
case 'already-live':
return 'already open';
case 'capacity-reached':
return 'session limit reached';
case 'rebuild-failed':
return 'the agent would not start';
default:
return reason;
}
}
Object.assign(CodemanApp.prototype, {
/** Ask the server whether a reboot left anything on offer, and show the banner if so. */
async initRebootRestoreBanner() {
@@ -59,6 +79,9 @@ Object.assign(CodemanApp.prototype, {
detail.textContent = count > 4 ? `${names}, …` : names;
detail.title = sessions.map((s) => `${s.name || s.id}\n${s.workingDir}`).join('\n\n');
}
const accept = this.$('rebootRestoreBannerAccept');
// The note is hidden at phone width, so the warning travels on the button too.
if (accept) accept.title = 'Conversations return; terminal history does not.';
banner.hidden = false;
},
@@ -66,8 +89,9 @@ Object.assign(CodemanApp.prototype, {
async restoreRebootSessions() {
const button = this.$('rebootRestoreBannerAccept');
if (button) button.disabled = true;
const res = await this._apiPost('/api/reboot-restore/restore', {});
const body = res && res.ok ? await res.json().catch(() => null) : null;
// _apiJson unwraps the { success, data } envelope every /api response carries;
// reading the outer object would report every count as zero.
const body = await this._apiJson('/api/reboot-restore/restore', { method: 'POST', body: {} });
if (!body) {
if (button) button.disabled = false;
this.showToast?.('Could not restore the sessions', 'error');
@@ -82,10 +106,21 @@ Object.assign(CodemanApp.prototype, {
this.showToast?.(`Restored ${restored} ${noun}. Terminal history did not survive the reboot.`, 'success');
}
if (skipped > 0) {
this.showToast?.(`${skipped} could not be restored (workspace gone, or already open)`, 'warning');
// Each reason means a different next step for the user, so they are not
// collapsed into one message: capacity clears by closing something, a
// failed start usually means the CLI is not on the server's PATH.
const reasons = new Set((body.skipped ?? []).map((s) => s.reason));
this.showToast?.(`${skipped} not restored: ${[...reasons].map(rebootSkipReason).join('; ')}`, 'warning');
}
},
/** Re-read the offer after a reconnect, for a tab that was open across the reboot. */
async refreshRebootRestoreBanner() {
const data = await this._apiJson('/api/reboot-restore');
this._rebootRestoreSessions = data?.sessions ?? [];
this.renderRebootRestoreBanner();
},
/** Drop the offer. The Resume list still reaches every one of these conversations. */
async dismissRebootRestore() {
this._rebootRestoreSessions = [];
+24 -15
View File
@@ -24,8 +24,9 @@
* - Spending is take-then-build: `take()` removes entries synchronously, before
* the route's first `await`, so a double-click or two devices cannot both
* reach the same entry and put two panes on one conversation.
* - One restore runs at a time. `beginSpending()` single-flights the route, so
* two concurrent clicks cannot interleave pane creation.
* - One restore runs at a time per owner. `beginSpending()` single-flights the
* route, so two concurrent clicks cannot interleave pane creation for the same
* user, while two different users never block each other.
*
* @dependencies reboot-restore (RebootRestoreEntry)
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
@@ -46,8 +47,13 @@ export class RebootRestoreRegistry {
private entries = new Map<string, RebootRestoreEntry>();
/** When the boot pass built the plan, in ms since the epoch. */
private builtAt = 0;
/** True while a restore route call is between its take and its last pane. */
private spending = false;
/**
* Owners with a restore in flight, between its take and its last pane.
* Keyed by owner so one user's restore does not turn another user's click into
* a conflict; `take()` already guarantees no two callers get the same entry.
* Single-user mode has one key, `undefined`, so it behaves as one global flight.
*/
private spending = new Set<string | undefined>();
/** Replace the plan with what the boot pass found. An empty list clears it. */
set(entries: readonly RebootRestoreEntry[]): void {
@@ -92,9 +98,11 @@ export class RebootRestoreRegistry {
/**
* Put entries back after a rebuild never got as far as creating a pane.
*
* Used for the click-time rejections, so a conversation the user resumed by
* hand meanwhile does not silently vanish from the banner while a workspace
* that came back stays offered.
* Used for the click-time rejections that may resolve themselves: a workspace
* that comes back, a capacity limit the user makes room under, a CLI that
* starts once its binary is on the PATH. A conversation the user resumed by
* hand is NOT put back, because that one cannot stop being true, and an entry
* the banner keeps re-offering forever is noise only Dismiss can clear.
*/
restore(entries: readonly RebootRestoreEntry[]): void {
for (const entry of entries) this.entries.set(entry.sessionId, entry);
@@ -110,24 +118,25 @@ export class RebootRestoreRegistry {
}
/**
* Claim the right to run a restore, or report that one is already running.
* Callers that get `true` must call `endSpending()` in a `finally`.
* Claim the right to run a restore for one owner, or report that owner already
* has one running. Callers that get `true` must call `endSpending()` in a
* `finally` with the same owner.
*/
beginSpending(): boolean {
if (this.spending) return false;
this.spending = true;
beginSpending(owner?: string): boolean {
if (this.spending.has(owner)) return false;
this.spending.add(owner);
return true;
}
endSpending(): void {
this.spending = false;
endSpending(owner?: string): void {
this.spending.delete(owner);
}
/** Test hook: forget everything, including the single-flight claim. */
reset(): void {
this.entries.clear();
this.builtAt = 0;
this.spending = false;
this.spending.clear();
}
private dropIfExpired(): void {
+63 -9
View File
@@ -27,7 +27,14 @@ import { FastifyInstance } from 'fastify';
import { existsSync } from 'node:fs';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { RebootRestoreRequestSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned } from '../route-helpers.js';
import {
parseBody,
getAuthUser,
canAccessOwned,
ownerFor,
isWorkingDirAllowed,
sessionCapacityMessage,
} from '../route-helpers.js';
import { rebootRestoreRegistry } from '../reboot-restore-registry.js';
import { rejectAlreadyLive, type RebootRestoreEntry, type RebootRestoreRejection } from '../../reboot-restore.js';
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
@@ -76,38 +83,65 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
app.post('/api/reboot-restore/restore', async (req, reply) => {
const body = parseBody(RebootRestoreRequestSchema, req.body, 'Invalid reboot restore request');
const user = getAuthUser(req);
const canAccess = accessorFor(req);
const owner = ownerFor(req);
// Take BEFORE the first await: a second click must find nothing to spend.
if (!rebootRestoreRegistry.beginSpending()) {
// The flight is per owner, because `take()` already guarantees two callers
// never receive the same entry, so one user's restore need not block another's.
if (!rebootRestoreRegistry.beginSpending(owner)) {
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, 'A reboot restore is already running'));
}
const taken = rebootRestoreRegistry.take(canAccess, body.sessionIds);
// Entries nothing built a pane for, returned to the plan on every exit path
// including a throw. Without this a failure between here and the loop would
// spend the offer and rebuild nothing, and the plan cannot be rebuilt.
const unspent = new Set(taken);
try {
if (taken.length === 0) return { restored: [], skipped: [] };
// The plan was built at boot and the board has moved on since. A conversation
// the user resumed by hand from the Resume list is already on screen, and a
// second pane on it would fight the first for the same transcript.
// second pane on it would fight the first for the same transcript. This one
// is never re-offered: unlike a missing workspace, it cannot stop being true.
const liveSessionIds = new Set(ctx.sessions.keys());
const liveConversationIds = new Set(
[...ctx.sessions.values()].map((session) => session.claudeSessionId).filter((id): id is string => !!id)
);
const { restore, skipped } = rejectAlreadyLive(taken, liveSessionIds, liveConversationIds);
// An entry nothing rebuilt stays on offer rather than disappearing silently.
rebootRestoreRegistry.restore(skipped.map((s) => taken.find((e) => e.sessionId === s.sessionId)!));
for (const entry of taken) {
if (skipped.some((s) => s.sessionId === entry.sessionId)) unspent.delete(entry);
}
const restored: ReturnType<typeof toBannerItem>[] = [];
const failures: RebootRestoreRejection[] = [...skipped];
const workspaceHooksEnabled = await ctx.getWorkspaceHooksEnabled();
for (const entry of restore) {
// Capacity is re-checked per iteration, because this loop is itself
// creating the sessions it counts. The offer can be a day old, so the
// board may be fuller now than the plan assumed.
const capMsg = sessionCapacityMessage(ctx.sessions, entry.owner);
if (capMsg) {
failures.push({ sessionId: entry.sessionId, reason: 'capacity-reached' });
continue;
}
// A repo can be deleted between the boot that planned this and the click.
if (!existsSync(entry.workingDir)) {
failures.push({ sessionId: entry.sessionId, reason: 'workspace-missing' });
continue;
}
// Multi-user workspace separation: the create route confines a non-admin's
// workingDir to their own case space, and a grant can be withdrawn between
// the session's creation and this restore, so the confinement is re-run
// rather than inherited from the record.
if (!isWorkingDirAllowed(user, entry.workingDir)) {
failures.push({ sessionId: entry.sessionId, reason: 'workspace-forbidden' });
unspent.delete(entry);
continue;
}
try {
const saved = entry.state;
const claudeModeConfig = await ctx.getClaudeModeConfig();
@@ -145,9 +179,14 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
});
await ctx.addSession(session);
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
// Before the pane spawns: the custom-model selection reaches it through
// the environment. Before the first persist: a constructed session holds
// none of this, so persisting it first would replace the fuller record
// with the reduced one and drop the pin that keeps it from being pruned.
await ctx.reapplyPersistedSessionState(session, saved);
await session.startInteractive();
ctx.persistSessionState(session);
// A session without its workspace hooks goes silently blind: no stop or
// idle events for respawn, no Approvals Inbox item, no red tab on a
@@ -166,10 +205,22 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
restored.push(toBannerItem(entry));
} catch (err) {
// One workspace that has gone missing must not stop the rest of the pass.
// One entry that will not start must not stop the rest of the pass, and
// must not leave a registered session with no pane behind it: by this
// point the session is in `ctx.sessions`, holds a tab-layout slot and has
// listeners, and the commonest cause is a CLI binary that is not on the
// PATH of a freshly booted machine.
console.error(`[reboot-restore] failed to rebuild ${entry.sessionId}:`, err);
failures.push({ sessionId: entry.sessionId, reason: 'workspace-missing' });
await ctx
.cleanupSession(entry.sessionId, true, 'reboot restore failed to start the session')
.catch((cleanupErr: unknown) =>
console.error(`[reboot-restore] cleanup after a failed rebuild failed: ${getErrorMessage(cleanupErr)}`)
);
failures.push({ sessionId: entry.sessionId, reason: 'rebuild-failed' });
// Left on offer: the user can put the binary back and click again.
continue;
}
unspent.delete(entry);
}
if (restored.length > 0) {
@@ -181,7 +232,10 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
return { restored, skipped: failures };
} finally {
rebootRestoreRegistry.endSpending();
// Anything that never became a pane goes back on offer, including after a
// throw, so a transient failure costs a retry rather than the whole plan.
rebootRestoreRegistry.restore([...unspent]);
rebootRestoreRegistry.endSpending(owner);
}
});
+48
View File
@@ -671,6 +671,7 @@ export class WebServer extends EventEmitter {
setupSessionListeners: this.setupSessionListeners.bind(this),
persistSessionState: this.persistSessionState.bind(this),
persistSessionStateNow: this._persistSessionStateNow.bind(this),
reapplyPersistedSessionState: this.reapplyPersistedSessionState.bind(this),
getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this),
// EventPort
broadcast: this.broadcast.bind(this),
@@ -2907,6 +2908,53 @@ export class WebServer extends EventEmitter {
return restore.length;
}
/**
* Re-apply the persisted state that a `Session` constructor does not take.
*
* The reboot-restore route builds a session from a record rather than
* attaching to a surviving pane, so everything the constructor has no
* parameter for starts at its default. Persisting such a session writes
* `toState()` wholesale, which would REPLACE the record with the reduced
* version — and for a pinned session that is worse than losing a setting,
* because `cleanupSessionsByIds()` keeps a record only while it is pinned, so
* dropping the pin hands the record to the next stale sweep.
*
* Respawn and Ralph are deliberately NOT re-armed here: a machine that just
* came up is the worst moment to turn an autonomous run loose, and the user
* re-arms what they want.
*/
async reapplyPersistedSessionState(session: Session, saved: SessionState): Promise<void> {
// The custom-model env has to be rebuilt from the endpoint store: the persist
// deliberately keeps the injected VALUES out of state.json, so only the
// bookkeeping survives a restart and the values are re-derived here.
const savedCustomModel = (saved as { __customModel?: CustomModelBookkeeping }).__customModel;
if (savedCustomModel) {
session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
}
if (saved.pinned) session.setPinned(true);
if (saved.autoCompactEnabled !== undefined || saved.autoCompactThreshold !== undefined) {
session.setAutoCompact(saved.autoCompactEnabled ?? false, saved.autoCompactThreshold, saved.autoCompactPrompt);
}
if (saved.autoClearEnabled !== undefined || saved.autoClearThreshold !== undefined) {
session.setAutoClear(saved.autoClearEnabled ?? false, saved.autoClearThreshold);
}
if (saved.autoResumeEnabled) {
session.restoreAutoResume(true, saved.autoResumeAt);
}
if (saved.inputTokens !== undefined || saved.outputTokens !== undefined || saved.totalCost !== undefined) {
session.restoreTokens(saved.inputTokens ?? 0, saved.outputTokens ?? 0, saved.totalCost ?? 0);
// Seed the daily-usage baseline, or the restored totals are counted again as new usage.
this.lastRecordedTokens.set(session.id, {
input: saved.inputTokens ?? 0,
output: saved.outputTokens ?? 0,
});
}
if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
}
if (saved.flickerFilterEnabled !== undefined) session.flickerFilterEnabled = saved.flickerFilterEnabled;
}
private async restoreMuxSessions(): Promise<boolean> {
try {
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)