mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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:
@@ -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,40 @@ 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.
|
||||
*
|
||||
* 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.
|
||||
* Two phases: `before-spawn` shapes the pane (the custom-model environment and
|
||||
* the nice priority) and must precede `startInteractive()`; `after-spawn` is
|
||||
* the session's own history (the pin, token and cost totals, auto-compact,
|
||||
* auto-clear, auto-resume, colour, image watcher, flicker filter) and must NOT
|
||||
* land on a session whose pane failed to start.
|
||||
*/
|
||||
reapplyPersistedSessionState(
|
||||
session: Session,
|
||||
saved: SessionState,
|
||||
phase: 'before-spawn' | 'after-spawn',
|
||||
options?: {
|
||||
/**
|
||||
* Re-arm a PENDING auto-resume schedule from the record's `autoResumeAt`.
|
||||
* Default true, which is what a Codeman restart wants: the limit footer
|
||||
* will not reprint on its own, so dropping the stamp there strands the
|
||||
* pause. A reboot restore passes false: the stamp predates the reboot,
|
||||
* the pane is new, and re-arming means every restored session types
|
||||
* `continue` into itself about a minute after one click. Auto-resume
|
||||
* stays ENABLED either way, so it re-arms on fresh evidence.
|
||||
*/
|
||||
rearmAutoResumeSchedule?: boolean;
|
||||
}
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Undo a session that was registered but never got a working pane: the map
|
||||
* entry, its tab-layout slot, and any pane the launch created before throwing.
|
||||
* Unlike {@link cleanupSession} it leaves the persisted record, the lifetime
|
||||
* token totals, the Ralph state and the workspace's own files untouched.
|
||||
*/
|
||||
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
|
||||
getSessionStateWithRespawn(session: Session): unknown;
|
||||
}
|
||||
|
||||
+108
-6
@@ -958,6 +958,10 @@ 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). 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);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
@@ -1910,6 +1914,53 @@ class CodemanApp {
|
||||
this._onSessionClearTerminal(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a buffer load that just fetched `payload` must end.
|
||||
*
|
||||
* A tmux pane capture is a point-in-time frame, so nothing that reached the
|
||||
* browser after the response headers can already be in it. Such a load
|
||||
* replays exactly that tail; discarding it drops the CLI's output for the
|
||||
* rest of the load window, and its next partial redraw then lands on a frame
|
||||
* the terminal never received.
|
||||
*
|
||||
* A `history` payload is the server's byte buffer alone: the direct-PTY
|
||||
* fallback, or a mux pane whose capture came back empty. The route reads
|
||||
* that buffer in the same synchronous tick it takes the capture, so it is
|
||||
* current up to the route's own read and no further, which is the same
|
||||
* exposure. It deliberately keeps the pre-existing discard all the same:
|
||||
* both cases are rare, neither has been measured, and a duplicated Ink
|
||||
* redraw is more visible than a few milliseconds of missing output.
|
||||
* `capturedFromMux` below is the one line to widen if either turns out to
|
||||
* matter.
|
||||
*
|
||||
* `headersReceivedAt` is the caller's own `performance.now()` reading from
|
||||
* the moment the response arrived, compared only against other client-side
|
||||
* readings, so there is no clock skew to worry about.
|
||||
*
|
||||
* What this cutoff does NOT cover, and there are two contributors. The
|
||||
* server appends output to the byte buffer and emits it in the same tick,
|
||||
* but BROADCASTS on a batch timer (8ms over WebSocket, 16 to 50ms over SSE),
|
||||
* and the terminal route runs synchronously from `capture-pane` to its
|
||||
* return, so a batch already pending when the capture ran leaves the server
|
||||
* after the reply, arrives after `headersReceivedAt`, and is replayed
|
||||
* although the capture holds it. Separately, `captureActivePaneBuffer` is
|
||||
* `execSync`, which blocks the event loop for the whole capture: anything
|
||||
* tmux had already painted into the pane that the server had not yet read
|
||||
* from the attach PTY is in the capture too, is broadcast only after the
|
||||
* reply, and replays the same way. The duplicate is one batch interval plus
|
||||
* one capture wide, against a recovery window that spans the whole chunked
|
||||
* write. Closing it belongs on the server: flush that session's pending
|
||||
* batch before taking the capture.
|
||||
*
|
||||
* @param {{source?: string}} payload - The parsed `data` of a terminal response.
|
||||
* @param {number} headersReceivedAt - When that response reached this client.
|
||||
* @returns {{flushQueued: boolean, since: number}} Options for `_finishBufferLoad`.
|
||||
*/
|
||||
_bufferLoadFinishOpts(payload, headersReceivedAt) {
|
||||
const capturedFromMux = payload?.source === 'mux-visible' || payload?.source === 'mux-full-history';
|
||||
return { flushQueued: capturedFromMux, since: headersReceivedAt };
|
||||
}
|
||||
|
||||
_onSessionTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
|
||||
@@ -1919,7 +1970,7 @@ class CodemanApp {
|
||||
// jump over the cap. Dropped data is recovered from the canonical buffer.
|
||||
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
|
||||
+ (this.flickerFilterBuffer?.length || 0)
|
||||
+ (this._loadBufferQueue?.reduce((s, w) => s + w.length, 0) || 0)
|
||||
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
|
||||
+ (this._terminalWriteInFlightBytes || 0);
|
||||
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
|
||||
// Schedule a self-recovery once the
|
||||
@@ -2502,9 +2553,11 @@ class CodemanApp {
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
let headersReceivedAt = performance.now();
|
||||
let data = (await res.json())?.data ?? {};
|
||||
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = performance.now();
|
||||
data = (await res.json())?.data ?? {};
|
||||
}
|
||||
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||
@@ -2520,7 +2573,12 @@ class CodemanApp {
|
||||
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||||
await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
undefined,
|
||||
this._bufferLoadFinishOpts(data, headersReceivedAt)
|
||||
);
|
||||
// A tail fetch can be partial, and the banner would otherwise keep
|
||||
// describing the pre-refresh buffer (#258).
|
||||
this._setHistoryTruncation(sessionId, data);
|
||||
@@ -2530,6 +2588,10 @@ class CodemanApp {
|
||||
});
|
||||
if (target === null || typeof this.terminal.scrollToLine !== 'function') this.terminal.scrollToBottom();
|
||||
else this.terminal.scrollToLine(target);
|
||||
// The load's own replay sampled the sticky-scroll baseline while the
|
||||
// terminal sat at the bottom of a just-rewritten buffer, so the next
|
||||
// flush would scroll back down and undo the restore above.
|
||||
this._syncStickyScrollBaseline();
|
||||
// Re-position local echo overlay at new prompt location
|
||||
this._localEchoOverlay?.rerender();
|
||||
// Resize PTY to match actual browser dimensions (critical for OpenCode
|
||||
@@ -2556,6 +2618,7 @@ class CodemanApp {
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const termData = (await res.json())?.data ?? {};
|
||||
|
||||
this.terminal.clear();
|
||||
@@ -2565,7 +2628,12 @@ class CodemanApp {
|
||||
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
|
||||
const cleanBuffer = termData.terminalBuffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||
// Use chunked write to avoid UI freeze with large buffers (can be 1-2MB)
|
||||
await this.chunkedTerminalWrite(cleanBuffer);
|
||||
await this.chunkedTerminalWrite(
|
||||
cleanBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
undefined,
|
||||
this._bufferLoadFinishOpts(termData, headersReceivedAt)
|
||||
);
|
||||
}
|
||||
|
||||
// Fire-and-forget resize — don't block on it
|
||||
@@ -3763,6 +3831,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');
|
||||
@@ -5856,7 +5930,12 @@ class CodemanApp {
|
||||
parsedAt,
|
||||
bufferLength: parsedBufferLength,
|
||||
completed,
|
||||
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
} = await this.chunkedTerminalWrite(
|
||||
buffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
sessionId,
|
||||
this._bufferLoadFinishOpts(payload, headersReceivedAt)
|
||||
);
|
||||
timing.resetAndParseMs = parsedAt - replayStartedAt;
|
||||
if (!completed || this.activeSessionId !== sessionId) return;
|
||||
// Keep shell tab restores bounded too. A user-triggered full-history pull
|
||||
@@ -5874,6 +5953,12 @@ class CodemanApp {
|
||||
const delta = parsedBufferLength - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
// The load's own replay sampled the sticky-scroll baseline while the
|
||||
// terminal sat at the bottom of a just-rewritten buffer, so the next
|
||||
// flush would scroll back down and undo the restore above. This path is
|
||||
// reached only from a scroll-up gesture, so being dragged down is the
|
||||
// exact opposite of what the user asked for.
|
||||
this._syncStickyScrollBaseline();
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
} catch {
|
||||
@@ -6318,6 +6403,15 @@ class CodemanApp {
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
// How this load must end, decided here because `chunkedTerminalWrite` is
|
||||
// what actually ends it for a non-empty buffer. A tmux pane capture is a
|
||||
// point-in-time frame, so nothing that reached the browser after the
|
||||
// response headers can already be in it. Replay exactly that tail;
|
||||
// discarding it drops the CLI's output for the rest of the load window,
|
||||
// and its next partial redraw then lands on a frame the terminal never
|
||||
// received. `since` keeps the pre-capture events dropped, because the
|
||||
// capture does hold those and replaying them would duplicate output.
|
||||
const finishOpts = this._bufferLoadFinishOpts(data, headersReceivedAt);
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
let freshResetAndParseMs = 0;
|
||||
@@ -6344,7 +6438,8 @@ class CodemanApp {
|
||||
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
bufferLoadOwner,
|
||||
finishOpts
|
||||
);
|
||||
freshResetAndParseMs = freshParsedAt - replayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
@@ -6394,7 +6489,14 @@ class CodemanApp {
|
||||
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
||||
// discarding — a new session's prompt arrives only as a queued SSE event.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty });
|
||||
// Only reached when the write was skipped. COD-144 lives here: a new
|
||||
// session's first prompt exists only as a queued event that predates the
|
||||
// response, so an empty paint replays its queue WHOLE rather than from
|
||||
// the header timestamp.
|
||||
this._finishBufferLoad(
|
||||
bufferLoadOwner,
|
||||
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
|
||||
);
|
||||
}
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
'Ultracode Agents': 'Ultracode 智能体',
|
||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||
'Approvals Inbox': '审批收件箱',
|
||||
'Auto-name Sessions': '自动命名会话',
|
||||
Approvals: '审批',
|
||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||
'No pending approvals': '没有待处理的审批',
|
||||
|
||||
@@ -225,6 +225,24 @@
|
||||
<button class="offline-banner-retry" id="hostWakeBannerAction" onclick="app.hostWakeAction()">Wake</button>
|
||||
</div>
|
||||
|
||||
<!-- Reboot-restore offer: shown when the server found sessions a host reboot
|
||||
killed and is asking whether to rebuild them. Populated by
|
||||
reboot-restore-ui.js; nothing is created until the user clicks. -->
|
||||
<div class="reboot-restore-banner" id="rebootRestoreBanner" role="status" hidden>
|
||||
<span class="reboot-restore-banner-icon" aria-hidden="true">↺</span>
|
||||
<span class="reboot-restore-banner-text" id="rebootRestoreBannerText"></span>
|
||||
<span class="reboot-restore-banner-detail" id="rebootRestoreBannerDetail"></span>
|
||||
<span class="reboot-restore-banner-note">Conversations return; terminal history does not.</span>
|
||||
<button
|
||||
class="reboot-restore-banner-accept"
|
||||
id="rebootRestoreBannerAccept"
|
||||
onclick="app.restoreRebootSessions()"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button class="reboot-restore-banner-dismiss" onclick="app.dismissRebootRestore()">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<!-- Timer Banner (shown when timed run is active) -->
|
||||
<div class="timer-banner" id="timerBanner" style="display: none;">
|
||||
<div class="timer-content">
|
||||
@@ -2059,6 +2077,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsAutoNameSessionsItem" data-search="auto name session title first prompt tab rename">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Auto-name Sessions <span class="set-tag">synced</span></span>
|
||||
<span class="set-row-desc">Title a new tab after its first prompt, keeping the case prefix. Renamed tabs are never touched.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||
@@ -3580,6 +3605,7 @@
|
||||
<script defer src="readmymind-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="approvals-ui.js"></script>
|
||||
<script defer src="reboot-restore-ui.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="host-wake-ui.js"></script>
|
||||
|
||||
@@ -3241,6 +3241,43 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
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;
|
||||
}
|
||||
|
||||
/* A flex item will not shrink below its content width at the default
|
||||
`min-width: auto`, so without this the nowrap text pushes the buttons off a
|
||||
360px viewport and the ellipsis never engages. */
|
||||
.reboot-restore-banner-text {
|
||||
min-width: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
padding: 0.4rem 0.5rem;
|
||||
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @fileoverview Reboot-restore banner: offer back the sessions a host reboot destroyed.
|
||||
*
|
||||
* A host reboot takes the tmux server down with it, so every session's pane dies
|
||||
* and the board comes up empty. The server works out what was running from the
|
||||
* records it still holds at boot, and this banner asks the user whether to
|
||||
* rebuild them. Nothing is created until they click, because the server's
|
||||
* reboot guess is a heuristic and a wrong automatic restore would spawn CLI
|
||||
* processes nobody asked for.
|
||||
*
|
||||
* 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` and Dismiss posts to
|
||||
* `POST /api/reboot-restore/dismiss`. Dismiss always clears the banner; Restore
|
||||
* re-reads the plan afterwards, because the server puts back anything it could
|
||||
* not build for a reason that may pass, such as a session limit or an agent that
|
||||
* would not start. The restored sessions arrive as ordinary `session:created`
|
||||
* events, so no extra rendering is needed here.
|
||||
*
|
||||
* The banner says that terminal history did not survive, because a restored
|
||||
* session is a new pane: the conversation continues and the scrollback does not.
|
||||
* Saying so is what keeps an empty pane from reading as a broken restore.
|
||||
* Backend: src/web/reboot-restore-registry.ts, src/web/routes/reboot-restore-routes.ts.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, showToast)
|
||||
* @dependency api-client.js at runtime (this._api / this._apiJson)
|
||||
* @loadorder 11.65, after approvals-ui.js and before admin-ui.js (11.7)
|
||||
*/
|
||||
|
||||
/** 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() {
|
||||
const data = await this._apiJson('/api/reboot-restore');
|
||||
const sessions = data?.sessions ?? [];
|
||||
if (sessions.length === 0) return;
|
||||
this._rebootRestoreSessions = sessions;
|
||||
this.renderRebootRestoreBanner();
|
||||
},
|
||||
|
||||
renderRebootRestoreBanner() {
|
||||
const banner = this.$('rebootRestoreBanner');
|
||||
if (!banner) return;
|
||||
const sessions = this._rebootRestoreSessions ?? [];
|
||||
if (sessions.length === 0) {
|
||||
banner.hidden = true;
|
||||
return;
|
||||
}
|
||||
const count = sessions.length;
|
||||
const text = this.$('rebootRestoreBannerText');
|
||||
if (text) {
|
||||
const noun = count === 1 ? 'session' : 'sessions';
|
||||
text.textContent = `Restore ${count} ${noun} from before the reboot`;
|
||||
}
|
||||
const detail = this.$('rebootRestoreBannerDetail');
|
||||
if (detail) {
|
||||
// Names, so the user can tell what they are about to relaunch.
|
||||
const names = sessions
|
||||
.map((s) => s.name || s.workingDir?.split('/').pop() || s.id.slice(0, 8))
|
||||
.slice(0, 4)
|
||||
.join(', ');
|
||||
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;
|
||||
},
|
||||
|
||||
/** Rebuild everything on offer. The panes are new, so scrollback does not come back. */
|
||||
async restoreRebootSessions() {
|
||||
const button = this.$('rebootRestoreBannerAccept');
|
||||
if (button) button.disabled = true;
|
||||
const res = await this._api('/api/reboot-restore/restore', { method: 'POST', body: {} });
|
||||
if (res && res.status === 409) {
|
||||
if (button) button.disabled = false;
|
||||
this.showToast?.('A restore is already running', 'info');
|
||||
return;
|
||||
}
|
||||
// The uniform envelope wraps every /api payload; reading the outer object
|
||||
// would report every count as zero.
|
||||
const body = res && res.ok ? (await res.json().catch(() => null))?.data : null;
|
||||
if (!body) {
|
||||
if (button) button.disabled = false;
|
||||
this.showToast?.('Could not restore the sessions', 'error');
|
||||
return;
|
||||
}
|
||||
const restored = body.restored?.length ?? 0;
|
||||
const skipped = body.skipped?.length ?? 0;
|
||||
// Re-read rather than clearing: the server puts back anything it could not
|
||||
// build for a reason that may pass, such as a session limit or an agent that
|
||||
// would not start, and blanking the banner here would put those entries out
|
||||
// of reach until a reload.
|
||||
await this.refreshRebootRestoreBanner();
|
||||
if (button) button.disabled = false;
|
||||
if (restored > 0) {
|
||||
const noun = restored === 1 ? 'conversation' : 'conversations';
|
||||
this.showToast?.(`Restored ${restored} ${noun}. Terminal history did not survive the reboot.`, 'success');
|
||||
}
|
||||
if (skipped > 0) {
|
||||
// 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 = [];
|
||||
this.renderRebootRestoreBanner();
|
||||
await this._apiPost('/api/reboot-restore/dismiss', {});
|
||||
},
|
||||
});
|
||||
@@ -408,6 +408,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// header), so the row is hidden elsewhere rather than offering a toggle that
|
||||
// changes nothing. Default ON — only an explicit false turns it off.
|
||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
@@ -2111,6 +2113,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
|
||||
@@ -2548,6 +2548,10 @@ body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .header-plan-usage,
|
||||
/* A solo window shows ONE session and has no tab strip to put restored ones in,
|
||||
so offering to rebuild a list of them there is an offer it cannot show the
|
||||
result of. The dashboard that spawned this window carries the banner. */
|
||||
body.solo-mode .reboot-restore-banner,
|
||||
body.solo-mode .btn-lifecycle-log {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -15243,6 +15247,85 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
skin, including the light ones. Visibility is driven by the `hidden`
|
||||
attribute, so the display rules need !important to lose to it. */
|
||||
|
||||
/* Reboot-restore offer. Amber rather than red: nothing is wrong, the board is
|
||||
asking a question, and the user can ignore it. See reboot-restore-ui.js. */
|
||||
.reboot-restore-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(90deg, #b45309, #92400e);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
flex-shrink: 0;
|
||||
z-index: 1250;
|
||||
}
|
||||
|
||||
.reboot-restore-banner[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-detail {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
/* A flex item will not shrink below its content width at the default
|
||||
`min-width: auto`, so without this the session names push the buttons out of
|
||||
the line between the phone breakpoint and full width. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-note {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-accept,
|
||||
.reboot-restore-banner-dismiss {
|
||||
flex-shrink: 0;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-accept:hover,
|
||||
.reboot-restore-banner-dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.reboot-restore-banner-accept:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-dismiss {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -66,6 +66,38 @@
|
||||
let composing = false;
|
||||
const pending = [];
|
||||
|
||||
/**
|
||||
* Resolve every candidate still pending, right now, instead of waiting for
|
||||
* its zero-delay timer.
|
||||
*
|
||||
* Android soft keyboards commit the last character and send the Enter key
|
||||
* in ONE InputConnection transaction: the `input` event and the Enter
|
||||
* keydown are both processed before any timer runs. Left on its timer the
|
||||
* candidate lost BOTH ways — xterm emits '\r' synchronously from the Enter
|
||||
* keydown (so the local-echo composer submitted the prompt without the
|
||||
* character), and that '\r' bumps `canonicalCount`, so the candidate then
|
||||
* read "xterm spoke for this keystroke" and stood down, dropping the
|
||||
* character outright. That is the "every message loses its last character"
|
||||
* report from phones.
|
||||
*
|
||||
* Draining at the next keydown is correct on both counts: the counter still
|
||||
* holds the value it had while this candidate's keystroke was current, and
|
||||
* the byte reaches the composer ahead of whatever the new key emits.
|
||||
*/
|
||||
function flushPending() {
|
||||
for (const candidate of pending.splice(0)) {
|
||||
if (candidate.timer !== null) {
|
||||
try {
|
||||
clearTimer(candidate.timer);
|
||||
} catch {
|
||||
// A broken timer host must not break input handling.
|
||||
}
|
||||
candidate.timer = null;
|
||||
}
|
||||
resolveCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelPending() {
|
||||
for (const candidate of pending.splice(0)) {
|
||||
candidate.active = false;
|
||||
@@ -111,6 +143,11 @@
|
||||
*/
|
||||
function handleKeyEvent(event) {
|
||||
if (destroyed || event?.type !== 'keydown') return;
|
||||
// Settle the PREVIOUS keystroke before this one can move the counter or
|
||||
// reach the PTY — see flushPending(). This runs from xterm's custom key
|
||||
// handler, i.e. before xterm processes the key, so a recovered character
|
||||
// is always ordered ahead of the bytes this keydown produces.
|
||||
flushPending();
|
||||
keydownSnapshot = canonicalCount;
|
||||
}
|
||||
|
||||
|
||||
+135
-29
@@ -3159,6 +3159,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
return buffer.viewportY >= buffer.baseY - 2;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-take the sticky-scroll baseline from where the viewport now sits.
|
||||
*
|
||||
* `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before it queues
|
||||
* data, and `flushPendingWrites` scrolls to the bottom off that sample. A
|
||||
* buffer load that replays its queue samples at the worst possible moment:
|
||||
* `_finishBufferLoad` runs inside `chunkedTerminalWrite`, before its promise
|
||||
* resolves, with the terminal freshly reset and rewritten, so the sample is
|
||||
* always true. A caller that then restores the reader's position would have
|
||||
* that restore undone by the next flush.
|
||||
*
|
||||
* `_onSessionNeedsRefresh` and `_maybeRefetchFullHistory` restore a position
|
||||
* and both call this, so their baseline describes the position they chose.
|
||||
*
|
||||
* The other two load paths do not call it, for different reasons.
|
||||
* `_onSessionClearTerminal` resets and rewrites with no scroll afterwards,
|
||||
* so the sampled true is already the truth there. `selectSession` does NOT
|
||||
* end at the bottom, whatever its `scrollToBottom()` after the write
|
||||
* suggests: it ends at `scrollToLastNonEmptyLine()`, which targets
|
||||
* `lastNonEmptyLine - rows + 2` and therefore parks ABOVE `baseY` whenever
|
||||
* the replayed frame keeps trailing blank rows, which a full capture does on
|
||||
* purpose. Its baseline is a stale true. What decides whether that matters
|
||||
* is the sticky snap in `flushPendingWrites`, and since de864e7d that snap
|
||||
* fires only when the flush found the viewport already at the bottom
|
||||
* (`preserveViewportY === null`), which a parked selectSession viewport is
|
||||
* not. Do not read the absent call here as a claim that selectSession lands
|
||||
* at the bottom.
|
||||
*/
|
||||
_syncStickyScrollBaseline() {
|
||||
this._wasAtBottomBeforeWrite = this.isTerminalAtBottom();
|
||||
},
|
||||
|
||||
// Record manual scroll gestures so sticky-scroll can give an upward scroll a
|
||||
// short grace window (see _hasRecentUserScrollUp). A downward scroll that
|
||||
// lands back at the bottom clears the suppression immediately.
|
||||
@@ -3295,7 +3327,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// to prevent interleaving historical buffer data with live SSE data.
|
||||
// This is critical: interleaving causes cursor position chaos with Ink redraws.
|
||||
if (this._isLoadingBuffer) {
|
||||
if (this._loadBufferQueue) this._loadBufferQueue.push(data);
|
||||
// Each entry records when it arrived. A flush of a tmux-capture load
|
||||
// replays only what arrived after the capture; without the timestamp it
|
||||
// would have to replay the whole queue, duplicating the events the
|
||||
// capture already contains. See _finishBufferLoad's `since`.
|
||||
if (this._loadBufferQueue) this._loadBufferQueue.push({ at: performance.now(), data });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3543,6 +3579,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._sendInputAsync(this.activeSessionId, text);
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-assert a history anchor captured before a terminal write (#358).
|
||||
*
|
||||
* Called from xterm's write callback, never synchronously after write():
|
||||
* xterm parses on its own schedule, so the buffer only carries the redraw's
|
||||
* effect once that callback fires. A null anchor means the user was following
|
||||
* live output and nothing needs restoring.
|
||||
*/
|
||||
_restoreTerminalViewport(preserveViewportY, sessionId) {
|
||||
if (preserveViewportY === null || preserveViewportY === undefined) return;
|
||||
// The anchor is a row index into the buffer it was captured from. Now that
|
||||
// this runs a parse later instead of synchronously, a session switch can land
|
||||
// in between: selectSession() resets the terminal and chunk-loads the new
|
||||
// session's scrollback, and scrolling THAT buffer to a row that meant
|
||||
// something in the previous one is not a restore, it is a jump to an
|
||||
// arbitrary place. Both checks cover one half of that window.
|
||||
if (sessionId !== undefined && sessionId !== this.activeSessionId) return;
|
||||
if (this._isLoadingBuffer) return;
|
||||
if (typeof this.terminal?.scrollToLine !== 'function') return;
|
||||
if (this.terminal.buffer?.active?.viewportY === preserveViewportY) return;
|
||||
this.terminal.scrollToLine(preserveViewportY);
|
||||
},
|
||||
|
||||
/**
|
||||
* Flush pending writes to terminal, processing DEC 2026 sync markers.
|
||||
* Strips markers and writes content atomically within a single frame.
|
||||
@@ -3578,6 +3637,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// scroll-to-bottom below, where it protects against a mid-flush race.
|
||||
const preserveViewportY =
|
||||
this.terminal.buffer?.active && !this.isTerminalAtBottom() ? this.terminal.buffer.active.viewportY : null;
|
||||
// Which buffer the anchor belongs to, checked again when the write parses.
|
||||
const flushSessionId = this.activeSessionId;
|
||||
|
||||
const writeChunk = joined.slice(0, MAX_FRAME_BYTES);
|
||||
if (_joinedLen > MAX_FRAME_BYTES) {
|
||||
@@ -3592,6 +3653,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(writeChunk, () => {
|
||||
this._terminalWriteInFlight = false;
|
||||
this._terminalWriteInFlightBytes = 0;
|
||||
// Restore INSIDE the callback (#358). xterm parses asynchronously, so
|
||||
// the moment write() returns the buffer has not moved yet: the old
|
||||
// restore ran here, found viewportY still equal to the anchor, and did
|
||||
// nothing at all — then the parse landed and a cursor-addressed Codex
|
||||
// redraw dragged the viewport to the live bottom with nothing left to
|
||||
// pull it back. The callback is xterm's own "this chunk is parsed"
|
||||
// signal, which is the earliest point the anchor can actually be
|
||||
// reasserted. (The synchronous version passed its regression test only
|
||||
// because the test's write mock moved the viewport synchronously.)
|
||||
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
|
||||
this._scheduleTerminalWriteFlush();
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -3599,13 +3670,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._terminalWriteInFlightBytes = 0;
|
||||
throw err;
|
||||
}
|
||||
if (
|
||||
preserveViewportY !== null &&
|
||||
this.terminal.buffer?.active?.viewportY !== preserveViewportY &&
|
||||
typeof this.terminal.scrollToLine === 'function'
|
||||
) {
|
||||
this.terminal.scrollToLine(preserveViewportY);
|
||||
}
|
||||
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
|
||||
const _dt = performance.now() - _t0;
|
||||
if (_dt > 100 || deferred)
|
||||
@@ -3617,7 +3681,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Give manual scroll-up gestures a short grace window so high-frequency
|
||||
// Codex status ticks do not snap the viewport back while the user is
|
||||
// trying to inspect earlier output.
|
||||
if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
|
||||
//
|
||||
// A live anchor wins outright. The two flags are captured at different
|
||||
// moments (_wasAtBottomBeforeWrite at the frame's first batchTerminalWrite,
|
||||
// the anchor at flush time), so a scroll-up in between leaves both set; now
|
||||
// that the anchor is reasserted after the parse, running both would jump to
|
||||
// the bottom and then back one frame later instead of simply staying put.
|
||||
if (preserveViewportY === null && this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
|
||||
this.terminal.scrollToBottom();
|
||||
}
|
||||
|
||||
@@ -3775,9 +3845,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
|
||||
* @param {string} buffer - The full terminal buffer to write
|
||||
* @param {number} chunkSize - Size of each chunk (default 32KB)
|
||||
* @param {string} [loadOwner] - Load token to finish under
|
||||
* @param {{ flushQueued?: boolean, since?: number }} [finishOpts] - Passed to
|
||||
* `_finishBufferLoad`. This method ends the load for every non-empty buffer,
|
||||
* so a caller that wants the queue replayed has to say so HERE; the call in
|
||||
* `selectSession` only runs when the write was skipped entirely.
|
||||
* @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
|
||||
*/
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner, finishOpts) {
|
||||
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
||||
// older writes abort instead of continuing to push stale data into the terminal.
|
||||
const writeGen = ++this._chunkedWriteGen;
|
||||
@@ -3790,7 +3865,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
completed,
|
||||
});
|
||||
if (!buffer || buffer.length === 0) {
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
resolve(parseSnapshot());
|
||||
return;
|
||||
}
|
||||
@@ -3804,7 +3879,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(cleanBuffer, () => resolve(parseSnapshot()));
|
||||
// The write is now ordered in xterm's queue. Release live output before
|
||||
// parsing completes; subsequent writes stay behind it without being lost.
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3835,7 +3910,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
resolve(result);
|
||||
});
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3849,15 +3924,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Open a buffer load: live terminal events are queued from here until
|
||||
* `_finishBufferLoad` decides what to do with them. Returns the load token the
|
||||
* finish call must present; a stale token makes that call a no-op.
|
||||
*
|
||||
* @param {string} [owner] Reuse an existing token to re-enter the same load
|
||||
* (see below); omit it to start a new one.
|
||||
* @returns {string} The load token.
|
||||
*/
|
||||
_beginBufferLoad(owner) {
|
||||
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
|
||||
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
|
||||
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
|
||||
// opens it again under the SAME owner when it starts writing. Resetting the
|
||||
// queue on that second call would throw away everything that arrived during
|
||||
// the fetch, which on the capture path is output no buffer holds. Re-entering
|
||||
// one load keeps its queue; a genuinely new load still starts empty.
|
||||
const reentering = this._bufferLoadOwner === loadOwner && Array.isArray(this._loadBufferQueue);
|
||||
this._bufferLoadOwner = loadOwner;
|
||||
this._isLoadingBuffer = true;
|
||||
if (!reentering) this._loadBufferQueue = [];
|
||||
return loadOwner;
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete a buffer load: unblock live SSE writes.
|
||||
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
|
||||
*
|
||||
* By default queued SSE events are DISCARDED, not flushed. For an established
|
||||
* session the loaded buffer from the API is the source of truth up to the
|
||||
* response timestamp; SSE events queued during the fetch+write overlap already
|
||||
* appear in that buffer, so flushing them writes duplicate data (especially Ink
|
||||
* cursor-up redraws), corrupting the terminal display.
|
||||
* session whose buffer came from the server's accumulated byte history, that
|
||||
* history is the source of truth up to the response timestamp; SSE events
|
||||
* queued during the fetch+write overlap already appear in it, so flushing
|
||||
* them writes duplicate data (especially Ink cursor-up redraws), corrupting
|
||||
* the terminal display.
|
||||
*
|
||||
* A tmux PANE CAPTURE is the exception, and the reason `since` exists. A
|
||||
* capture is a point-in-time frame taken part-way through the fetch, so it is
|
||||
* the source of truth only up to CAPTURE time — not up to the response. Every
|
||||
* event that arrives between the capture and the end of the chunked write is
|
||||
* queued and, under a plain discard, lost outright: nothing re-fetches, and
|
||||
* the CLI's next partial redraw lands on a frame the terminal never received.
|
||||
* The caller passes the response's own arrival time as `since` so exactly
|
||||
* that tail is replayed and the pre-capture events stay dropped.
|
||||
*
|
||||
* COD-144: a brand-new session is the exception. Its terminal fetch can resolve
|
||||
* BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the
|
||||
@@ -3871,17 +3980,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||
*
|
||||
* @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op.
|
||||
* @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events.
|
||||
* @param {{ flushQueued?: boolean, since?: number }} [opts] When `flushQueued`
|
||||
* is true, replay queued events whose arrival timestamp is at or after
|
||||
* `since` (default 0, meaning the whole queue).
|
||||
*/
|
||||
_beginBufferLoad(owner) {
|
||||
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
|
||||
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
|
||||
this._bufferLoadOwner = loadOwner;
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
return loadOwner;
|
||||
},
|
||||
|
||||
_finishBufferLoad(owner, opts) {
|
||||
if (owner !== undefined && this._bufferLoadOwner !== owner) {
|
||||
return false;
|
||||
@@ -3892,9 +3994,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._bufferLoadOwner = null;
|
||||
// COD-144: replay (rather than discard) queued live events when the load
|
||||
// painted nothing — the queued prompt is the only content a new session has.
|
||||
// A tmux-capture load replays too, but only the tail: `since` cuts the queue
|
||||
// at the moment the capture stopped being able to contain what arrived.
|
||||
if (opts?.flushQueued && queued && queued.length) {
|
||||
for (const data of queued) {
|
||||
this.batchTerminalWrite(data);
|
||||
const since = typeof opts.since === 'number' ? opts.since : 0;
|
||||
for (const entry of queued) {
|
||||
if (entry.at < since) continue;
|
||||
this.batchTerminalWrite(entry.data);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @fileoverview The pending restore plan: what a host reboot destroyed, waiting on a click.
|
||||
*
|
||||
* The boot pass builds this plan inside `restoreMuxSessions()`, in the window
|
||||
* where reconciliation has reported the dead sessions and `cleanupStaleSessions()`
|
||||
* has not pruned their records yet. The board then offers "restore N sessions
|
||||
* from before the reboot", and `web/routes/reboot-restore-routes` spends the plan
|
||||
* when the user clicks.
|
||||
*
|
||||
* Invariants:
|
||||
* - Entries are in-memory only. A server restart drops the plan, and nothing
|
||||
* re-builds it, because the records it was built from are pruned by then.
|
||||
* That costs the convenience this feature adds and never the conversation:
|
||||
* the conversation IS the transcript under `~/.claude/projects`, which
|
||||
* `services/unified-session-service.ts` reads for the Welcome screen's Resume
|
||||
* list and the Session Manager, and `resumeHistorySession()` in
|
||||
* `web/public/terminal-ui.js` resumes from a row there with no persisted
|
||||
* session record involved. A dropped plan therefore returns the user to
|
||||
* resuming by hand, one at a time, which is where they are without this
|
||||
* feature. What the plan held that a transcript does not is the owner, the
|
||||
* name, the env overrides, the effort and the lineage.
|
||||
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
|
||||
* import and no IO, which keeps it unit-testable and cycle-free.
|
||||
* - 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 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
|
||||
*
|
||||
* @module web/reboot-restore-registry
|
||||
*/
|
||||
|
||||
import type { RebootRestoreEntry } from '../reboot-restore.js';
|
||||
|
||||
/**
|
||||
* A plan older than this is dropped on read. A machine that rebooted yesterday
|
||||
* has moved on, and an offer nobody took by then is noise rather than a rescue.
|
||||
*/
|
||||
const PLAN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export class RebootRestoreRegistry {
|
||||
/** Keyed by session id, in the order the boot pass found them. */
|
||||
private entries = new Map<string, RebootRestoreEntry>();
|
||||
/** When the boot pass built the plan, in ms since the epoch. */
|
||||
private builtAt = 0;
|
||||
/**
|
||||
* Entries handed to a restore that has not finished, by session id, each
|
||||
* remembering which caller is spending it.
|
||||
*
|
||||
* A taken entry is still part of the offer until its restore resolves it, so
|
||||
* it has to stay reachable by everything that can invalidate an offer. Holding
|
||||
* the entries themselves — rather than a counter to compare against later —
|
||||
* means `clear()` filters them by the SAME `canAccess(entry.owner)` predicate
|
||||
* it already applies to the plan. A counter cannot do that, because the caller
|
||||
* spending an entry need not be its owner: an admin may restore another user's
|
||||
* sessions, and then the spender and the owner are different keys.
|
||||
*/
|
||||
private parked = new Map<string, { entry: RebootRestoreEntry; spender: string | undefined }>();
|
||||
/**
|
||||
* 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 {
|
||||
this.entries = new Map(entries.map((entry) => [entry.sessionId, entry]));
|
||||
this.builtAt = entries.length > 0 ? Date.now() : 0;
|
||||
// A fresh boot plan supersedes anything an in-flight restore still holds.
|
||||
this.parked.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries a viewer may see, newest plan first-come order preserved.
|
||||
*
|
||||
* @param canAccess Ownership predicate, so a user sees their own entries and
|
||||
* an admin sees all. Applied here rather than in the route so the count the
|
||||
* banner shows and the entries a click spends come from one filter.
|
||||
*/
|
||||
list(canAccess: (owner: string | undefined) => boolean): RebootRestoreEntry[] {
|
||||
this.dropIfExpired();
|
||||
return [...this.entries.values()].filter((entry) => canAccess(entry.owner));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the entries a click is about to spend.
|
||||
*
|
||||
* Synchronous and total: an entry leaves the plan here, before any pane is
|
||||
* created, so a second click finds nothing to spend. Entries a caller may not
|
||||
* access are left in place, and unknown ids are ignored.
|
||||
*
|
||||
* @param sessionIds The ids to spend, or undefined for every visible entry.
|
||||
*/
|
||||
take(
|
||||
canAccess: (owner: string | undefined) => boolean,
|
||||
sessionIds: readonly string[] | undefined,
|
||||
spender: string | undefined
|
||||
): RebootRestoreEntry[] {
|
||||
this.dropIfExpired();
|
||||
const wanted = sessionIds ? new Set(sessionIds) : undefined;
|
||||
const taken: RebootRestoreEntry[] = [];
|
||||
for (const entry of [...this.entries.values()]) {
|
||||
if (wanted && !wanted.has(entry.sessionId)) continue;
|
||||
if (!canAccess(entry.owner)) continue;
|
||||
this.entries.delete(entry.sessionId);
|
||||
// Parked rather than forgotten: until this restore resolves the entry, a
|
||||
// dismiss still has to be able to reach and cancel it.
|
||||
this.parked.set(entry.sessionId, { entry, spender });
|
||||
taken.push(entry);
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put entries back after a rebuild never got as far as creating a pane.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
releaseFlight(spender: string | undefined, keep: readonly RebootRestoreEntry[]): void {
|
||||
const wanted = new Set(keep.map((entry) => entry.sessionId));
|
||||
let added = 0;
|
||||
for (const [sessionId, held] of [...this.parked]) {
|
||||
if (held.spender !== spender) continue;
|
||||
this.parked.delete(sessionId);
|
||||
// Still parked means nothing cancelled it while the restore ran. A dismiss,
|
||||
// an expiry or a fresh boot plan removes it from `parked`, and then it does
|
||||
// not come back however the restore ended.
|
||||
if (wanted.has(sessionId)) {
|
||||
this.entries.set(sessionId, held.entry);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
if (added > 0 && this.builtAt === 0) this.builtAt = Date.now();
|
||||
}
|
||||
|
||||
/** Drop the entries a viewer can see. Returns how many went. */
|
||||
clear(canAccess: (owner: string | undefined) => boolean): number {
|
||||
const removable = [...this.entries.values()].filter((entry) => canAccess(entry.owner));
|
||||
for (const entry of removable) this.entries.delete(entry.sessionId);
|
||||
// Entries a restore is holding are dismissed by the same rule, so a dismiss
|
||||
// that lands mid-restore wins. Judged on the ENTRY's owner, exactly as above,
|
||||
// rather than on who happens to be restoring it.
|
||||
let parkedRemoved = 0;
|
||||
for (const [sessionId, held] of [...this.parked]) {
|
||||
if (!canAccess(held.entry.owner)) continue;
|
||||
this.parked.delete(sessionId);
|
||||
parkedRemoved += 1;
|
||||
}
|
||||
if (this.entries.size === 0) this.builtAt = 0;
|
||||
return removable.length + parkedRemoved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(owner?: string): boolean {
|
||||
if (this.spending.has(owner)) return false;
|
||||
this.spending.add(owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
endSpending(owner?: string): void {
|
||||
this.spending.delete(owner);
|
||||
}
|
||||
|
||||
/** Test hook: forget everything, including the single-flight claim. */
|
||||
reset(): void {
|
||||
this.entries.clear();
|
||||
this.parked.clear();
|
||||
this.builtAt = 0;
|
||||
this.spending.clear();
|
||||
}
|
||||
|
||||
private dropIfExpired(): void {
|
||||
if (this.builtAt > 0 && Date.now() - this.builtAt > PLAN_TTL_MS) {
|
||||
// A restore that took entries just before the expiry must not hand them
|
||||
// back afterwards and give an expired plan another full day of life.
|
||||
this.parked.clear();
|
||||
this.entries.clear();
|
||||
this.builtAt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide singleton, mirroring `approvalInbox`. */
|
||||
export const rebootRestoreRegistry = new RebootRestoreRegistry();
|
||||
@@ -11,6 +11,7 @@ export { registerCronRoutes } from './cron-routes.js';
|
||||
export { registerSystemRoutes } from './system-routes.js';
|
||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerApprovalRoutes } from './approval-routes.js';
|
||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @fileoverview Reboot-restore routes: offer back the sessions a host reboot destroyed.
|
||||
*
|
||||
* The boot pass leaves a plan in `web/reboot-restore-registry` when the machine
|
||||
* plausibly rebooted. The board reads it, shows a banner, and the user decides:
|
||||
* - `GET /api/reboot-restore`: what is on offer, ownership-scoped
|
||||
* - `POST /api/reboot-restore/restore`: rebuild some or all of it
|
||||
* - `POST /api/reboot-restore/dismiss`: drop the offer
|
||||
*
|
||||
* A click, not the heuristic, is what creates panes. The heuristic only decides
|
||||
* whether the banner appears, so a wrong yes costs a line of text the user
|
||||
* dismisses rather than N CLI processes nobody asked for.
|
||||
*
|
||||
* Rebuilding is take-then-build: entries leave the plan synchronously at the top
|
||||
* of the route, before the first `await`, and the whole route is single-flighted,
|
||||
* so a double-click or two devices cannot put two panes on one conversation.
|
||||
* Three things are re-checked at click time rather than trusted from boot: the
|
||||
* owner's privilege grant, the workspace still being on disk, and the
|
||||
* conversation not already being live because the user resumed it by hand.
|
||||
*
|
||||
* A rebuilt session comes back attached, idle and disarmed. Respawn controllers
|
||||
* and Ralph loops are deliberately not re-armed, and its terminal scrollback is
|
||||
* gone, because the pane is new. The banner says so.
|
||||
*/
|
||||
|
||||
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,
|
||||
ownerFor,
|
||||
isWorkingDirAllowedForUsername,
|
||||
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';
|
||||
import { Session } from '../../session.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { applyWorkspaceHooks, seedAgentSessionPreamble } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionAttachmentHistoryItem } from '../../types.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
type RebootRestoreCtx = SessionPort & EventPort & ConfigPort & InfraPort;
|
||||
|
||||
/** The banner's view of one restorable session. The record itself never leaves the server. */
|
||||
function toBannerItem(entry: RebootRestoreEntry) {
|
||||
return {
|
||||
id: entry.sessionId,
|
||||
name: entry.name,
|
||||
workingDir: entry.workingDir,
|
||||
mode: entry.mode,
|
||||
owner: entry.owner,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRestoreCtx): void {
|
||||
const accessorFor = (req: Parameters<typeof getAuthUser>[0]) => {
|
||||
const user = getAuthUser(req);
|
||||
return (owner: string | undefined) => canAccessOwned(user, owner);
|
||||
};
|
||||
|
||||
// ========== What is on offer ==========
|
||||
|
||||
app.get('/api/reboot-restore', async (req) => {
|
||||
const entries = rebootRestoreRegistry.list(accessorFor(req));
|
||||
return {
|
||||
sessions: entries.map(toBannerItem),
|
||||
// Said plainly here so the banner never implies a full restore: the pane is
|
||||
// new, so the conversation continues and the terminal history does not.
|
||||
scrollbackRestored: false,
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Spend it ==========
|
||||
|
||||
app.post('/api/reboot-restore/restore', async (req, reply) => {
|
||||
const body = parseBody(RebootRestoreRequestSchema, req.body, 'Invalid reboot restore request');
|
||||
const canAccess = accessorFor(req);
|
||||
const owner = ownerFor(req);
|
||||
|
||||
// Take BEFORE the first await: a second click must find nothing to spend.
|
||||
// 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, owner);
|
||||
// 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. This one
|
||||
// is never re-offered: unlike a missing workspace, it cannot stop being true.
|
||||
// Read fresh each time rather than snapshotted once: the loop below awaits a
|
||||
// real `startInteractive()` per entry, so by the tenth entry a snapshot taken
|
||||
// here is tens of seconds old, and a conversation the user resumed by hand in
|
||||
// that window would be invisible to it.
|
||||
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());
|
||||
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) {
|
||||
// The already-live check, re-run against the board as it is NOW. The pass
|
||||
// above decided the batch; this catches a conversation that went live while
|
||||
// an earlier entry in this same batch was starting. Spent rather than
|
||||
// returned to the plan, for the same reason as the batch pass: unlike a
|
||||
// missing workspace or a withdrawn grant, an open conversation is not a
|
||||
// condition that stops being true.
|
||||
const [lateLive] = rejectAlreadyLive([entry], liveSessionIds(), liveConversationIds()).skipped;
|
||||
if (lateLive) {
|
||||
failures.push(lateLive);
|
||||
unspent.delete(entry);
|
||||
continue;
|
||||
}
|
||||
// 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. Keyed on the OWNER, not on the
|
||||
// caller: an admin spending another user's entry must be held to that
|
||||
// user's confinement, and `isWorkingDirAllowed` would wave an admin
|
||||
// through. The same reason the two grant re-checks below read
|
||||
// `saved.owner`.
|
||||
if (!(await isWorkingDirAllowedForUsername(entry.owner, entry.workingDir))) {
|
||||
// Left on offer: a withdrawn grant can be restored, unlike an already-open
|
||||
// conversation, so this is not the permanent kind of refusal.
|
||||
failures.push({ sessionId: entry.sessionId, reason: 'workspace-forbidden' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const saved = entry.state;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
// The old id is reused on purpose: a pinned record, subagent parents,
|
||||
// window states and the lifecycle log all key off it, and the unpinned
|
||||
// record is gone, so there is nothing to collide with.
|
||||
id: saved.id,
|
||||
workingDir: saved.workingDir,
|
||||
mode: saved.mode,
|
||||
name: saved.name,
|
||||
// Without this the constructor re-infers ownership from the name, so a
|
||||
// session the user renamed by hand to something shaped like `w<n>-<case>`
|
||||
// comes back as `placeholder` and auto-naming overwrites their name on
|
||||
// the next prompt. The route persists below, so the loss would go to
|
||||
// disk. `restoreMuxSessions()` passes it for the same reason.
|
||||
nameSource: saved.nameSource,
|
||||
createdAt: saved.createdAt,
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
// No `muxSession`: the reboot took the pane with it, so `startInteractive()`
|
||||
// takes its create branch and makes a fresh one.
|
||||
claudeMode: await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, saved.owner),
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
resumeSessionId: entry.resumeConversationId,
|
||||
// Re-resolved against the owner's CURRENT grant, never replayed from the
|
||||
// record: a grant held when the record was written may be gone now.
|
||||
envOverrides: await clampEnvOverridesForOwner(
|
||||
saved.owner,
|
||||
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
|
||||
),
|
||||
effort: saved.effort,
|
||||
attachmentHistory:
|
||||
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
|
||||
saved.attachmentHistory,
|
||||
lastSubmitAt: saved.lastSubmitAt,
|
||||
claudeSessionChain: saved.claudeSessionChain,
|
||||
lastActivityAt: saved.lastActivityAt,
|
||||
owner: saved.owner,
|
||||
parentSessionId: saved.parentSessionId,
|
||||
});
|
||||
|
||||
await ctx.addSession(session);
|
||||
// Before the listeners, because setupSessionListeners() reads the
|
||||
// image-watcher flag this phase restores; before the spawn, because the
|
||||
// custom-model environment and the nice priority shape the process.
|
||||
await ctx.reapplyPersistedSessionState(session, saved, 'before-spawn');
|
||||
await ctx.setupSessionListeners(session);
|
||||
await session.startInteractive();
|
||||
// The session's own history, applied only once the pane exists: on a
|
||||
// failed start these totals would belong to a session that never ran.
|
||||
// Both halves precede the route's OWN persist, which matters because a
|
||||
// constructed session carries none of this and `toState()` is written
|
||||
// wholesale, so persisting first would replace the fuller record with
|
||||
// the reduced one and drop the pin that keeps it from being pruned. A
|
||||
// listener-driven persist can still land inside the debounce window
|
||||
// while the pane starts; the write below repairs the record.
|
||||
// `rearmAutoResumeSchedule: false`: the saved stamp predates the reboot and
|
||||
// the pane is new, so honouring it would have every restored session type
|
||||
// `continue` into itself about a minute after one click. Auto-resume stays
|
||||
// enabled and re-arms on the next real limit message. This is also what the
|
||||
// module header promises ("comes back attached, idle and disarmed").
|
||||
await ctx.reapplyPersistedSessionState(session, saved, 'after-spawn', {
|
||||
rearmAutoResumeSchedule: false,
|
||||
});
|
||||
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
|
||||
// blocking dialog. The boot-time sweep finished hours ago, so the click
|
||||
// path installs them itself. `hooks: 'always'` is the capability that says
|
||||
// this CLI installs Codeman's hooks into the workspace.
|
||||
if (workspaceHooksEnabled && getCli(session.mode)?.capabilities.hooks === 'always') {
|
||||
await applyWorkspaceHooks(session.workingDir, true).catch((err: unknown) =>
|
||||
console.warn(`[reboot-restore] hook install failed for ${session.workingDir}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Both create paths seed this; without it a restored claude session's agent
|
||||
// skill falls back to writing out the whole ~150-line §0 preamble. Remote and
|
||||
// docker sessions never reach here (the plan rejects them as
|
||||
// `remote-or-docker`), so the local-only condition is structural.
|
||||
if (getCli(session.mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
|
||||
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
|
||||
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
}
|
||||
|
||||
getLifecycleLog().log({ event: 'recovered', sessionId: session.id, name: session.name });
|
||||
// Every other open tab and phone needs this; the clicking tab already has
|
||||
// the response, and the client's handler is an idempotent upsert.
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
restored.push(toBannerItem(entry));
|
||||
} catch (err) {
|
||||
// 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.
|
||||
//
|
||||
// Reaching this is rarer than it looks, measured against a real server:
|
||||
// the CLI resolver finds its binary by absolute path rather than through
|
||||
// PATH, and tmux falls back to another directory rather than failing when
|
||||
// it cannot enter the workspace, so neither of the two obvious "freshly
|
||||
// booted machine" failures throws. What is left is the mux layer itself
|
||||
// failing, which is why this path is defended rather than expected.
|
||||
console.error(`[reboot-restore] failed to rebuild ${entry.sessionId}:`, err);
|
||||
// Not cleanupSession(): that is the user-initiated delete, and it would
|
||||
// count this session's historical tokens into the lifetime totals, demote
|
||||
// a pinned record to `stopped` (which this pass reads as an intentional
|
||||
// kill, making the session permanently unrestorable) and delete the
|
||||
// workspace's `.claude-images`. This undoes only the construction.
|
||||
await ctx
|
||||
.discardPartiallyBuiltSession(entry.sessionId)
|
||||
.catch((discardErr: unknown) =>
|
||||
console.error(`[reboot-restore] discarding a failed rebuild failed: ${getErrorMessage(discardErr)}`)
|
||||
);
|
||||
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) {
|
||||
// A reboot leaves recovery with nothing alive to find, so its own block never
|
||||
// started the stats collector. This clears and re-arms its interval, so it is
|
||||
// safe to call whether or not the collector is already running.
|
||||
ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return { restored, skipped: failures };
|
||||
} finally {
|
||||
// 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.
|
||||
// Ends the flight: entries still parked for it come back if they are in
|
||||
// `unspent`, and a Dismiss that unparked them meanwhile wins.
|
||||
rebootRestoreRegistry.releaseFlight(owner, [...unspent]);
|
||||
rebootRestoreRegistry.endSpending(owner);
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Drop it ==========
|
||||
|
||||
app.post('/api/reboot-restore/dismiss', async (req) => {
|
||||
const dismissed = rebootRestoreRegistry.clear(accessorFor(req));
|
||||
return { dismissed };
|
||||
});
|
||||
}
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
} from '../route-helpers.js';
|
||||
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
@@ -450,72 +451,6 @@ export async function _clampExternalCliBypassForOwner(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Env-var keys a non-granted owner must not be able to set, because each one
|
||||
* hands back privilege the config clamp above just removed, or redirects a
|
||||
* credential-resolution endpoint.
|
||||
*
|
||||
* The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
|
||||
* that is also how a user configures the harness's non-privileged knobs.
|
||||
*
|
||||
* - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's
|
||||
* bypass is a command-line FLAG, reachable only through the per-CLI config the
|
||||
* clamp already owns; this one is an env var, so the config clamp alone is
|
||||
* half a gate.
|
||||
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
|
||||
* executes at BOOT, before any approval row can apply. A user who can write a
|
||||
* workspace can put a profile in it, so this is the wider of the two.
|
||||
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
|
||||
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
|
||||
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
|
||||
* URL would have the operator's API key sent as a bearer credential to a host
|
||||
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
|
||||
* your OWN key removes privilege rather than granting it.)
|
||||
* - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
|
||||
* credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
|
||||
* because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
|
||||
* forward any operator-held key into an omp pane today (omp's provider
|
||||
* credentials live in `~/.omp` config files, not env vars), so there is no
|
||||
* known concrete exfiltration path yet — clamped defensively anyway, since a
|
||||
* non-granted owner redirecting where a shared multi-tenant deployment
|
||||
* resolves auth from is not something to allow silently (found in
|
||||
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
|
||||
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
|
||||
*/
|
||||
function ownerClampedEnvKeys(): string[] {
|
||||
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Env-var half of the multi-user bypass clamp.
|
||||
*
|
||||
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
|
||||
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
|
||||
* AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
|
||||
* request lands last and wins, and a non-granted owner could restore
|
||||
* `danger-full-access` on the very request the config clamp downgraded.
|
||||
*
|
||||
* Keys are DROPPED rather than rewritten: dropping falls through to what
|
||||
* `_configureCliEnv()` exports, which is the clamped config and the server's own
|
||||
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
|
||||
* granted owner, like every other clamp here
|
||||
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
|
||||
* and it returns the caller's own object untouched when there is nothing to strip.
|
||||
*/
|
||||
async function clampEnvOverridesForOwner(
|
||||
owner: string | undefined,
|
||||
envOverrides: Record<string, string> | undefined
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!envOverrides) return envOverrides;
|
||||
const keys = ownerClampedEnvKeys();
|
||||
if (!keys.some((key) => key in envOverrides)) return envOverrides;
|
||||
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
|
||||
const clamped = { ...envOverrides };
|
||||
for (const key of keys) delete clamped[key];
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** Test hook: the env-var half of the same multi-user safety gate. */
|
||||
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
|
||||
|
||||
@@ -1747,6 +1682,9 @@ export function registerSessionRoutes(
|
||||
|
||||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||||
// Every write here is `fromUser`: this route carries a person's prompt, or an
|
||||
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
|
||||
// approvals write through the session directly and never say so).
|
||||
//
|
||||
// Because the response has already been sent by then, a failure there is the
|
||||
// one case the caller can never learn about — so the dedup bookkeeping is
|
||||
@@ -1769,32 +1707,32 @@ export function registerSessionRoutes(
|
||||
} else if (useMux && waitPromise) {
|
||||
// The response is already staying open for the wait, so the tmux write can be
|
||||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||||
const ok = await session.writeViaMux(inputStr).catch(() => false);
|
||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||
if (ok) {
|
||||
delivered = true;
|
||||
} else {
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
delivered = session.write(inputStr);
|
||||
delivered = session.write(inputStr, { fromUser: true });
|
||||
if (!delivered) undoOnFailure();
|
||||
}
|
||||
} else if (useMux) {
|
||||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||||
session
|
||||
.writeViaMux(inputStr)
|
||||
.writeViaMux(inputStr, { fromUser: true })
|
||||
.then((ok) => {
|
||||
if (ok) return;
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
if (!session.write(inputStr)) undoOnFailure();
|
||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!session.write(inputStr)) undoOnFailure();
|
||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||
});
|
||||
} else {
|
||||
// Same rollback. NOT an error response, deliberately: a session can
|
||||
// legitimately have no PTY yet (created but not started), and callers have
|
||||
// always been able to write to one without a 4xx.
|
||||
delivered = session.write(inputStr);
|
||||
delivered = session.write(inputStr, { fromUser: true });
|
||||
if (!delivered && tagged) {
|
||||
session.forgetInputSeq(clientId as string, seq as number);
|
||||
}
|
||||
@@ -2043,6 +1981,9 @@ export function registerSessionRoutes(
|
||||
console.error('[Server] send-key failed:', err);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||||
}
|
||||
// The bytes bypassed the session's write path, so tell the auto-name
|
||||
// tracker about them or the two lines of a prompt join with no separator.
|
||||
session.trackUserInput(hex.map((byte) => String.fromCharCode(parseInt(byte, 16))).join(''));
|
||||
return {};
|
||||
});
|
||||
|
||||
|
||||
@@ -185,7 +185,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
delivered = session.write(msg.d);
|
||||
// Browser keystrokes are the user's own, so they may name the tab.
|
||||
delivered = session.write(msg.d, { fromUser: true });
|
||||
// A session whose PTY is gone swallows the write. ACKing anyway told
|
||||
// the client to drop the frame from its durable queue and left the seq
|
||||
// burnt, so the retry that reliable delivery exists for was rejected as
|
||||
|
||||
@@ -1185,6 +1185,20 @@ const NotificationEventSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Body of `POST /api/reboot-restore/restore`.
|
||||
*
|
||||
* `sessionIds` restores a subset, and omitting it restores everything the caller
|
||||
* can see. The ids are session ids from `GET /api/reboot-restore`, and an id the
|
||||
* caller does not own is ignored rather than refused, matching how the session
|
||||
* list scopes rather than 403s.
|
||||
*/
|
||||
export const RebootRestoreRequestSchema = z
|
||||
.object({
|
||||
sessionIds: z.array(z.string().max(128)).max(200).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SettingsUpdateSchema = z
|
||||
.object({
|
||||
// User-facing product branding. This changes browser/UI copy only; package,
|
||||
@@ -1254,6 +1268,13 @@ export const SettingsUpdateSchema = z
|
||||
* already pending immediately.
|
||||
*/
|
||||
approvalsInboxEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Auto-name sessions: a placeholder tab (`w3-case`) takes its first real
|
||||
* prompt as a title (`w3-case: fix the login redirect`). Synced, default
|
||||
* OFF: the prompt lands in mux-sessions.json, every session:updated
|
||||
* broadcast and /api/search, which is the user's choice to make.
|
||||
*/
|
||||
autoNameSessions: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
||||
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
||||
|
||||
+251
-1
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Extracted from server.ts for modularity. Provides:
|
||||
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
|
||||
* - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
|
||||
* - `createSessionListeners()` — builds all session listener handlers via dependency injection
|
||||
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
|
||||
*
|
||||
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
|
||||
@@ -29,6 +29,8 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
|
||||
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
|
||||
|
||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||
export interface SessionListenerRefs {
|
||||
@@ -63,6 +65,7 @@ export interface SessionListenerRefs {
|
||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
|
||||
promptSubmitted: (prompt: string) => void;
|
||||
}
|
||||
|
||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||
@@ -83,10 +86,13 @@ interface SessionListenerDeps {
|
||||
cleanupRespawnOnExit(sessionId: string): void;
|
||||
getStore(): import('../state-store.js').StateStore;
|
||||
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
||||
updateSessionName(sessionId: string, name: string): boolean;
|
||||
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
|
||||
isAutoNameEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all 26 session listener handlers, capturing dependencies via closure.
|
||||
* Creates all session listener handlers, capturing dependencies via closure.
|
||||
* Call `attachSessionListeners()` after to wire them to the session.
|
||||
*/
|
||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||
@@ -451,6 +457,30 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Names a placeholder tab after its first real prompt (`w3-case: fix the
|
||||
* login redirect`), behind the synced `autoNameSessions` setting. The
|
||||
* eligibility check comes first so the settings read costs nothing on the
|
||||
* prompts of an already-named session; a prompt that yields no title (a
|
||||
* slash command) leaves the session eligible for the next one.
|
||||
*/
|
||||
promptSubmitted: (prompt: string) => {
|
||||
if (session.nameSource !== 'placeholder') return;
|
||||
const title = deriveAutoSessionName(prompt);
|
||||
if (!title) return;
|
||||
void deps
|
||||
.isAutoNameEnabled()
|
||||
.then((enabled) => {
|
||||
if (!enabled) return;
|
||||
const name = composeAutoSessionName(session.name, title, MAX_SESSION_NAME_LENGTH);
|
||||
if (!session.applyAutoName(name)) return;
|
||||
deps.updateSessionName(session.id, session.name);
|
||||
deps.persistSessionState(session);
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
})
|
||||
.catch((err) => console.error(`[Session] auto-name failed for ${session.id}:`, err));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -487,6 +517,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('bashToolEnd', refs.bashToolEnd);
|
||||
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
||||
session.on('attachmentRequested', refs.attachmentRequested);
|
||||
session.on('promptSubmitted', refs.promptSubmitted);
|
||||
}
|
||||
|
||||
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
||||
@@ -522,4 +553,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('bashToolEnd', refs.bashToolEnd);
|
||||
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
||||
session.off('attachmentRequested', refs.attachmentRequested);
|
||||
session.off('promptSubmitted', refs.promptSubmitted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user