Merge origin/master into feat/remote-host-wake

Resolves CLAUDE.md count tables (route counts recounted on the merged
tree: 235 handlers, sessions 37) and keeps both the host-wake and the
reboot-restore banner in index.html.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
This commit is contained in:
Randalix
2026-09-18 22:20:41 +02:00
co-authored by Claude Opus 5
77 changed files with 5422 additions and 368 deletions
+251 -1
View File
@@ -39,7 +39,9 @@ import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
import { hostname as getHostname, uptime as osUptime } from 'node:os';
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
import type { RemoteWakeRegistry } from '../remote-wake.js';
@@ -173,6 +175,7 @@ import {
registerScheduledRoutes,
registerHookEventRoutes,
registerApprovalRoutes,
registerRebootRestoreRoutes,
registerReadMyMindRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
@@ -678,6 +681,8 @@ 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),
discardPartiallyBuiltSession: this.discardPartiallyBuiltSession.bind(this),
getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this),
// EventPort
broadcast: this.broadcast.bind(this),
@@ -1070,6 +1075,7 @@ export class WebServer extends EventEmitter {
registerScheduledRoutes(this.app, ctx);
registerHookEventRoutes(this.app, ctx);
registerApprovalRoutes(this.app, ctx);
registerRebootRestoreRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
@@ -1745,6 +1751,10 @@ export class WebServer extends EventEmitter {
getStore: () => this.store,
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
this.registerAttachment(id, filePath, source),
updateSessionName: (id: string, name: string) => this.mux.updateSessionName(id, name),
// Opt-in: the first prompt lands in the tab name, mux-sessions.json, every
// session:updated broadcast and /api/search, so it is a choice, not a default.
isAutoNameEnabled: async () => (await this.readSettings()).autoNameSessions === true,
};
}
@@ -2870,6 +2880,229 @@ export class WebServer extends EventEmitter {
return false;
}
/**
* Work out what a host reboot destroyed, and leave it on offer for the board.
*
* Runs inside `restoreMuxSessions()`, in the window after `reconcileSessions()`
* has reported the dead sessions and before `finalizeRestoredState()` prunes
* their records, so `state.json` is still the full picture here. That window is
* the only place the plan can be built, which is why the boot pass builds it
* even though nothing is rebuilt until a user clicks.
*
* Nothing is created here. The plan goes to `rebootRestoreRegistry`, the board
* offers it as a banner, and `web/routes/reboot-restore-routes` rebuilds what
* the user asks for. A wrong reboot guess therefore costs a line of text the
* user dismisses, not N CLI processes nobody asked for.
*
* @returns how many sessions are on offer.
*/
private planRebootRestoreOffer(dead: string[], livePaneCount: number): number {
if (dead.length === 0) return 0;
const persisted = this.store.getSessions();
if (
!looksLikeHostReboot({
livePaneCount,
deadSessionCount: dead.length,
uptimeSeconds: osUptime(),
newestPersistedActivityAt: newestPersistedActivity(persisted),
now: Date.now(),
})
) {
return 0;
}
const { restore, skipped } = planRebootRestore(dead, persisted, (workingDir) => existsSync(workingDir));
if (skipped.length > 0) {
console.log(`[Server] Reboot restore is passing over ${skipped.length} dead session(s):`);
for (const rejection of skipped) {
console.log(`[Server] ${rejection.sessionId}: ${rejection.reason}`);
}
}
rebootRestoreRegistry.set(restore);
if (restore.length > 0) {
console.log(`[Server] Host reboot detected; offering ${restore.length} session(s) for restore`);
}
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.
*
* Split in two phases because the two halves have opposite timing needs:
*
* - `before-spawn` shapes the pane itself, so it has to land before the CLI
* process starts, and before `setupSessionListeners()`, which reads the
* image-watcher flag. The custom-model selection is an environment injection
* and the nice priority is applied to the spawn.
* - `after-spawn` is the session's own accumulated history. It must NOT land
* on a session whose pane failed to start: the totals would then belong to a
* session that never ran, and any later cleanup would add them to the
* lifetime figures a second time.
*
* Respawn and Ralph are deliberately NOT re-armed: a machine that just came up
* is the worst moment to turn an autonomous run loose, and the user re-arms
* what they want. Ralph's loop CONFIGURATION does not survive either, because
* `toState()` reads `ralphEnabled` and the completion phrase off a live
* tracker, and there is no way to hold them without arming the loop.
*/
async reapplyPersistedSessionState(
session: Session,
saved: SessionState,
phase: 'before-spawn' | 'after-spawn',
options?: { rearmAutoResumeSchedule?: boolean }
): Promise<void> {
if (phase === 'before-spawn') {
// 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.niceEnabled !== undefined || saved.niceValue !== undefined) {
session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
}
// `setupSessionListeners()` READS this flag to decide whether to start the
// watcher, so setting it later would leave the session reporting the feature
// as on with nothing watching.
if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled;
return;
}
if (saved.pinned) session.restorePin(true, saved.pinnedAt);
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) {
// The stamp is re-armed by default, because a Codeman restart leaves the
// limit footer un-reprinted and dropping it there would strand the pause.
// A reboot restore opts out: that stamp predates the reboot, the pane is
// new, and honouring it means every session the user restored types
// `continue` into itself about a minute later, unattended. The setting
// itself stays on either way, so it re-arms on the next limit message.
const rearm = options?.rearmAutoResumeSchedule !== false;
session.restoreAutoResume(true, rearm ? saved.autoResumeAt : undefined);
}
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.color) session.setColor(saved.color);
if (saved.flickerFilterEnabled !== undefined) session.flickerFilterEnabled = saved.flickerFilterEnabled;
}
/**
* Undo a session that was registered but never got a working pane.
*
* Deliberately NOT `cleanupSession()`, which is the user-initiated delete: that
* path adds the session's token totals to the lifetime figures, demotes a
* pinned record to `stopped` (the durable marker of an intentional kill, which
* would make the session permanently ineligible for a reboot restore), drops
* the persisted Ralph state, and recursively removes `.claude-images` from the
* WORKING DIRECTORY, which belongs to the workspace rather than to this session
* and may hold another live session's pasted images.
*
* Everything else `_doCleanupSession()` does, this has to do as well. It is the
* inverse of `registerSessionWithLayout()` plus `setupSessionListeners()`, and
* every registration those two make has to come back out — above all
* `sessionListenerRefs`, whose presence makes `setupSessionListeners()` return
* early. Leaving that entry behind is worse than the leak this function exists
* to prevent: the retry reuses the same session id, wires no listeners at all,
* and the user gets a tab that never shows output.
*
* The persisted record, the lifetime totals, the stored Ralph state and the
* workspace's own files are left exactly as they were, so the session stays
* restorable on the next attempt.
*/
async discardPartiallyBuiltSession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.sessions.delete(sessionId);
// --- the inverse of setupSessionListeners(), in reverse order ---
// Listeners first: while they are attached, one of them can still reach a
// tracker this is about to stop.
const listeners = this.sessionListenerRefs.get(sessionId);
if (listeners) {
detachSessionListeners(session, listeners);
this.sessionListenerRefs.delete(sessionId);
}
// An FSWatcher on the workspace that nothing else closes.
imageWatcher.unwatchSession(sessionId);
// An fs.watch on the workspace (or on @fix_plan.md), likewise.
session.ralphTracker.stopWatchingFixPlan();
const summaryTracker = this.runSummaryTrackers.get(sessionId);
if (summaryTracker) {
// Closes the run's own record before the tracker goes, the way
// `_doCleanupSession()` does. Cosmetic rather than load-bearing, but a
// run left open reads as still going in the away digest.
summaryTracker.recordSessionStopped();
summaryTracker.stop();
this.runSummaryTrackers.delete(sessionId);
}
// Also mirrors `_doCleanupSession()`. The PERSISTED Ralph state is left
// alone on purpose (that is one of the things separating this from
// cleanupSession); this only clears the in-memory tracker the failed
// construction built, which the retry reuses the id of.
session.ralphTracker.fullReset();
// --- what anything else may have attached to this id in the meantime ---
// A rebuild can fail AFTER startInteractive() resolved, and a restored
// workspace still carries Codeman's hooks, so the CLI can post a hook event
// within milliseconds. Each of these outlives the listeners and would
// otherwise meet the retry, which reuses the same session id by design.
this.stopTranscriptWatcher(sessionId);
attachmentRegistry.clearSession(sessionId);
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
// --- the inverse of the construction itself ---
this.sse.cleanupSessionBatches(sessionId);
this.persistDeb.cancelKey(sessionId);
fileStreamManager.closeSessionStreams(sessionId);
// `lastRecordedTokens` is deliberately NOT deleted: the `after-spawn` phase
// seeds it as the daily-usage baseline for these restored totals, and the
// retry reuses the id, so dropping it would count them as new usage.
// The per-session custom-model config dir carries the endpoint's API key, and
// `before-spawn` may already have written it. Nothing else would ever remove
// it: the stale sweep only touches state.json. A retry rewrites it.
removeConfigDir(customModelConfigDir(sessionId));
try {
session.removeAllListeners();
await session.stop(true);
} catch (err) {
console.warn(`[Server] stopping a partially built session failed: ${getErrorMessage(err)}`);
// `stop()` kills the mux session in its last block, after destroying its
// trackers, so a throw on the way there leaves the pane running.
await this.mux.killSession(sessionId).catch(() => {});
}
try {
await this.tabLayouts.sessionsRemoved([{ id: sessionId, owner: session.owner }]);
} catch (err) {
console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`);
}
// Any `session:updated` the half-built session emitted before it failed left a
// tab on every other open board, and the client's handler is an upsert.
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
}
private async restoreMuxSessions(): Promise<boolean> {
try {
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
@@ -2879,6 +3112,22 @@ export class WebServer extends EventEmitter {
console.log(`[Server] Discovered ${discovered.length} unknown mux session(s)`);
}
// Build the reboot-restore offer HERE: `dead` is only known after
// reconciliation, and the records it reads are pruned by
// `cleanupStaleSessions()` as soon as `finalizeRestoredState()` runs.
//
// Guarded on its own, because this runs inside the try that decides whether
// RECOVERY succeeded. A throw here would otherwise be caught below, report
// restoration as failed, and block the stale cleanup and layout
// reconciliation that follow — turning an optional convenience into a
// failure of the thing it is supposed to help. An offer nobody gets is the
// correct way for this to fail.
try {
this.planRebootRestoreOffer(dead, alive.length);
} catch (err) {
console.error('[Server] Building the reboot-restore offer failed; continuing recovery:', err);
}
if (alive.length > 0 || discovered.length > 0) {
console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`);
@@ -2923,6 +3172,7 @@ export class WebServer extends EventEmitter {
workingDir: muxSession.workingDir,
mode: muxSession.mode,
name: sessionName,
nameSource: savedState?.nameSource,
// When the session FIRST started, not when this server booted.
// Without it every recovered session was restamped `Date.now()` on
// each restart, so a week-old pane read as "created 2m ago" on the