Overview Home Screen phone
@@ -3580,6 +3605,7 @@
+
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index 9be29153..f364c701 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -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));
diff --git a/src/web/public/reboot-restore-ui.js b/src/web/public/reboot-restore-ui.js
new file mode 100644
index 00000000..1e738bdd
--- /dev/null
+++ b/src/web/public/reboot-restore-ui.js
@@ -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', {});
+ },
+});
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 92631b0f..79bee3c0 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -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,
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 231e4b10..8775e8da 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -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;
diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js
index 3e00ea65..d6a95f74 100644
--- a/src/web/public/terminal-keycode229-recovery.js
+++ b/src/web/public/terminal-keycode229-recovery.js
@@ -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;
}
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 7ecc994c..c767ad36 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -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;
diff --git a/src/web/reboot-restore-registry.ts b/src/web/reboot-restore-registry.ts
new file mode 100644
index 00000000..94a7c63f
--- /dev/null
+++ b/src/web/reboot-restore-registry.ts
@@ -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();
+ /** 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();
+ /**
+ * 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();
+
+ /** 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();
diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts
index c1f0a952..f11d6194 100644
--- a/src/web/routes/index.ts
+++ b/src/web/routes/index.ts
@@ -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';
diff --git a/src/web/routes/reboot-restore-routes.ts b/src/web/routes/reboot-restore-routes.ts
new file mode 100644
index 00000000..fb6cec97
--- /dev/null
+++ b/src/web/routes/reboot-restore-routes.ts
@@ -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[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[] = [];
+ 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-`
+ // 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 }).__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 };
+ });
+}
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index 531ffcdd..24a8c6d9 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -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 | undefined
-): Promise | 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 {};
});
diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts
index ed60f5d3..ec7fe9e1 100644
--- a/src/web/routes/ws-routes.ts
+++ b/src/web/routes/ws-routes.ts
@@ -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
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index ec0014d1..c8c3a273 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -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:
diff --git a/src/web/server.ts b/src/web/server.ts
index 38d39154..020f4c50 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -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 {
+ 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 {
+ 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 {
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
diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts
index 33724557..6522c01a 100644
--- a/src/web/session-listener-wiring.ts
+++ b/src/web/session-listener-wiring.ts
@@ -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;
+ updateSessionName(sessionId: string, name: string): boolean;
+ /** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
+ isAutoNameEnabled(): Promise;
}
/**
- * 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);
}
diff --git a/test/capture-load-window.browser.test.ts b/test/capture-load-window.browser.test.ts
new file mode 100644
index 00000000..8cea77ef
--- /dev/null
+++ b/test/capture-load-window.browser.test.ts
@@ -0,0 +1,180 @@
+/**
+ * @fileoverview Output arriving after a pane capture survives the buffer load.
+ *
+ * `batchTerminalWrite` queues live terminal events while a buffer load runs,
+ * and `_finishBufferLoad` discards that queue by default. That is right when
+ * the loaded buffer is the server's accumulated byte history, which is current
+ * up to the response. A tmux pane capture is current only up to CAPTURE time,
+ * so anything arriving between the capture and the end of the chunked write is
+ * queued and then dropped, with nothing scheduling a re-fetch.
+ *
+ * The queue now stamps each entry with its arrival time, and a capture load
+ * replays the tail that arrived after the response headers. These drive the
+ * real client in chromium: the event is injected from inside the response's
+ * own `json()` call, which is the one place guaranteed to land after the
+ * headers and before the chunked write.
+ *
+ * Port: 3256 (capture load window)
+ *
+ * Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import { WebServer } from '../src/web/server.js';
+
+const PORT = 3256;
+const BASE_URL = `http://localhost:${PORT}`;
+const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
+
+let server: WebServer;
+let browser: Browser;
+
+beforeAll(async () => {
+ server = new WebServer(PORT, false, true); // testMode
+ await server.start();
+ browser = await chromium.launch({ headless: true });
+}, 60_000);
+
+afterAll(async () => {
+ await browser?.close();
+ await server?.stop();
+}, 30_000);
+
+/**
+ * Select the session with the terminal fetch stubbed, injecting one live event
+ * from inside `json()`. Returns how many terminal rows carry the marker, so a
+ * flush that replays too much fails as loudly as one that replays nothing.
+ */
+async function runLoad(page: Page, sessionId: string, source: string): Promise {
+ return page.evaluate(
+ async ({ sid, src, marker }) => {
+ const app = (
+ window as unknown as {
+ app: {
+ selectSession: (id: string, o?: object) => Promise;
+ _onSessionTerminal: (e: { id: string; data: string }) => void;
+ terminal: {
+ buffer: {
+ active: {
+ length: number;
+ getLine: (i: number) => { translateToString: (t: boolean) => string } | undefined;
+ };
+ };
+ };
+ };
+ }
+ ).app;
+
+ const realFetch = window.fetch.bind(window);
+ window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(typeof input === 'string' ? input : ((input as Request).url ?? input));
+ if (!url.includes('/terminal')) return realFetch(input as RequestInfo, init);
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ // `selectSession` timestamps the headers the moment this promise
+ // resolves, then calls json(). Injecting here puts the event after
+ // that timestamp and inside the load window, which is exactly the
+ // gap a pane capture cannot cover.
+ json: async () => {
+ app._onSessionTerminal({ id: sid, data: `\r\n${marker}\r\n` });
+ return {
+ success: true,
+ data: {
+ terminalBuffer: '\x1b[1;1Hcaptured frame line one\r\n',
+ status: 'idle',
+ fullSize: 512,
+ retainedBytes: 512,
+ truncated: false,
+ truncationReason: null,
+ source: src,
+ captureCols: 80,
+ captureRows: 24,
+ },
+ };
+ },
+ }) as unknown as Promise;
+ }) as typeof window.fetch;
+
+ try {
+ await app.selectSession(sid);
+ await new Promise((r) => setTimeout(r, 1200));
+ const buf = app.terminal.buffer.active;
+ let hits = 0;
+ for (let i = 0; i < buf.length; i++) {
+ if (buf.getLine(i)?.translateToString(true).includes(marker)) hits += 1;
+ }
+ return hits;
+ } finally {
+ window.fetch = realFetch;
+ }
+ },
+ { sid: sessionId, src: source, marker: MARKER }
+ );
+}
+
+async function openSession(page: Page): Promise {
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
+ // xterm loads from /vendor, so the terminal appears a beat after the app.
+ // Without it every buffer assertion below would throw rather than compare.
+ await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
+ timeout: 30_000,
+ });
+ return page.evaluate(async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', name: 'capture-load-window-test' }),
+ });
+ const body = await res.json();
+ return body.data?.session?.id ?? body.data?.id ?? body.id;
+ });
+}
+
+describe('output emitted during a capture load', () => {
+ let context: BrowserContext;
+ let page: Page;
+
+ afterAll(async () => {
+ await context?.close();
+ });
+
+ it('reaches the terminal exactly once when the buffer came from a pane capture', async () => {
+ context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
+ page = await context.newPage();
+ const sessionId = await openSession(page);
+ expect(sessionId).toBeTruthy();
+
+ // Exactly once. The cutoff exists so the flush cannot also replay events the
+ // payload already carried, which would double the output rather than heal it.
+ expect(await runLoad(page, sessionId, 'mux-visible')).toBe(1);
+
+ await page.evaluate(
+ (sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
+ sessionId
+ );
+ await context.close();
+ }, 60_000);
+
+ it('stays dropped when the buffer came from the accumulated byte history', async () => {
+ // The byte history already contains everything up to the response, so
+ // replaying the queue on top of it would duplicate the output — most
+ // visibly Ink's cursor-up redraws. The discard has to survive this fix.
+ context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
+ page = await context.newPage();
+ const sessionId = await openSession(page);
+ // Without this, a failed create passes the zero-hit assertion below
+ // vacuously — nothing was loaded, so nothing was replayed.
+ expect(sessionId).toBeTruthy();
+
+ expect(await runLoad(page, sessionId, 'history')).toBe(0);
+
+ await page.evaluate(
+ (sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
+ sessionId
+ );
+ await context.close();
+ }, 60_000);
+});
diff --git a/test/discard-partially-built-session.test.ts b/test/discard-partially-built-session.test.ts
new file mode 100644
index 00000000..012009f4
--- /dev/null
+++ b/test/discard-partially-built-session.test.ts
@@ -0,0 +1,131 @@
+/**
+ * `WebServer.discardPartiallyBuiltSession()` against the real server object.
+ *
+ * The reboot-restore route calls this when a rebuild registers a session and
+ * then fails to start its pane. It has to be the exact inverse of
+ * `registerSessionWithLayout()` plus `setupSessionListeners()`, and it must NOT
+ * be the user-initiated delete: banking the session's token totals, demoting a
+ * pinned record or deleting the workspace's files would all be wrong for a
+ * session that never ran.
+ *
+ * These tests drive the real method rather than the route, because the route
+ * tests run against a mock context whose `discardPartiallyBuiltSession` is a
+ * one-line stub — an earlier version of this function left four registrations
+ * behind and every route test still passed.
+ *
+ * The retry assertion is the important one. `setupSessionListeners()` returns
+ * early when `sessionListenerRefs` still holds the session id, so a discard that
+ * leaves that entry makes the next attempt wire nothing at all, and the user
+ * gets a tab that never shows output.
+ */
+import { mkdirSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import { safeRmHomeTree } from './mocks/test-helpers.js';
+
+import { WebServer } from '../src/web/server.js';
+import { Session } from '../src/session.js';
+import { TmuxManager } from '../src/tmux-manager.js';
+
+/** Reach the private collections the discard is responsible for emptying. */
+interface ServerInternals {
+ sessions: Map;
+ sessionListenerRefs: Map;
+ runSummaryTrackers: Map;
+ registerSessionWithLayout(session: Session): Promise;
+ setupSessionListeners(session: Session): Promise;
+ discardPartiallyBuiltSession(sessionId: string): Promise;
+}
+
+const WORKSPACE = join(homedir(), '.codeman-test-discard');
+const SESSION_ID = 'a1b2c3d4e5f60718';
+
+let server: WebServer;
+let internals: ServerInternals;
+let mux: TmuxManager;
+
+function buildSession(): Session {
+ return new Session({
+ id: SESSION_ID,
+ workingDir: WORKSPACE,
+ mode: 'claude',
+ name: 'rebuilt session',
+ mux,
+ useMux: true,
+ });
+}
+
+beforeAll(() => {
+ mkdirSync(WORKSPACE, { recursive: true });
+ // Test mode: no port is opened and no CLI is launched. One server for the file,
+ // stopped at the end: the constructor registers handlers on the module-level
+ // image, subagent, team and workflow watchers, and only stop() removes them.
+ server = new WebServer(0, false, true);
+ internals = server as unknown as ServerInternals;
+ mux = new TmuxManager();
+});
+
+afterEach(async () => {
+ await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
+});
+
+afterAll(async () => {
+ await server.stop().catch(() => {});
+ safeRmHomeTree(WORKSPACE);
+});
+
+describe('discarding a session whose pane never started', () => {
+ it('takes the session back out of the server', async () => {
+ const session = buildSession();
+ await internals.registerSessionWithLayout(session);
+ await internals.setupSessionListeners(session);
+ expect(internals.sessions.has(SESSION_ID)).toBe(true);
+
+ await internals.discardPartiallyBuiltSession(SESSION_ID);
+ expect(internals.sessions.has(SESSION_ID)).toBe(false);
+ });
+
+ it('releases the listener registration, so a retry can wire itself again', async () => {
+ const first = buildSession();
+ await internals.registerSessionWithLayout(first);
+ await internals.setupSessionListeners(first);
+ expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(true);
+
+ const firstRefs = internals.sessionListenerRefs.get(SESSION_ID);
+ await internals.discardPartiallyBuiltSession(SESSION_ID);
+ expect(internals.sessionListenerRefs.has(SESSION_ID)).toBe(false);
+
+ // The retry reuses the id by design. `setupSessionListeners()` returns early
+ // while the refs are still there, so a session built now would run blind: no
+ // terminal output, no status updates, no exit broadcast. Asserting a DIFFERENT
+ // refs object is what distinguishes wiring the retry from finding the corpse
+ // of the first attempt still in place.
+ const retry = buildSession();
+ await internals.registerSessionWithLayout(retry);
+ await internals.setupSessionListeners(retry);
+ const retryRefs = internals.sessionListenerRefs.get(SESSION_ID);
+ expect(retryRefs).toBeDefined();
+ expect(retryRefs).not.toBe(firstRefs);
+ });
+
+ it('stops the run-summary tracker, whose interval would otherwise keep firing', async () => {
+ const session = buildSession();
+ await internals.registerSessionWithLayout(session);
+ await internals.setupSessionListeners(session);
+ const tracker = internals.runSummaryTrackers.get(SESSION_ID) as { stop: () => void };
+ expect(tracker).toBeDefined();
+ // Dropping the map entry is not enough: the tracker arms a setInterval in its
+ // constructor, and only stop() clears it, so a discard that merely forgot the
+ // entry would leave the timer running for the life of the process.
+ const stopped = vi.spyOn(tracker, 'stop');
+
+ await internals.discardPartiallyBuiltSession(SESSION_ID);
+ expect(stopped).toHaveBeenCalled();
+ expect(internals.runSummaryTrackers.has(SESSION_ID)).toBe(false);
+ });
+
+ it('does nothing at all for a session it never registered', async () => {
+ await expect(internals.discardPartiallyBuiltSession('never-existed')).resolves.toBeUndefined();
+ });
+});
diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts
index 126c406d..abab770f 100644
--- a/test/install-sh-invariants.test.ts
+++ b/test/install-sh-invariants.test.ts
@@ -48,13 +48,12 @@ describe('install.sh generated-catalogue block', () => {
);
});
- it('declares every array the detection code indexes', () => {
+ it('declares every array install.sh actually reads', () => {
for (const name of [
'CLI_IDS',
'CLI_LABELS',
'CLI_ENABLED',
- 'CLI_KIND',
- 'CLI_NPM',
+ 'CLI_LAUNCHER_ONLY',
'CLI_DOCS',
'CLI_CMD_LINUX',
'CLI_CMD_DARWIN',
@@ -69,6 +68,17 @@ describe('install.sh generated-catalogue block', () => {
}
});
+ it('declares no array install.sh never reads', () => {
+ // CLI_KIND and CLI_NPM were generated and read by nothing (the .mjs/docker-hosts.ts
+ // producers read the JSON's `kind`/`npmPackage` fields directly; only these two bash
+ // arrays were dead). A generated-but-unread array is a maintenance trap the generator
+ // itself cannot warn about — it has no reader to check against — so this pins the
+ // opposite of the test above: naming what must NOT come back rather than what must.
+ for (const name of ['CLI_KIND', 'CLI_NPM']) {
+ expect(new RegExp(`^${name}=\\(`, 'm').test(SOURCE), `${name} is declared but nothing reads it`).toBe(false);
+ }
+ });
+
it('keeps no hand-written per-CLI detection behind', () => {
// The nine `*_SEARCH_PATHS` arrays and eighteen `check_`/`get__path` pairs are
// what this change removes. One left behind would be a second source of truth that the
@@ -93,6 +103,16 @@ describe('install.sh generated-catalogue block', () => {
[]
);
});
+
+ it('keeps no dead generic-lookup helpers behind', () => {
+ // _cli_index/check_cli/get_cli_path were the ungenericized precursor to the per-CLI
+ // helpers above: same shape, one level of indirection, called from nowhere once the
+ // catalogue-driven menu and hints stopped needing a lookup-by-id. Unlike the per-CLI
+ // pairs these are exact names, not derived from the catalogue.
+ for (const fn of ['_cli_index()', 'check_cli()', 'get_cli_path()']) {
+ expect(CODE.includes(fn), `${fn} should have been removed as dead code`).toBe(false);
+ }
+ });
});
describe('install.sh trust boundary', () => {
@@ -245,3 +265,42 @@ describe('install.sh AI CLI install menu', () => {
expect(run.status).toBe(1);
});
});
+
+describe('install.sh detect_all_clis and a disabled entry', () => {
+ // No stock entry ships disabled today, so this is characterization rather than a regression
+ // pin on real data: it drives the real function in a real bash with entry 0 fabricated
+ // disabled, and points its binary at `bash` — guaranteed resolvable via `command -v` — to
+ // prove the entry is genuinely never PROBED (CLI_FOUND_PATH stays empty) rather than merely
+ // filtered out downstream by every consumer's own `CLI_ENABLED` check.
+ function driveDetect(disableEntry0: boolean) {
+ const driver = `
+ set -euo pipefail
+ export CODEMAN_INSTALL_SH_LIB=1
+ . "$1"
+ k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
+ k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
+ # Point entry 0's first declared binary at something that WILL resolve, so a probe that
+ # runs at all finds it.
+ CLI_ALL_BINS[\${CLI_BIN_OFF[0]}]="bash"
+ ${disableEntry0 ? 'CLI_ENABLED[0]="0"' : ''}
+ CLI_DETECT_DONE=""
+ detect_all_clis
+ echo "path0=[\${CLI_FOUND_PATH[0]}]"
+ echo "found=$CLI_FOUND_COUNT"
+ `;
+ const result = spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000 });
+ return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
+ }
+
+ it('probes an enabled entry (control case)', () => {
+ const run = driveDetect(false);
+ expect(run.stdout, run.stderr).not.toContain('path0=[]');
+ expect(run.stdout).toContain('found=1');
+ });
+
+ it('never probes a disabled entry', () => {
+ const run = driveDetect(true);
+ expect(run.stdout, run.stderr).toContain('path0=[]');
+ expect(run.stdout).toContain('found=0');
+ });
+});
diff --git a/test/mocks/mock-route-context-completeness.test.ts b/test/mocks/mock-route-context-completeness.test.ts
new file mode 100644
index 00000000..effb98c8
--- /dev/null
+++ b/test/mocks/mock-route-context-completeness.test.ts
@@ -0,0 +1,28 @@
+/**
+ * The mock route context must offer everything the real one does.
+ *
+ * Route tests pass their context as `ctx as never`, and `tsconfig.json` includes
+ * only `src/**`, so no type check ever compares the mock against the ports. A
+ * port that gained a method left this mock missing it twice; both times the
+ * route under test threw a TypeError inside its own catch, and the suite
+ * reported a plausible-looking failure for an unrelated reason.
+ *
+ * So the comparison is made at runtime, against `WebServer.createRouteContext()`
+ * rather than against the port types, which is what keeps it from drifting: the
+ * server's own context object is the thing route modules are really given.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { WebServer } from '../../src/web/server.js';
+import { createMockRouteContext } from './mock-route-context.js';
+
+describe('the mock route context', () => {
+ it('offers every member the real route context does', () => {
+ const server = new WebServer(0, false, true);
+ const real = (server as unknown as { createRouteContext(): Record }).createRouteContext();
+ const mock = createMockRouteContext() as unknown as Record;
+
+ const missing = Object.keys(real).filter((key) => !(key in mock));
+ expect(missing, `mock-route-context.ts is missing: ${missing.join(', ')}`).toEqual([]);
+ });
+});
diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts
index 8a1bd884..9cb26d6a 100644
--- a/test/mocks/mock-route-context.ts
+++ b/test/mocks/mock-route-context.ts
@@ -61,6 +61,10 @@ export function createMockRouteContext(options?: {
setupSessionListeners: vi.fn(async () => {}),
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
+ reapplyPersistedSessionState: vi.fn(async () => {}),
+ discardPartiallyBuiltSession: vi.fn(async (id: string) => {
+ sessions.delete(id);
+ }),
getSessionStateWithRespawn: vi.fn((s: MockSession) => s.toState()),
// -- EventPort --
@@ -149,6 +153,7 @@ export function createMockRouteContext(options?: {
clearRespawnConfig: vi.fn(),
updateRespawnConfig: vi.fn(),
setHistoryLimit: vi.fn(async () => {}),
+ startStatsCollection: vi.fn(),
},
runSummaryTrackers: new Map(),
activePlanOrchestrators: new Map(),
diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts
index c0776813..5520c3a5 100644
--- a/test/mocks/mock-session.ts
+++ b/test/mocks/mock-session.ts
@@ -68,6 +68,9 @@ export class MockSession extends EventEmitter {
this.lastSubmitAt = Date.now();
}
+ /** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
+ trackUserInput(_data: string): void {}
+
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
diff --git a/test/reboot-restore.test.ts b/test/reboot-restore.test.ts
new file mode 100644
index 00000000..67bc34e9
--- /dev/null
+++ b/test/reboot-restore.test.ts
@@ -0,0 +1,395 @@
+/**
+ * @fileoverview The decision half of reboot restore, and proof that the existing
+ * recovery construction path can CREATE a resumed pane.
+ *
+ * Three things are under test. `src/reboot-restore.ts` decides whether the
+ * machine rebooted and which dead sessions may be offered back. The plan
+ * registry in `src/web/reboot-restore-registry.ts` holds that offer between the
+ * boot that builds it and the click that spends it. The third is the claim the
+ * whole feature rests on: a `Session` built the way `restoreMuxSessions()`
+ * already builds one, but given no `muxSession` and a `resumeSessionId`, creates
+ * a fresh pane that resumes the old conversation. If that holds, the restore
+ * needs no new session-creation service.
+ *
+ * `reconcileSessions()` reports every session ALIVE under vitest, so the
+ * server's own boot pass cannot be reached from here. The decision logic is
+ * therefore driven directly, and the construction claim is driven through a real
+ * `Session` against the in-memory tmux layer vitest substitutes.
+ */
+import { mkdirSync, rmSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { Session } from '../src/session.js';
+import { TmuxManager } from '../src/tmux-manager.js';
+import type { SessionState } from '../src/types.js';
+import {
+ looksLikeHostReboot,
+ newestPersistedActivity,
+ planRebootRestore,
+ rejectAlreadyLive,
+ resolveResumeConversationId,
+ type RebootRestoreEntry,
+} from '../src/reboot-restore.js';
+import { RebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
+
+const HOUR = 60 * 60 * 1000;
+const NOW = 1_760_000_000_000;
+
+function persistedSession(overrides: Partial & { id: string }): SessionState {
+ return {
+ // A live agent's record carries its process id; `/exit` persists null instead.
+ pid: 99999,
+ status: 'idle',
+ workingDir: '/tmp/spike',
+ currentTaskId: null,
+ createdAt: NOW - 4 * HOUR,
+ lastActivityAt: NOW - 2 * HOUR,
+ mode: 'claude',
+ ...overrides,
+ } as SessionState;
+}
+
+describe('reboot detection', () => {
+ const base = {
+ livePaneCount: 0,
+ deadSessionCount: 2,
+ // The host came up 10 minutes ago, well after the sessions were last active.
+ uptimeSeconds: 600,
+ newestPersistedActivityAt: NOW - 2 * HOUR,
+ now: NOW,
+ };
+
+ it('calls it a reboot when the socket is empty and the host booted after the last activity', () => {
+ expect(looksLikeHostReboot(base)).toBe(true);
+ });
+
+ it('refuses when some panes survived, which is an ordinary server restart', () => {
+ expect(looksLikeHostReboot({ ...base, livePaneCount: 3 })).toBe(false);
+ });
+
+ it('refuses on a long-uptime host, where someone wiped the tmux socket by hand', () => {
+ // Up for 30 days: the sessions were active long AFTER this boot, so the panes
+ // went away for some reason other than the machine restarting.
+ expect(looksLikeHostReboot({ ...base, uptimeSeconds: 30 * 24 * 60 * 60 })).toBe(false);
+ });
+
+ it('refuses when nothing died', () => {
+ expect(looksLikeHostReboot({ ...base, deadSessionCount: 0 })).toBe(false);
+ });
+
+ it('reads the newest activity stamp across the persisted records', () => {
+ const persisted = {
+ a: persistedSession({ id: 'a', lastActivityAt: NOW - 5 * HOUR }),
+ b: persistedSession({ id: 'b', lastActivityAt: NOW - 1 * HOUR }),
+ };
+ expect(newestPersistedActivity(persisted)).toBe(NOW - 1 * HOUR);
+ });
+});
+
+describe('which dead sessions may be rebuilt', () => {
+ it('rebuilds a session that was simply running when the power went out', () => {
+ const persisted = { live: persistedSession({ id: 'live', status: 'busy' }) };
+ const plan = planRebootRestore(['live'], persisted, () => true);
+ expect(plan.restore.map((s) => s.sessionId)).toEqual(['live']);
+ });
+
+ it('never revives a session the user killed while pinned (COD-142 demotes it to stopped)', () => {
+ const persisted = { killed: persistedSession({ id: 'killed', status: 'stopped', pinned: true }) };
+ const plan = planRebootRestore(['killed'], persisted, () => true);
+ expect(plan.restore).toEqual([]);
+ expect(plan.skipped).toEqual([{ sessionId: 'killed', reason: 'intentionally-ended' }]);
+ });
+
+ it('never revives a session whose record an unpinned kill already deleted', () => {
+ const plan = planRebootRestore(['gone'], {}, () => true);
+ expect(plan.restore).toEqual([]);
+ expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]);
+ });
+
+ it('never revives a pane whose PTY-exit breaker had tripped', () => {
+ const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) };
+ expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked');
+ });
+
+ it('leaves remote sessions to the COD-108 reconnect watcher', () => {
+ const persisted = {
+ r: persistedSession({
+ id: 'r',
+ remote: { hostId: 'h', host: 'example.test', username: 'u', sessionName: 'n', owned: true },
+ } as Partial & { id: string }),
+ };
+ expect(planRebootRestore(['r'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
+ });
+
+ it('leaves docker sessions alone, since the container may not be up', () => {
+ const persisted = {
+ d: persistedSession({ id: 'd', docker: { containerId: 'abc', caseId: 'c' } } as Partial & {
+ id: string;
+ }),
+ };
+ expect(planRebootRestore(['d'], persisted, () => true).skipped[0].reason).toBe('remote-or-docker');
+ });
+
+ it('skips a CLI whose history the claude transcript reader does not understand', () => {
+ const persisted = { c: persistedSession({ id: 'c', mode: 'codex' }) };
+ expect(planRebootRestore(['c'], persisted, () => true).skipped[0].reason).toBe('unsupported-mode');
+ });
+});
+
+describe('a session with no attach process in its record', () => {
+ it('is refused, because there was nothing running to bring back', () => {
+ // A session that never started, or whose pane died outright. NOT a session
+ // the user ended with `/exit`: that keeps its pid, because the pid is the
+ // tmux attach process and `remain-on-exit` keeps the pane alive.
+ const persisted = { exited: persistedSession({ id: 'exited', status: 'idle', pid: null }) };
+ const plan = planRebootRestore(['exited'], persisted, () => true);
+ expect(plan.restore).toEqual([]);
+ expect(plan.skipped).toEqual([{ sessionId: 'exited', reason: 'not-running' }]);
+ });
+
+ it('still restores the session beside it that was attached when the power went', () => {
+ const persisted = {
+ exited: persistedSession({ id: 'exited', pid: null }),
+ running: persistedSession({ id: 'running', pid: 4242 }),
+ };
+ const plan = planRebootRestore(['exited', 'running'], persisted, () => true);
+ expect(plan.restore.map((entry) => entry.sessionId)).toEqual(['running']);
+ expect(plan.skipped.map((s) => s.reason)).toEqual(['not-running']);
+ });
+
+ it('refuses a record with no pid field at all', () => {
+ const persisted = { odd: persistedSession({ id: 'odd', pid: undefined as unknown as null }) };
+ expect(planRebootRestore(['odd'], persisted, () => true).skipped[0].reason).toBe('not-running');
+ });
+});
+
+describe('a workspace that is no longer on disk', () => {
+ it('is kept out of the offer, so a click cannot scaffold a deleted repo', () => {
+ const persisted = { gone: persistedSession({ id: 'gone', workingDir: '/tmp/deleted-repo' }) };
+ const plan = planRebootRestore(['gone'], persisted, () => false);
+ expect(plan.restore).toEqual([]);
+ expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'workspace-missing' }]);
+ });
+
+ it('is judged per session, not for the batch', () => {
+ const persisted = {
+ kept: persistedSession({ id: 'kept', workingDir: '/tmp/still-here' }),
+ gone: persistedSession({ id: 'gone', workingDir: '/tmp/deleted-repo' }),
+ };
+ const plan = planRebootRestore(['kept', 'gone'], persisted, (dir) => dir === '/tmp/still-here');
+ expect(plan.restore.map((entry) => entry.sessionId)).toEqual(['kept']);
+ expect(plan.skipped.map((s) => s.reason)).toEqual(['workspace-missing']);
+ });
+});
+
+describe('a conversation that came back on its own before the click', () => {
+ const entry: RebootRestoreEntry = {
+ sessionId: 'abc',
+ workingDir: '/tmp/spike',
+ mode: 'claude',
+ resumeConversationId: 'conv-1',
+ state: persistedSession({ id: 'abc' }),
+ };
+
+ it('is skipped when the user resumed it by hand from the Resume list', () => {
+ // Same conversation, different session id: the Resume list creates a NEW id.
+ const result = rejectAlreadyLive([entry], new Set(['other']), new Set(['conv-1']));
+ expect(result.restore).toEqual([]);
+ expect(result.skipped).toEqual([{ sessionId: 'abc', reason: 'already-live' }]);
+ });
+
+ it('is skipped when a session with that id is already on the board', () => {
+ const result = rejectAlreadyLive([entry], new Set(['abc']), new Set());
+ expect(result.skipped).toEqual([{ sessionId: 'abc', reason: 'already-live' }]);
+ });
+
+ it('is rebuilt when neither its id nor its conversation is live', () => {
+ const result = rejectAlreadyLive([entry], new Set(['other']), new Set(['conv-other']));
+ expect(result.restore.map((e) => e.sessionId)).toEqual(['abc']);
+ expect(result.skipped).toEqual([]);
+ });
+});
+
+describe('the plan the banner spends', () => {
+ const all = () => true;
+ const entryFor = (sessionId: string, owner?: string): RebootRestoreEntry => ({
+ sessionId,
+ owner,
+ workingDir: '/tmp/spike',
+ mode: 'claude',
+ resumeConversationId: `conv-${sessionId}`,
+ state: persistedSession({ id: sessionId, owner }),
+ });
+
+ it('hands an entry to the first caller and nothing to the second', () => {
+ const registry = new RebootRestoreRegistry();
+ registry.set([entryFor('a'), entryFor('b')]);
+ expect(registry.take(all, undefined, undefined).map((e) => e.sessionId)).toEqual(['a', 'b']);
+ // The double-click: two panes on one conversation is what this prevents.
+ expect(registry.take(all, undefined, undefined)).toEqual([]);
+ });
+
+ it('spends only the ids a caller asked for', () => {
+ const registry = new RebootRestoreRegistry();
+ registry.set([entryFor('a'), entryFor('b')]);
+ expect(registry.take(all, ['b'], undefined).map((e) => e.sessionId)).toEqual(['b']);
+ expect(registry.list(all).map((e) => e.sessionId)).toEqual(['a']);
+ });
+
+ it("shows a user their own sessions and leaves another owner's alone", () => {
+ const registry = new RebootRestoreRegistry();
+ registry.set([entryFor('mine', 'alice'), entryFor('theirs', 'bob')]);
+ const asAlice = (owner: string | undefined) => owner === 'alice';
+ expect(registry.list(asAlice).map((e) => e.sessionId)).toEqual(['mine']);
+ expect(registry.take(asAlice, undefined, 'alice').map((e) => e.sessionId)).toEqual(['mine']);
+ // Bob's entry is still on offer for Bob.
+ expect(registry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
+ });
+
+ it('puts back an entry that no pane was created for', () => {
+ const registry = new RebootRestoreRegistry();
+ registry.set([entryFor('a')]);
+ const taken = registry.take(all, undefined, undefined);
+ registry.releaseFlight(undefined, taken);
+ expect(registry.list(all).map((e) => e.sessionId)).toEqual(['a']);
+ });
+
+ it('runs one restore at a time', () => {
+ const registry = new RebootRestoreRegistry();
+ expect(registry.beginSpending()).toBe(true);
+ expect(registry.beginSpending()).toBe(false);
+ registry.endSpending();
+ expect(registry.beginSpending()).toBe(true);
+ });
+
+ it('drops what a dismiss cleared', () => {
+ const registry = new RebootRestoreRegistry();
+ registry.set([entryFor('a'), entryFor('b')]);
+ expect(registry.clear(all)).toBe(2);
+ expect(registry.list(all)).toEqual([]);
+ });
+
+ it('forgets a plan nobody took for a day', () => {
+ const registry = new RebootRestoreRegistry();
+ registry.set([entryFor('a')]);
+ const dayLater = Date.now() + 25 * HOUR;
+ const realNow = Date.now;
+ Date.now = () => dayLater;
+ try {
+ expect(registry.list(all)).toEqual([]);
+ } finally {
+ Date.now = realNow;
+ }
+ });
+});
+
+describe('which conversation a rebuilt pane resumes', () => {
+ it('prefers the chain tail, the conversation the CLI reported last', () => {
+ const state = persistedSession({
+ id: 'sess-1',
+ resumeSessionId: 'launch-id',
+ claudeSessionChain: ['launch-id', 'after-clear'],
+ });
+ expect(resolveResumeConversationId(state)).toBe('after-clear');
+ });
+
+ it('falls back to the id the session originally resumed', () => {
+ const state = persistedSession({ id: 'sess-1', resumeSessionId: 'resumed-id' });
+ expect(resolveResumeConversationId(state)).toBe('resumed-id');
+ });
+
+ it('falls back to the session id, which is what Claude was launched with', () => {
+ expect(resolveResumeConversationId(persistedSession({ id: 'sess-1' }))).toBe('sess-1');
+ });
+});
+
+describe('the recovery construction path can create a resumed pane', () => {
+ const workingDir = join(homedir(), 'codeman-cases', 'reboot-restore-spike');
+ const sessions: Session[] = [];
+
+ afterEach(() => {
+ for (const s of sessions.splice(0)) s.stop();
+ rmSync(workingDir, { recursive: true, force: true });
+ });
+
+ /** Built exactly as the reboot pass builds one: no `muxSession`, plus a resume id. */
+ function rebuildFromPersistedState(state: SessionState, mux: TmuxManager): Session {
+ mkdirSync(workingDir, { recursive: true });
+ const session = new Session({
+ id: state.id,
+ workingDir,
+ mode: state.mode,
+ name: state.name,
+ createdAt: state.createdAt,
+ mux,
+ useMux: true,
+ resumeSessionId: resolveResumeConversationId(state),
+ owner: state.owner,
+ lastActivityAt: state.lastActivityAt,
+ claudeSessionChain: state.claudeSessionChain,
+ });
+ sessions.push(session);
+ return session;
+ }
+
+ it('creates a NEW mux session rather than needing one to attach to', async () => {
+ const mux = new TmuxManager();
+ const state = persistedSession({ id: 'aaaaaaa1-1111-4111-8111-111111111111', name: 'w1-spike' });
+ const session = rebuildFromPersistedState(state, mux);
+
+ expect(mux.getSessions()).toHaveLength(0);
+ await session.startInteractive();
+
+ const created = mux.getSessions();
+ expect(created).toHaveLength(1);
+ expect(created[0].sessionId).toBe('aaaaaaa1-1111-4111-8111-111111111111');
+ expect(created[0].workingDir).toBe(workingDir);
+ });
+
+ it('comes back pointed at the conversation the pane was holding', async () => {
+ const mux = new TmuxManager();
+ const state = persistedSession({
+ id: 'aaaaaaa2-2222-4222-8222-222222222222',
+ resumeSessionId: 'launch-id',
+ claudeSessionChain: ['launch-id', 'after-clear'],
+ });
+ const session = rebuildFromPersistedState(state, mux);
+
+ await session.startInteractive();
+
+ // The chain tail wins: a `/clear` before the reboot moved the CLI off the launch id.
+ expect(session.claudeSessionId).toBe('after-clear');
+ });
+
+ it('comes back idle, with no prompt sent and no autonomous loop armed', async () => {
+ const mux = new TmuxManager();
+ const state = persistedSession({
+ id: 'aaaaaaa3-3333-4333-8333-333333333333',
+ ralphEnabled: true,
+ respawnEnabled: true,
+ });
+ const session = rebuildFromPersistedState(state, mux);
+
+ await session.startInteractive();
+
+ // No prompt was queued: nothing is waiting on a task. The status itself is not
+ // assertable here, because the test PTY echoes and the activity detector reads
+ // that echo as work; in production the pane settles once the CLI finishes booting.
+ expect(session.currentTaskId).toBeNull();
+ // The pass never touches the tracker, so a persisted Ralph loop stays cold.
+ expect(session.ralphTracker.enabled).toBe(false);
+ });
+
+ it('keeps the owner it was persisted with, there being no request to read one from', async () => {
+ const mux = new TmuxManager();
+ const state = persistedSession({ id: 'aaaaaaa4-4444-4444-8444-444444444444', owner: 'alice' });
+ const session = rebuildFromPersistedState(state, mux);
+
+ await session.startInteractive();
+
+ expect(session.owner).toBe('alice');
+ expect(mux.getSessions()[0].owner).toBe('alice');
+ });
+});
diff --git a/test/routes/reboot-restore-rebuild-failure.test.ts b/test/routes/reboot-restore-rebuild-failure.test.ts
new file mode 100644
index 00000000..98cf7a17
--- /dev/null
+++ b/test/routes/reboot-restore-rebuild-failure.test.ts
@@ -0,0 +1,377 @@
+/**
+ * Reboot-restore route: what happens when a rebuild gets part-way and then fails.
+ *
+ * The other route test file deliberately uses workspaces that do not exist, so it
+ * never reaches `new Session()`. This one mocks the `Session` module so the route
+ * runs its whole construction path — `addSession`, `setupSessionListeners`,
+ * `reapplyPersistedSessionState`, `startInteractive` — and then throws.
+ *
+ * The mock is the only way in. Driven against a real server, `startInteractive()`
+ * does not throw for either obvious cause: 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. A mux-layer
+ * failure is what is left, and it cannot be provoked from a test. Without the
+ * mock this path would go unexercised, which is how the original version of this
+ * route shipped a session leak the tests could not see.
+ *
+ * It also covers the session caps, because those too are only reachable once the
+ * route is actually willing to build something.
+ */
+import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+import fastifyCookie from '@fastify/cookie';
+
+/** Set per test: whether the mocked `startInteractive()` rejects. */
+let startShouldThrow = false;
+/** Ordering log, so a test can assert what ran before the pane spawned. */
+const callOrder: string[] = [];
+
+vi.mock('../../src/session.js', () => ({
+ Session: class {
+ id: string;
+ mode: string;
+ name?: string;
+ workingDir: string;
+ owner?: string;
+ claudeSessionId: string | null = null;
+ constructor(config: { id: string; mode?: string; name?: string; workingDir: string; owner?: string }) {
+ this.id = config.id;
+ this.mode = config.mode ?? 'claude';
+ this.name = config.name;
+ this.workingDir = config.workingDir;
+ this.owner = config.owner;
+ }
+ async startInteractive() {
+ callOrder.push('startInteractive');
+ if (startShouldThrow) throw new Error('spawn claude ENOENT');
+ }
+ /** The mock route context projects a session through this on broadcast. */
+ toState() {
+ return { id: this.id, mode: this.mode, name: this.name, workingDir: this.workingDir, owner: this.owner };
+ }
+ },
+}));
+
+const { registerRebootRestoreRoutes } = await import('../../src/web/routes/reboot-restore-routes.js');
+const { rebootRestoreRegistry } = await import('../../src/web/reboot-restore-registry.js');
+const { installRouteErrorHandler } = await import('../../src/web/route-error-handler.js');
+const { httpStatusForErrorCode } = await import('../../src/types.js');
+const { createMockRouteContext } = await import('../mocks/index.js');
+type ApiErrorCode = import('../../src/types.js').ApiErrorCode;
+type RebootRestoreEntry = import('../../src/reboot-restore.js').RebootRestoreEntry;
+type SessionState = import('../../src/types.js').SessionState;
+
+/** A real directory, so the route's workspace checks pass and it reaches the build. */
+const WORKSPACE = process.cwd();
+
+function offerEntry(sessionId: string, owner?: string): RebootRestoreEntry {
+ return {
+ sessionId,
+ name: `session ${sessionId}`,
+ workingDir: WORKSPACE,
+ owner,
+ mode: 'claude',
+ resumeConversationId: `conv-${sessionId}`,
+ state: {
+ id: sessionId,
+ pid: null,
+ status: 'idle',
+ workingDir: WORKSPACE,
+ currentTaskId: null,
+ createdAt: 1_760_000_000_000,
+ mode: 'claude',
+ owner,
+ } as SessionState,
+ };
+}
+
+async function createHarness(ctx: ReturnType): Promise {
+ const app = Fastify({ logger: false });
+ await app.register(fastifyCookie);
+ registerRebootRestoreRoutes(app, ctx as never);
+ app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
+ if (!req.url.startsWith('/api')) return done(null, payload);
+ if (payload === null || typeof payload !== 'object') return done(null, payload);
+ const p = payload as { success?: unknown; errorCode?: unknown };
+ if (p.success === false) {
+ if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
+ reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
+ }
+ return done(null, payload);
+ }
+ if (p.success === true) return done(null, payload);
+ return done(null, { success: true, data: payload });
+ });
+ installRouteErrorHandler(app);
+ await app.ready();
+ return app;
+}
+
+beforeEach(() => {
+ startShouldThrow = false;
+ callOrder.length = 0;
+});
+
+afterEach(() => {
+ rebootRestoreRegistry.reset();
+ vi.clearAllMocks();
+});
+
+describe('a rebuild that fails after the session is registered', () => {
+ it('reports why it failed rather than blaming the workspace', async () => {
+ startShouldThrow = true;
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ const app = await createHarness(ctx);
+
+ const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
+ expect(res.restored).toEqual([]);
+ // Not `workspace-missing`: the directory is there, the agent would not start.
+ expect(res.skipped).toEqual([{ sessionId: 'a', reason: 'rebuild-failed' }]);
+ await app.close();
+ });
+
+ it('does not leave a registered session with no pane behind it', async () => {
+ startShouldThrow = true;
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ const app = await createHarness(ctx);
+
+ await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+ // The session reached ctx.sessions via addSession; the route has to take it
+ // back out, or the board shows a tab whose pane never existed.
+ expect(ctx.discardPartiallyBuiltSession).toHaveBeenCalledWith('a');
+ expect(ctx.sessions.has('a')).toBe(false);
+ // NOT the user-initiated delete: that would bank this session's historical
+ // tokens into the lifetime totals, demote a pinned record to `stopped`, and
+ // delete the workspace's .claude-images.
+ expect(ctx.cleanupSession).not.toHaveBeenCalled();
+ await app.close();
+ });
+
+ it('keeps the entry on offer, so the user can fix the PATH and click again', async () => {
+ startShouldThrow = true;
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ const app = await createHarness(ctx);
+
+ await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+ const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
+ await app.close();
+ });
+});
+
+describe('a rebuild that succeeds', () => {
+ it('re-applies the persisted state before the record is written again', async () => {
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ const app = await createHarness(ctx);
+
+ const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
+ expect(res.restored.map((s: { id: string }) => s.id)).toEqual(['a']);
+ // A session built from a record carries none of the pin, token totals or
+ // custom-model selection, so persisting it first would replace the fuller
+ // record with the reduced one.
+ expect(ctx.reapplyPersistedSessionState).toHaveBeenCalled();
+ const reapplyOrder = (ctx.reapplyPersistedSessionState as ReturnType).mock.invocationCallOrder[0];
+ const persistOrder = (ctx.persistSessionState as ReturnType).mock.invocationCallOrder[0];
+ expect(reapplyOrder).toBeLessThan(persistOrder);
+ await app.close();
+ });
+
+ it('shapes the pane before it spawns, and restores the history after', async () => {
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ (ctx.reapplyPersistedSessionState as ReturnType).mockImplementation(
+ async (_s: unknown, _saved: unknown, phase: string) => {
+ callOrder.push(`reapply:${phase}`);
+ }
+ );
+ (ctx.setupSessionListeners as ReturnType).mockImplementation(async () => {
+ callOrder.push('setupSessionListeners');
+ });
+ const app = await createHarness(ctx);
+
+ await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+ // `setupSessionListeners()` READS the image-watcher flag that `before-spawn`
+ // restores, so the phase has to precede it or the session comes back
+ // reporting the watcher as on with nothing watching. The custom-model
+ // environment has to reach the process, and the token totals must not land
+ // on a session whose pane never started.
+ expect(callOrder).toEqual([
+ 'reapply:before-spawn',
+ 'setupSessionListeners',
+ 'startInteractive',
+ 'reapply:after-spawn',
+ ]);
+ await app.close();
+ });
+
+ it('tells every other board about the rebuilt session', async () => {
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ const app = await createHarness(ctx);
+
+ await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+ expect(ctx.broadcast).toHaveBeenCalledWith('session:created', expect.anything());
+ await app.close();
+ });
+
+ it('spends the entry, so it is no longer on offer', async () => {
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ const app = await createHarness(ctx);
+
+ await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+ const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions).toEqual([]);
+ await app.close();
+ });
+});
+
+describe('the session caps', () => {
+ it('counts the sessions it is itself creating, not just the ones it started with', async () => {
+ rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ // One seat short of the documented maximum of 50, counting the session the
+ // mock context seeds. A check that ran once before the loop would restore
+ // BOTH entries; only a per-iteration check refuses the second.
+ for (let i = 0; i < 48; i += 1) {
+ ctx.sessions.set(`filler-${i}`, { id: `filler-${i}`, owner: undefined } as never);
+ }
+ const app = await createHarness(ctx);
+
+ const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
+ expect(res.restored.map((s: { id: string }) => s.id)).toEqual(['a']);
+ expect(res.skipped).toEqual([{ sessionId: 'b', reason: 'capacity-reached' }]);
+
+ // Refused rather than lost: closing a session and clicking again works.
+ const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['b']);
+ await app.close();
+ });
+
+ it('refuses every entry when the board is already at the cap', async () => {
+ rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ for (let i = 0; i < 50; i += 1) {
+ ctx.sessions.set(`filler-${i}`, { id: `filler-${i}`, owner: undefined } as never);
+ }
+ const app = await createHarness(ctx);
+
+ const res = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
+ expect(res.restored).toEqual([]);
+ expect(res.skipped.map((s: { reason: string }) => s.reason)).toEqual(['capacity-reached', 'capacity-reached']);
+ await app.close();
+ });
+});
+
+describe('a failure before any entry is considered', () => {
+ it('returns the whole plan rather than spending it', async () => {
+ rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ (ctx.getWorkspaceHooksEnabled as ReturnType).mockRejectedValue(new Error('settings unreadable'));
+ const app = await createHarness(ctx);
+
+ const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+ expect(res.statusCode).toBeGreaterThanOrEqual(500);
+
+ // The plan cannot be rebuilt once boot has pruned the records, so a throw
+ // anywhere in the route has to hand the entries back.
+ const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions.map((s: { id: string }) => s.id).sort()).toEqual(['a', 'b']);
+ await app.close();
+ });
+
+ it('releases the single flight, so the next click is not refused', async () => {
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ (ctx.getWorkspaceHooksEnabled as ReturnType).mockRejectedValue(new Error('settings unreadable'));
+ const app = await createHarness(ctx);
+
+ await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+ expect(rebootRestoreRegistry.beginSpending(undefined)).toBe(true);
+ rebootRestoreRegistry.endSpending(undefined);
+ await app.close();
+ });
+});
+
+describe('a dismiss that lands while a restore is running', () => {
+ it('wins when an admin is restoring the entries and their owner dismisses', async () => {
+ const theirs = offerEntry('theirs', 'bob');
+ rebootRestoreRegistry.set([theirs]);
+ // An admin may spend another user's entries, so the caller doing the restore
+ // and the owner of what is being restored are different people.
+ const taken = rebootRestoreRegistry.take(() => true, undefined, 'admin');
+ expect(taken.map((e) => e.sessionId)).toEqual(['theirs']);
+
+ // Bob dismisses his own banner. Nothing of his is in the plan any more, and
+ // the restore is running under a different name than his.
+ rebootRestoreRegistry.clear((owner) => owner === 'bob');
+ rebootRestoreRegistry.releaseFlight('admin', taken);
+
+ expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
+ });
+
+ it('wins when an admin dismisses everything mid-restore', async () => {
+ rebootRestoreRegistry.set([offerEntry('theirs', 'bob')]);
+ const taken = rebootRestoreRegistry.take(() => true, undefined, 'admin');
+
+ rebootRestoreRegistry.clear(() => true);
+ rebootRestoreRegistry.releaseFlight('admin', taken);
+
+ expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
+ });
+
+ it('wins, rather than being undone when the route hands its entries back', async () => {
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const ctx = createMockRouteContext({ workspaceHooksEnabled: false });
+ // The user clicks Dismiss while the restore is between its take and its
+ // return. Driven through the ROUTE, so removing the generation argument from
+ // the route would make this fail.
+ (ctx.getWorkspaceHooksEnabled as ReturnType).mockImplementation(async () => {
+ rebootRestoreRegistry.clear(() => true);
+ throw new Error('settings unreadable');
+ });
+ const app = await createHarness(ctx);
+
+ await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+
+ const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions).toEqual([]);
+ await app.close();
+ });
+
+ it('reaches an in-flight restore the dismisser can see, even once its entries are taken', async () => {
+ const mine = offerEntry('mine', 'alice');
+ rebootRestoreRegistry.set([mine]);
+ const taken = rebootRestoreRegistry.take((owner) => owner === 'alice', undefined, 'alice');
+ expect(taken).toHaveLength(1);
+
+ // The plan is empty now, so the dismiss has nothing of Alice's left in the
+ // plan; it has to reach the entry the restore is holding.
+ rebootRestoreRegistry.clear((owner) => owner === 'alice');
+ rebootRestoreRegistry.releaseFlight('alice', taken);
+
+ expect(rebootRestoreRegistry.list(() => true)).toEqual([]);
+ });
+
+ it('does not reach another owner, whose unspent entries still come back', async () => {
+ const mine = offerEntry('mine', 'alice');
+ const theirs = offerEntry('theirs', 'bob');
+ rebootRestoreRegistry.set([mine, theirs]);
+
+ // Bob is mid-restore, holding his own entry.
+ const bobsTaken = rebootRestoreRegistry.take((owner) => owner === 'bob', undefined, 'bob');
+ expect(bobsTaken.map((e) => e.sessionId)).toEqual(['theirs']);
+
+ // Alice dismisses her own banner meanwhile.
+ rebootRestoreRegistry.clear((owner) => owner === 'alice');
+
+ // Bob's restore finishes and hands his entry back. Alice's dismiss covered
+ // her entries, not his, so his offer survives.
+ rebootRestoreRegistry.releaseFlight('bob', bobsTaken);
+ expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['theirs']);
+ });
+});
diff --git a/test/routes/reboot-restore-routes.test.ts b/test/routes/reboot-restore-routes.test.ts
new file mode 100644
index 00000000..19067d21
--- /dev/null
+++ b/test/routes/reboot-restore-routes.test.ts
@@ -0,0 +1,248 @@
+/**
+ * Reboot-restore route tests (src/web/routes/reboot-restore-routes.ts) via
+ * app.inject(), no live port.
+ *
+ * Every entry these tests put on offer names a workspace that does not exist, so
+ * the route's click-time workspace check rejects it before any `Session` is
+ * constructed. That keeps the tests on the route's own guards — taking, scoping,
+ * single-flighting and re-checking — and leaves pane creation to
+ * test/reboot-restore.test.ts, which drives a real `Session` for it.
+ *
+ * The routes read the process-wide `rebootRestoreRegistry` singleton, so every
+ * test resets it; a leaked entry would bleed into the next one.
+ */
+import { describe, it, expect, afterEach, beforeEach } from 'vitest';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import Fastify, { type FastifyInstance } from 'fastify';
+import fastifyCookie from '@fastify/cookie';
+import { registerRebootRestoreRoutes } from '../../src/web/routes/reboot-restore-routes.js';
+import { rebootRestoreRegistry } from '../../src/web/reboot-restore-registry.js';
+import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
+import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
+import { createMockRouteContext } from '../mocks/index.js';
+import type { RebootRestoreEntry } from '../../src/reboot-restore.js';
+import type { SessionState } from '../../src/types.js';
+
+async function createHarness(authUser?: { username: string; role: 'admin' | 'user' }): Promise {
+ return createHarnessWithCtx(createMockRouteContext(), authUser);
+}
+
+async function createHarnessWithCtx(
+ ctx: ReturnType,
+ authUser?: { username: string; role: 'admin' | 'user' }
+): Promise {
+ const app = Fastify({ logger: false });
+ await app.register(fastifyCookie);
+ if (authUser) {
+ app.addHook('onRequest', async (req) => {
+ (req as unknown as { authUser: typeof authUser }).authUser = authUser;
+ });
+ }
+ registerRebootRestoreRoutes(app, ctx as never);
+
+ app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
+ if (!req.url.startsWith('/api')) return done(null, payload);
+ if (payload === null || typeof payload !== 'object') return done(null, payload);
+ const p = payload as { success?: unknown; errorCode?: unknown };
+ if (p.success === false) {
+ if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
+ reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
+ }
+ return done(null, payload);
+ }
+ if (p.success === true) return done(null, payload);
+ return done(null, { success: true, data: payload });
+ });
+
+ installRouteErrorHandler(app);
+ await app.ready();
+ return app;
+}
+
+/** An entry whose workspace is deliberately absent, so no pane is ever created. */
+function offerEntry(sessionId: string, owner?: string): RebootRestoreEntry {
+ return {
+ sessionId,
+ name: `session ${sessionId}`,
+ workingDir: `/tmp/codeman-reboot-restore-missing/${sessionId}`,
+ owner,
+ mode: 'claude',
+ resumeConversationId: `conv-${sessionId}`,
+ state: {
+ id: sessionId,
+ pid: null,
+ status: 'idle',
+ workingDir: `/tmp/codeman-reboot-restore-missing/${sessionId}`,
+ currentTaskId: null,
+ createdAt: 1_760_000_000_000,
+ mode: 'claude',
+ owner,
+ } as SessionState,
+ };
+}
+
+afterEach(() => {
+ rebootRestoreRegistry.reset();
+});
+
+describe('GET /api/reboot-restore', () => {
+ it('reports nothing when no reboot left anything behind', async () => {
+ const app = await createHarness();
+ const res = await app.inject({ method: 'GET', url: '/api/reboot-restore' });
+ expect(res.statusCode).toBe(200);
+ expect(res.json().data.sessions).toEqual([]);
+ await app.close();
+ });
+
+ it('names what is on offer, and says the scrollback is not coming back', async () => {
+ rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
+ const app = await createHarness();
+ const body = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(body.sessions.map((s: { id: string }) => s.id)).toEqual(['a', 'b']);
+ expect(body.scrollbackRestored).toBe(false);
+ await app.close();
+ });
+
+ it('never carries the persisted record itself to the browser', async () => {
+ rebootRestoreRegistry.set([offerEntry('a', 'alice')]);
+ const app = await createHarness({ username: 'alice', role: 'admin' });
+ const body = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(Object.keys(body.sessions[0]).sort()).toEqual(['id', 'mode', 'name', 'owner', 'workingDir']);
+ expect(body.sessions[0].state).toBeUndefined();
+ await app.close();
+ });
+});
+
+describe('POST /api/reboot-restore/restore', () => {
+ it('reports a workspace that is gone, and keeps offering it in case it comes back', async () => {
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ const app = await createHarness();
+
+ const first = (await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
+ expect(first.restored).toEqual([]);
+ expect(first.skipped).toEqual([{ sessionId: 'a', reason: 'workspace-missing' }]);
+
+ // Nothing was built, so the entry goes back: a repo can be restored from a
+ // backup between two clicks, and losing the offer would be unrecoverable.
+ const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
+ await app.close();
+ });
+
+ it('never re-offers a conversation that is already open', async () => {
+ const entry = offerEntry('a');
+ rebootRestoreRegistry.set([entry]);
+ const app = await createHarness();
+ const ctx = createMockRouteContext({ sessionId: entry.sessionId });
+ // A session with that id is live, which is what the Resume list would produce.
+ const liveApp = await createHarnessWithCtx(ctx);
+
+ const res = (await liveApp.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} })).json().data;
+ expect(res.skipped).toEqual([{ sessionId: 'a', reason: 'already-live' }]);
+
+ // Unlike a missing workspace, this one is dropped: it cannot stop being true.
+ const left = (await liveApp.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions).toEqual([]);
+ await liveApp.close();
+ await app.close();
+ });
+
+ it('spends only the sessions the click named', async () => {
+ rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
+ const app = await createHarness();
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/reboot-restore/restore',
+ payload: { sessionIds: ['b'] },
+ });
+ expect(res.json().data.skipped).toEqual([{ sessionId: 'b', reason: 'workspace-missing' }]);
+
+ // 'a' was never taken, and 'b' came back because no pane was built for it.
+ const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions.map((s: { id: string }) => s.id).sort()).toEqual(['a', 'b']);
+ await app.close();
+ });
+
+ it('refuses a body it does not recognise rather than guessing', async () => {
+ const app = await createHarness();
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/reboot-restore/restore',
+ payload: { sessionIds: 'not-an-array' },
+ });
+ expect(res.statusCode).toBeGreaterThanOrEqual(400);
+ await app.close();
+ });
+
+ it('turns a second concurrent restore away rather than interleaving it', async () => {
+ rebootRestoreRegistry.set([offerEntry('a')]);
+ // Claimed by a restore already in flight for this same owner (undefined in
+ // single-user mode, which is what the harness runs as).
+ expect(rebootRestoreRegistry.beginSpending(undefined)).toBe(true);
+ const app = await createHarness();
+ const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+ expect(res.statusCode).toBe(409);
+ rebootRestoreRegistry.endSpending(undefined);
+ await app.close();
+ });
+});
+
+describe('POST /api/reboot-restore/restore: multi-user workspace confinement', () => {
+ const saved: Record = {};
+ let realDir: string;
+
+ beforeEach(() => {
+ saved.CODEMAN_MULTIUSER = process.env.CODEMAN_MULTIUSER;
+ process.env.CODEMAN_MULTIUSER = '1';
+ // This branch sits AFTER the existsSync check, so the workspace has to be
+ // real for the confinement rule to be the thing that rejects the entry.
+ realDir = mkdtempSync(join(tmpdir(), 'codeman-reboot-restore-real-'));
+ });
+
+ afterEach(() => {
+ if (saved.CODEMAN_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
+ else process.env.CODEMAN_MULTIUSER = saved.CODEMAN_MULTIUSER;
+ rmSync(realDir, { recursive: true, force: true });
+ });
+
+ it("refuses a workspace outside the OWNER's case space, and leaves it on offer", async () => {
+ const entry = offerEntry('a', 'alice');
+ entry.workingDir = realDir;
+ (entry.state as { workingDir: string }).workingDir = realDir;
+ rebootRestoreRegistry.set([entry]);
+
+ // An admin does the clicking. The confinement is still resolved against
+ // alice, the entry's OWNER: `isWorkingDirAllowed` waves an admin through, so
+ // reading the caller here would hand an admin the power to rebuild another
+ // user's session anywhere on the box.
+ const app = await createHarness({ username: 'root-user', role: 'admin' });
+ const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/restore', payload: {} });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.json().data.restored).toEqual([]);
+ expect(res.json().data.skipped).toEqual([{ sessionId: 'a', reason: 'workspace-forbidden' }]);
+
+ // A withdrawn grant can be given back, so unlike `already-live` this is not
+ // the permanent kind of refusal and the entry stays claimable.
+ const left = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(left.sessions.map((s: { id: string }) => s.id)).toEqual(['a']);
+ await app.close();
+ });
+});
+
+describe('POST /api/reboot-restore/dismiss', () => {
+ it('drops the offer and leaves the banner with nothing to show', async () => {
+ rebootRestoreRegistry.set([offerEntry('a'), offerEntry('b')]);
+ const app = await createHarness();
+
+ const res = await app.inject({ method: 'POST', url: '/api/reboot-restore/dismiss', payload: {} });
+ expect(res.json().data.dismissed).toBe(2);
+
+ const after = (await app.inject({ method: 'GET', url: '/api/reboot-restore' })).json().data;
+ expect(after.sessions).toEqual([]);
+ await app.close();
+ });
+});
diff --git a/test/routes/session-name-routes.test.ts b/test/routes/session-name-routes.test.ts
new file mode 100644
index 00000000..ba161759
--- /dev/null
+++ b/test/routes/session-name-routes.test.ts
@@ -0,0 +1,60 @@
+/**
+ * @fileoverview PUT /api/sessions/:id/name hands the name to the user (#376).
+ *
+ * A rename flips `nameSource` to `manual`, persists it and broadcasts it, so
+ * auto-naming can never overwrite a name a person chose, on this server or
+ * on the one that restores the session after a restart.
+ *
+ * Uses app.inject() — no real HTTP ports needed.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
+import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
+import { Session } from '../../src/session.js';
+import { SseEvent } from '../../src/web/sse-events.js';
+
+describe('PUT /api/sessions/:id/name', () => {
+ let harness: RouteTestHarness;
+ let session: Session;
+ const updateSessionName = vi.fn(() => true);
+
+ beforeAll(async () => {
+ harness = await createRouteTestHarness(registerSessionRoutes);
+ // A REAL session, since the ownership flag lives on the class, not the mock.
+ session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
+ harness.ctx.sessions.set(session.id, session as never);
+ (harness.ctx.mux as Record).updateSessionName = updateSessionName;
+ });
+
+ afterAll(async () => {
+ await harness.app.close();
+ });
+
+ it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
+ expect(session.nameSource).toBe('placeholder');
+
+ const res = await harness.app.inject({
+ method: 'PUT',
+ url: `/api/sessions/${session.id}/name`,
+ payload: { name: 'my window' },
+ });
+
+ expect(res.statusCode).toBe(200);
+ // The harness registers the bare route; the {success,data} envelope is a server-level hook.
+ expect(res.json()).toMatchObject({ name: 'my window' });
+ expect(session.name).toBe('my window');
+ expect(session.nameSource).toBe('manual');
+ expect(session.applyAutoName('w1-demo: fix it')).toBe(false);
+ expect(session.name).toBe('my window');
+
+ expect(updateSessionName).toHaveBeenCalledWith(session.id, 'my window');
+ expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
+ expect(harness.ctx.broadcast).toHaveBeenCalledWith(
+ SseEvent.SessionUpdated,
+ expect.objectContaining({ id: session.id, name: 'my window', nameSource: 'manual' })
+ );
+ // What the restore path will read back: the persisted state carries the flag.
+ expect(session.toState().nameSource).toBe('manual');
+ });
+});
diff --git a/test/session-auto-name.test.ts b/test/session-auto-name.test.ts
new file mode 100644
index 00000000..7d997071
--- /dev/null
+++ b/test/session-auto-name.test.ts
@@ -0,0 +1,274 @@
+/**
+ * @fileoverview Auto-naming a session after its first prompt (#376).
+ *
+ * The tracker sits on the raw keystroke stream, so most of these pin the
+ * per-key rules that a review of the first cut found missing: a bare Esc ate
+ * the next prompt's first character, a wheel report mid-word dropped half the
+ * prompt, pasted newlines counted as Enter, and every prompt renamed the tab.
+ *
+ * Port: N/A (no server needed)
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { Session } from '../src/session.js';
+import {
+ SubmittedPromptTracker,
+ deriveAutoSessionName,
+ composeAutoSessionName,
+ isGeneratedSessionName,
+} from '../src/session-auto-name.js';
+
+describe('SubmittedPromptTracker', () => {
+ it('reports the draft on Enter across arbitrary chunks, honouring backspace', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the')).toEqual([]);
+ expect(tracker.feed(' login bugs\x7f')).toEqual([]);
+ expect(tracker.feed('\r')).toEqual(['fix the login bug']);
+ expect(tracker.feed('\r')).toEqual([]);
+ expect(tracker.feed('修复登录跳转\x08问题\r')).toEqual(['修复登录跳问题']);
+ });
+
+ it('treats a bare Esc as the Esc key, not the start of a sequence', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('\x1b');
+ expect(tracker.feed('fix the login bug\r')).toEqual(['fix the login bug']);
+ tracker.feed('\x1b');
+ expect(tracker.feed('修复登录\r')).toEqual(['修复登录']);
+ // Esc then digits and punctuation used to grow the escape buffer without bound.
+ tracker.feed('\x1b');
+ expect(tracker.feed('12345, ok?\r')).toEqual(['12345, ok?']);
+ // A double Esc is two Esc keys, each its own write (in ONE chunk, `ESC s`
+ // is Alt+s by the terminal's own encoding and stays swallowed).
+ tracker.feed('\x1b');
+ tracker.feed('\x1b');
+ expect(tracker.feed('still here\r')).toEqual(['still here']);
+ });
+
+ it('swallows Alt chords and turns Alt+Enter into a newline in the draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix\x1bb the\x1b\rbug\r')).toEqual(['fix the bug']);
+ });
+
+ it('ignores cursor keys, mouse and focus reports, Shift+Tab and Tab', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the \x1b[<64;10;5M\x1b[<65;10;5mlogin bug\r')).toEqual(['fix the login bug']);
+ expect(tracker.feed('look at @src/ses\tsion.ts and fix it\r')).toEqual(['look at @src/session.ts and fix it']);
+ expect(tracker.feed('typo\x1b[D\x1b[C\x1b[H\x1b[F\x1b[3~\x1b[Z\x1b[I\x1b[O\x1bOC fixed\r')).toEqual(['typo fixed']);
+ expect(tracker.feed('mod\x1b[1;5D\x1b[1;2Cifiers\r')).toEqual(['modifiers']);
+ });
+
+ it('taints the draft on history recall so Enter submits nothing rather than a fragment', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('old text\x1b[A and more\r')).toEqual([]);
+ expect(tracker.feed('\x1bOB\r')).toEqual([]);
+ expect(tracker.feed('\x1b[1;5A\r')).toEqual([]);
+ expect(tracker.feed('\x10x\r')).toEqual([]);
+ expect(tracker.feed('\x12search\r')).toEqual([]);
+ expect(tracker.feed('fresh prompt\r')).toEqual(['fresh prompt']);
+ // Ctrl+C empties the composer, which also clears the taint.
+ expect(tracker.feed('stale\x1b[A\x03typed after\r')).toEqual(['typed after']);
+ });
+
+ it('keeps bracketed-paste newlines inside the draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('\x1b[200~line one\nline two\r\nline three\x1b[201~ plus typed\r')).toEqual([
+ 'line one line two line three plus typed',
+ ]);
+ // A paste split across chunks stays a paste.
+ tracker.feed('\x1b[200~first\r');
+ expect(tracker.feed('second\x1b[201~\r')).toEqual(['first second']);
+ });
+
+ it('joins a Shift+Enter / Ctrl+J newline with a space', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('Fix the login bug');
+ tracker.feed('\n');
+ expect(tracker.feed('Also add tests.\r')).toEqual(['Fix the login bug Also add tests.']);
+ });
+
+ it('mirrors Ctrl+W, Ctrl+U and Ctrl+C', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('fix the bugs\x17bug\r')).toEqual(['fix the bug']);
+ expect(tracker.feed('discarded\x15kept\r')).toEqual(['kept']);
+ expect(tracker.feed('discarded\x03kept\r')).toEqual(['kept']);
+ });
+
+ it('keeps the HEAD of an over-long draft', () => {
+ const tracker = new SubmittedPromptTracker();
+ const [prompt] = tracker.feed(`${'a'.repeat(9000)}\r`);
+ expect(prompt).toHaveLength(8192);
+ // Backspaces past the cap consume the overflow before the kept text.
+ const [again] = tracker.feed(`${'b'.repeat(8200)}${'\x7f'.repeat(10)}\r`);
+ expect(again).toHaveLength(8190);
+ });
+
+ it('abandons a malformed escape without eating the text, and taints on an over-long one', () => {
+ const tracker = new SubmittedPromptTracker();
+ expect(tracker.feed('\x1b[修复\r')).toEqual(['修复']);
+ expect(tracker.feed('\x1b]0;window title\x07hello\r')).toEqual(['hello']);
+ // Nothing a terminal sends runs past 64 bytes; the tail is garbage, not a title.
+ expect(tracker.feed(`\x1b]${'x'.repeat(80)}after\r`)).toEqual([]);
+ expect(tracker.feed('next prompt\r')).toEqual(['next prompt']);
+ });
+
+ it('resumes a CSI split across chunks', () => {
+ const tracker = new SubmittedPromptTracker();
+ tracker.feed('abc\x1b[');
+ expect(tracker.feed('Ddef\r')).toEqual(['abcdef']);
+ });
+});
+
+describe('deriveAutoSessionName', () => {
+ it('takes the first sentence, drops the full stop, and bounds the length', () => {
+ expect(deriveAutoSessionName('Fix the login bug. Also add tests.')).toBe('Fix the login bug');
+ expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题');
+ expect(deriveAutoSessionName('Why does this crash? It worked before')).toBe('Why does this crash?');
+ expect(deriveAutoSessionName('Run v2.0 tests. Then deploy')).toBe('Run v2.0 tests');
+ expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
+ const cut = deriveAutoSessionName('word '.repeat(40).trim()) ?? '';
+ expect(cut.endsWith('…')).toBe(true);
+ expect(cut).toMatch(/^(word )+word…$/);
+ });
+
+ it('does not cut on an abbreviation early in the prompt', () => {
+ expect(deriveAutoSessionName('e.g. fix this now')).toBe('e.g. fix this now');
+ expect(deriveAutoSessionName('Ok. Fix the login bug')).toBe('Ok. Fix the login bug');
+ });
+
+ it('returns null for commands and empties, but not for paths', () => {
+ expect(deriveAutoSessionName('/clear')).toBeNull();
+ expect(deriveAutoSessionName('/model opus')).toBeNull();
+ expect(deriveAutoSessionName('/ralph-loop:ralph-loop')).toBeNull();
+ expect(deriveAutoSessionName('! npm test')).toBeNull();
+ expect(deriveAutoSessionName(' ')).toBeNull();
+ expect(deriveAutoSessionName('/home/me/notes.txt what is this')).toBe('/home/me/notes.txt what is this');
+ });
+
+ it('strips control bytes and ANSI before the title is persisted', () => {
+ expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
+ expect(deriveAutoSessionName('a\x00b\tc')).toBe('a b c');
+ });
+});
+
+describe('composeAutoSessionName', () => {
+ it('keeps the placeholder as a prefix so the case and the counter survive', () => {
+ expect(composeAutoSessionName('w3-myapp', 'fix the login bug')).toBe('w3-myapp: fix the login bug');
+ expect(composeAutoSessionName('', 'fix the login bug')).toBe('fix the login bug');
+ });
+
+ it('honours the rename cap in UTF-16 units', () => {
+ const name = composeAutoSessionName('w3-myapp', '😀'.repeat(100), 40);
+ expect(name.length).toBeLessThanOrEqual(40);
+ expect(name.startsWith('w3-myapp: ')).toBe(true);
+ expect(name.endsWith('…')).toBe(true);
+ expect(composeAutoSessionName('x'.repeat(127), 'title', 128)).toBe('x'.repeat(127));
+ });
+
+ it('recognises only the generated w/s + number + case form', () => {
+ expect(isGeneratedSessionName('w12-my_case-2')).toBe(true);
+ expect(isGeneratedSessionName('s1-shell')).toBe(true);
+ expect(isGeneratedSessionName('w1-case: fix it')).toBe(false);
+ expect(isGeneratedSessionName('alpha')).toBe(false);
+ });
+});
+
+describe('Session name ownership', () => {
+ it('infers placeholder vs manual from the name and persists the source', () => {
+ const placeholder = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(placeholder.nameSource).toBe('placeholder');
+ expect(placeholder.toState().nameSource).toBe('placeholder');
+ expect(new Session({ workingDir: '/tmp' }).nameSource).toBe('placeholder');
+ expect(new Session({ workingDir: '/tmp', name: 'my window' }).nameSource).toBe('manual');
+ expect(new Session({ workingDir: '/tmp', name: 'w1-demo: fix it' }).nameSource).toBe('manual');
+ // The boot restore passes the persisted source, which outranks the inference.
+ const recovered = new Session({ workingDir: '/tmp', name: 'w1-demo: fix it', nameSource: 'auto' });
+ expect(recovered.nameSource).toBe('auto');
+ expect(recovered.applyAutoName('w1-demo: other')).toBe(false);
+ });
+
+ it('names once: the first prompt takes it, later prompts and renames do not', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(session.applyAutoName('w1-demo: fix the login bug')).toBe(true);
+ expect(session.name).toBe('w1-demo: fix the login bug');
+ expect(session.nameSource).toBe('auto');
+ expect(session.applyAutoName('w1-demo: 1')).toBe(false);
+ expect(session.name).toBe('w1-demo: fix the login bug');
+
+ session.name = 'mine';
+ expect(session.nameSource).toBe('manual');
+ expect(session.applyAutoName('other')).toBe(false);
+ expect(session.name).toBe('mine');
+ });
+
+ it('consumes the first prompt even when the composed name is unchanged', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ expect(session.applyAutoName('w1-demo')).toBe(false);
+ expect(session.nameSource).toBe('auto');
+ });
+});
+
+describe('Session promptSubmitted', () => {
+ function withFakePty(session: Session): ReturnType {
+ const write = vi.fn();
+ (session as unknown as { ptyProcess: { write: typeof write } }).ptyProcess = { write };
+ return write;
+ }
+
+ it('emits for user input only, after the bytes reached the PTY', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+
+ // No PTY yet: the write fails and nothing is reported.
+ expect(session.write('lost\r', { fromUser: true })).toBe(false);
+ expect(prompts).toEqual([]);
+
+ const write = withFakePty(session);
+ expect(session.write('Read @ralph_prompt.md and follow the instructions.\r')).toBe(true);
+ expect(prompts).toEqual([]);
+ expect(session.write('fix the ', { fromUser: true })).toBe(true);
+ expect(session.write('login bug\r', { fromUser: true })).toBe(true);
+ expect(prompts).toEqual(['fix the login bug']);
+ expect(write).toHaveBeenCalledTimes(3);
+ // The pane's last-Enter stamp is kept for EVERY write, user or not.
+ expect(session.lastSubmitAt).toBeGreaterThan(0);
+ });
+
+ it('never feeds the tracker for a shell session', () => {
+ const session = new Session({ workingDir: '/tmp', name: 's1-demo', mode: 'shell' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ withFakePty(session);
+ expect(session.write('ls -la\r', { fromUser: true })).toBe(true);
+ session.trackUserInput('cd src\r');
+ expect(prompts).toEqual([]);
+ });
+
+ it('feeds the send-key line feed so a two-line prompt keeps its separator', () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ withFakePty(session);
+ session.write('Fix the login bug', { fromUser: true });
+ session.trackUserInput('\n');
+ session.write('Also add tests.\r', { fromUser: true });
+ expect(prompts).toEqual(['Fix the login bug Also add tests.']);
+ });
+
+ it('reports through writeViaMux only when the mux accepted the input', async () => {
+ const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
+ const prompts: string[] = [];
+ session.on('promptSubmitted', (p: string) => prompts.push(p));
+ const sendInput = vi.fn(async () => false);
+ (session as unknown as { _mux: unknown; _muxSession: unknown })._mux = { sendInput };
+ (session as unknown as { _mux: unknown; _muxSession: unknown })._muxSession = { sessionId: session.id };
+
+ expect(await session.writeViaMux('dropped\r', { fromUser: true })).toBe(false);
+ expect(prompts).toEqual([]);
+ sendInput.mockResolvedValue(true);
+ expect(await session.writeViaMux('delivered\r', { fromUser: true })).toBe(true);
+ expect(prompts).toEqual(['delivered']);
+ expect(await session.writeViaMux('/clear\r')).toBe(true);
+ expect(prompts).toEqual(['delivered']);
+ });
+});
diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts
index 74ea312c..1173fecb 100644
--- a/test/session-listener-wiring.test.ts
+++ b/test/session-listener-wiring.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
+import { SseEvent } from '../src/web/sse-events.js';
describe('session listener wiring', () => {
it('forwards the attachment request source through registerAttachment', async () => {
@@ -20,4 +21,70 @@ describe('session listener wiring', () => {
);
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
});
+
+ /** The listener reads the setting asynchronously; let its promise chain settle. */
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
+
+ function autoNameDeps(session: Session, enabled: boolean) {
+ const deps = {
+ updateSessionName: vi.fn(() => true),
+ persistSessionState: vi.fn(),
+ broadcast: vi.fn(),
+ getSessionStateWithRespawn: vi.fn(() => session.toState()),
+ isAutoNameEnabled: vi.fn(async () => enabled),
+ };
+ return {
+ deps,
+ refs: createSessionListeners(session, deps as unknown as Parameters[1]),
+ };
+ }
+
+ it('names a placeholder tab after its first real prompt, in the prefix form, once', async () => {
+ const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
+ const { deps, refs } = autoNameDeps(session, true);
+
+ // A slash command yields no title and leaves the session eligible; the
+ // setting is not even read for it.
+ refs.promptSubmitted('/clear');
+ await flush();
+ expect(deps.isAutoNameEnabled).not.toHaveBeenCalled();
+ expect(session.name).toBe('w1-demo');
+
+ refs.promptSubmitted('整理登录模块并补充测试');
+ await flush();
+ expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
+ expect(session.nameSource).toBe('auto');
+ expect(deps.updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', 'w1-demo: 整理登录模块并补充测试');
+ expect(deps.persistSessionState).toHaveBeenCalledWith(session);
+ expect(deps.broadcast).toHaveBeenCalledWith(
+ SseEvent.SessionUpdated,
+ expect.objectContaining({ name: 'w1-demo: 整理登录模块并补充测试', nameSource: 'auto' })
+ );
+
+ // The second prompt never reaches the setting: the tab is named.
+ refs.promptSubmitted('1');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
+ expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
+ });
+
+ it('leaves the tab alone while the setting is off, and never touches a manual name', async () => {
+ const session = new Session({ id: 'wiring-auto-name-off', workingDir: '/tmp', name: 'w1-demo' });
+ const { deps, refs } = autoNameDeps(session, false);
+
+ refs.promptSubmitted('fix the login bug');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
+ expect(session.name).toBe('w1-demo');
+ // Still a placeholder: flipping the setting on names the NEXT prompt.
+ expect(session.nameSource).toBe('placeholder');
+ expect(deps.updateSessionName).not.toHaveBeenCalled();
+
+ session.name = '人工命名';
+ refs.promptSubmitted('新的任务不能覆盖人工命名');
+ await flush();
+ expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
+ expect(session.name).toBe('人工命名');
+ expect(deps.persistSessionState).not.toHaveBeenCalled();
+ });
});
diff --git a/test/terminal-buffer-flush.test.ts b/test/terminal-buffer-flush.test.ts
index 54267beb..bacd2923 100644
--- a/test/terminal-buffer-flush.test.ts
+++ b/test/terminal-buffer-flush.test.ts
@@ -1,20 +1,31 @@
/**
- * @fileoverview Regression tests for the buffer-load flush path (COD-144).
+ * @fileoverview Regression tests for the buffer-load flush path: what becomes of
+ * the live terminal events queued while a buffer load runs, once the load ends.
*
- * Bug: newly launched Shell sessions rendered BLANK until a tab-switch. The
- * buffer-load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`)
- * QUEUES live SSE terminal events while `_isLoadingBuffer` is true, then on
- * completion DISCARDS the queue (`_loadBufferQueue = null`). That de-dup is
- * correct for an established session (the fetched buffer already contains the
- * queued output, so replaying it would duplicate Ink redraws). But for a
- * brand-new shell the fetch resolves BEFORE the PTY emits its prompt — the
- * fetched buffer is empty and the prompt arrives only as a queued event, which
- * then gets discarded → blank terminal.
+ * Two rules, each from a real bug.
*
- * Fix: `_finishBufferLoad(owner, { flushQueued })` REPLAYS the queued events
- * through `batchTerminalWrite()` (after `_isLoadingBuffer` is cleared, so they
- * write through normally) ONLY when the load painted nothing. The default path
- * (no opts) still discards, preserving de-dup for established sessions.
+ * COD-144: newly launched Shell sessions rendered BLANK until a tab-switch. The
+ * load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`) queues
+ * live events while `_isLoadingBuffer` is true and used to DISCARD the queue on
+ * completion. Right for a buffer built from the server's byte history (the
+ * queued output is already in it, so replaying it duplicates Ink redraws),
+ * wrong for a brand-new shell whose fetch resolves BEFORE the PTY emits its
+ * prompt: the prompt arrived only as a queued event and was thrown away. A
+ * caller that knows the load painted nothing passes `{ flushQueued: true }`
+ * and the queue is REPLAYED through `batchTerminalWrite()` after
+ * `_isLoadingBuffer` is cleared, so the events write through normally.
+ *
+ * #436: a tmux pane capture is current only as of the instant `capture-pane`
+ * ran, so everything the CLI printed between the capture and the end of the
+ * chunked write was queued and dropped, and its next partial redraw landed on
+ * a frame the terminal never received. Queue entries now carry their arrival
+ * time and `_finishBufferLoad` takes a `since` cutoff, so a capture load
+ * replays exactly the tail that arrived after the response headers. All four
+ * fetch-and-write paths take that policy from one helper,
+ * `_bufferLoadFinishOpts`, and a static scan below pins each of them to it,
+ * because the same fix had already been written into one path out of four,
+ * twice. A path that replays and then restores a scroll position re-takes the
+ * sticky-scroll baseline (`_syncStickyScrollBaseline`), pinned the same way.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts). We extract the REAL
@@ -56,10 +67,10 @@ type BufferLoadApp = {
_bufferLoadSeq: number;
_bufferLoadOwner: string | null;
_isLoadingBuffer: boolean;
- _loadBufferQueue: string[] | null;
+ _loadBufferQueue: { at: number; data: string }[] | null;
batchTerminalWrite: (data: string) => void;
_beginBufferLoad: (owner?: string) => string;
- _finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean }) => boolean;
+ _finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean; since?: number }) => boolean;
};
/**
@@ -84,10 +95,57 @@ function makeApp() {
return { app, writes };
}
-/** Simulate live SSE events arriving while a buffer load is in progress (the queue path). */
-function pushWhileLoading(app: BufferLoadApp, data: string) {
- // Mirrors batchTerminalWrite's queue branch: if loading, push to the queue.
- if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push(data);
+/**
+ * A stub carrying the REAL `batchTerminalWrite` on top of the real begin/finish
+ * methods, so a replay samples the sticky-scroll baseline exactly as it does in
+ * the browser. The terminal is a fake whose `buffer.active` the test moves by
+ * hand, which is what a caller's `scrollToLine` does to a real one.
+ */
+function makeScrollApp() {
+ const buffer = { viewportY: 0, baseY: 100 };
+ const app = {
+ buffer,
+ terminal: { buffer: { active: buffer } },
+ sessions: new Map(),
+ activeSessionId: null,
+ pendingWrites: [] as string[],
+ writeFrameScheduled: false,
+ _wasAtBottomBeforeWrite: false,
+ _bufferLoadSeq: 0,
+ _bufferLoadOwner: null as string | null,
+ _isLoadingBuffer: false,
+ _loadBufferQueue: null as { at: number; data: string }[] | null,
+ _scheduleTerminalWriteFlush: vi.fn(),
+ batchTerminalWrite: mixin.batchTerminalWrite as (data: string) => void,
+ isTerminalAtBottom: mixin.isTerminalAtBottom as () => boolean,
+ _syncStickyScrollBaseline: mixin._syncStickyScrollBaseline as () => void,
+ _beginBufferLoad: mixin._beginBufferLoad as BufferLoadApp['_beginBufferLoad'],
+ _finishBufferLoad: mixin._finishBufferLoad as BufferLoadApp['_finishBufferLoad'],
+ };
+ return app;
+}
+
+/**
+ * Slice one class method out of app.js, from its header to the next method's.
+ *
+ * Bounding the slice matters: the two methods checked below are not followed by
+ * a JSDoc block, so a scan for the next comment would run on into unrelated
+ * code and match its scroll calls instead of theirs.
+ */
+function methodBody(source: string, method: string): string {
+ const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
+ expect(start, `${method} not found in app.js`).toBeGreaterThan(-1);
+ const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
+ return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
+}
+
+/**
+ * Simulate a live SSE event arriving while a buffer load is in progress.
+ * Mirrors batchTerminalWrite's queue branch, which stamps each entry with its
+ * arrival time so a flush can replay only the tail (see the `since` tests).
+ */
+function pushWhileLoading(app: BufferLoadApp, data: string, at = performance.now()) {
+ if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push({ at, data });
}
describe('buffer-load flush (COD-144)', () => {
@@ -153,11 +211,165 @@ describe('buffer-load flush (COD-144)', () => {
// State untouched — still loading, queue intact, nothing replayed.
expect(app._isLoadingBuffer).toBe(true);
expect(app._bufferLoadOwner).toBe('real-owner');
- expect(app._loadBufferQueue).toEqual(['queued']);
+ expect(app._loadBufferQueue).toEqual([{ at: expect.any(Number), data: 'queued' }]);
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
expect(writes).toEqual([]);
});
+ // ── The tmux-capture tail: `since` ──
+ //
+ // A pane capture is a point-in-time frame taken part-way through the fetch, so
+ // it holds what arrived BEFORE the capture and nothing after. selectSession
+ // passes the response's arrival time as `since`, which splits the queue at
+ // exactly that line: pre-capture events are already painted and must stay
+ // dropped, post-capture events exist nowhere else and must be replayed.
+
+ it('flushes only the entries at or after `since`', () => {
+ const { app, writes } = makeApp();
+ const owner = app._beginBufferLoad('load-since');
+ pushWhileLoading(app, 'already-in-the-capture', 100);
+ pushWhileLoading(app, 'arrived-at-the-headers', 200);
+ pushWhileLoading(app, 'arrived-after-the-headers', 300);
+
+ app._finishBufferLoad(owner, { flushQueued: true, since: 200 });
+
+ // The pre-capture event stays dropped; the boundary entry counts as after.
+ expect(writes).toEqual(['arrived-at-the-headers', 'arrived-after-the-headers']);
+ });
+
+ it('flushQueued without `since` still replays the whole queue', () => {
+ // The COD-144 path: a brand-new session's first prompt predates the
+ // response, so cutting the queue would drop the only content it has.
+ const { app, writes } = makeApp();
+ const owner = app._beginBufferLoad('load-no-since');
+ pushWhileLoading(app, 'prompt', 10);
+ pushWhileLoading(app, 'more', 20);
+
+ app._finishBufferLoad(owner, { flushQueued: true });
+
+ expect(writes).toEqual(['prompt', 'more']);
+ });
+
+ it('a `since` past every entry flushes nothing', () => {
+ const { app, writes } = makeApp();
+ const owner = app._beginBufferLoad('load-since-late');
+ pushWhileLoading(app, 'old', 10);
+
+ app._finishBufferLoad(owner, { flushQueued: true, since: 999 });
+
+ expect(writes).toEqual([]);
+ expect(app.batchTerminalWrite).not.toHaveBeenCalled();
+ });
+
+ // ── Re-entering one load ──
+ //
+ // `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
+ // opens it again under the SAME owner when it starts writing. A reset on that
+ // second call would silently throw away everything queued during the fetch,
+ // which on the capture path is output no buffer holds.
+
+ it('re-entering the same load keeps what the queue already holds', () => {
+ const { app, writes } = makeApp();
+ const owner = app._beginBufferLoad('load-reenter');
+ pushWhileLoading(app, 'arrived-during-the-fetch', 100);
+
+ // chunkedTerminalWrite re-opens the load it was handed.
+ app._beginBufferLoad(owner);
+ pushWhileLoading(app, 'arrived-during-the-write', 200);
+
+ app._finishBufferLoad(owner, { flushQueued: true, since: 50 });
+
+ expect(writes).toEqual(['arrived-during-the-fetch', 'arrived-during-the-write']);
+ });
+
+ it('a genuinely different load still starts with an empty queue', () => {
+ const { app, writes } = makeApp();
+ app._beginBufferLoad('load-first');
+ pushWhileLoading(app, 'belongs-to-the-abandoned-load', 100);
+
+ // A tab switch starts a new load under a new owner. Its events are not ours.
+ const second = app._beginBufferLoad('load-second');
+ pushWhileLoading(app, 'belongs-to-this-load', 200);
+
+ app._finishBufferLoad(second, { flushQueued: true, since: 0 });
+
+ expect(writes).toEqual(['belongs-to-this-load']);
+ });
+
+ // ── The sticky-scroll baseline across a replay ──
+ //
+ // `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before queueing, and
+ // `flushPendingWrites` scrolls to the bottom off that sample. The replay 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.
+
+ it('the replay latches the baseline true, and the viewport restore re-takes it', () => {
+ const app = makeScrollApp();
+ const owner = app._beginBufferLoad('load-scroll');
+ pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
+
+ // The load ends with the terminal reset and rewritten, so it reads as bottom.
+ app.buffer.viewportY = app.buffer.baseY;
+ app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
+ expect(app._wasAtBottomBeforeWrite).toBe(true);
+
+ // The caller now puts the reader back where they were reading.
+ app.buffer.viewportY = 40;
+ app._syncStickyScrollBaseline();
+
+ // The next flush must leave them there.
+ expect(app._wasAtBottomBeforeWrite).toBe(false);
+ });
+
+ it('a restore that lands back at the bottom keeps sticky scroll armed', () => {
+ const app = makeScrollApp();
+ const owner = app._beginBufferLoad('load-scroll-bottom');
+ pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
+
+ app.buffer.viewportY = app.buffer.baseY;
+ app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
+ app._syncStickyScrollBaseline();
+
+ // A reader who was already at the bottom still wants to be carried along.
+ expect(app._wasAtBottomBeforeWrite).toBe(true);
+ });
+
+ it('both callers that restore a scroll position re-take the baseline', () => {
+ // The wiring lives in app.js, outside this file's vm harness. Without it the
+ // two methods below restore the viewport and the next flush undoes it.
+ const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
+
+ for (const method of ['_onSessionNeedsRefresh', '_maybeRefetchFullHistory']) {
+ const body = methodBody(source, method);
+ const restoreAt = body.lastIndexOf('scrollToLine(');
+ const syncAt = body.indexOf('this._syncStickyScrollBaseline()');
+ expect(restoreAt, `${method} no longer restores a scroll position`).toBeGreaterThan(-1);
+ expect(syncAt, `${method} never re-takes the baseline`).toBeGreaterThan(-1);
+ expect(syncAt, `${method} re-takes the baseline before its restore`).toBeGreaterThan(restoreAt);
+ }
+ });
+
+ it('every path that fetches a terminal buffer and writes it asks the shared helper', () => {
+ // Drift guard. The first version of this fix covered one of the four paths,
+ // and a later pass found it still covering one of four. Nothing else in the
+ // gate stops a fifth path, or an inlined `{ flushQueued: true }`, from
+ // splitting the policy up again; the browser suite that would notice does
+ // not run in CI.
+ const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
+
+ for (const method of [
+ 'selectSession',
+ '_onSessionNeedsRefresh',
+ '_onSessionClearTerminal',
+ '_maybeRefetchFullHistory',
+ ]) {
+ expect(methodBody(source, method), `${method} decides the flush policy itself`).toContain(
+ 'this._bufferLoadFinishOpts('
+ );
+ }
+ });
+
it('empty queue + flushQueued is a no-op (no throw, no writes)', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-empty');
diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts
index 514e93da..e5120ca6 100644
--- a/test/terminal-flush-budget.test.ts
+++ b/test/terminal-flush-budget.test.ts
@@ -49,6 +49,39 @@ function loadTerminalUiHarness(mode: string) {
return { app, writes };
}
+/**
+ * Swap in a terminal whose write() parses ASYNCHRONOUSLY, the way xterm.js does.
+ *
+ * The real renderer queues the chunk and applies it later, firing the write
+ * callback once it has been parsed; a redraw that addresses a row past the
+ * viewport (Codex's status line) drags the viewport to the live bottom at that
+ * point, not when write() returns. `parse()` runs that pending work.
+ */
+function attachAsyncParsingTerminal(app: any, opts: { viewportY: number; baseY: number }) {
+ const buffer = { viewportY: opts.viewportY, baseY: opts.baseY };
+ const pending: Array<() => void> = [];
+ app.terminal.buffer = { active: buffer };
+ app.terminal.write = vi.fn((_data: string, callback?: () => void) => {
+ pending.push(() => {
+ buffer.viewportY = buffer.baseY; // the redraw lands
+ callback?.();
+ });
+ });
+ app.terminal.scrollToLine = vi.fn((line: number) => {
+ buffer.viewportY = line;
+ });
+ app.terminal.scrollToBottom = vi.fn(() => {
+ buffer.viewportY = buffer.baseY;
+ });
+ return {
+ buffer,
+ parse: () => {
+ const queued = pending.splice(0, pending.length);
+ for (const run of queued) run();
+ },
+ };
+}
+
function loadAppHarness() {
const dir = resolve(import.meta.dirname, '../src/web/public');
const fetchMock = vi.fn();
@@ -322,6 +355,51 @@ describe('terminal flush budget', () => {
expect(app._bufferLoadOwner).toBe(null);
});
+ // ── Which payloads end their load by replaying the queue ──
+ //
+ // A pane capture is current only up to capture time, so the tail that arrived
+ // after the response exists nowhere else and has to be replayed. The server's
+ // accumulated byte history is current up to the response, so replaying on top
+ // of it would duplicate output. `_bufferLoadFinishOpts` is the one place that
+ // decides this, for all four paths that fetch a terminal buffer and write it.
+
+ it('replays the tail for a visible-pane capture', () => {
+ const { CodemanApp } = loadAppHarness();
+ const app = Object.create(CodemanApp.prototype) as any;
+
+ expect(app._bufferLoadFinishOpts({ source: 'mux-visible' }, 1234)).toEqual({
+ flushQueued: true,
+ since: 1234,
+ });
+ });
+
+ it('replays the tail for a full-history capture', () => {
+ const { CodemanApp } = loadAppHarness();
+ const app = Object.create(CodemanApp.prototype) as any;
+
+ expect(app._bufferLoadFinishOpts({ source: 'mux-full-history' }, 1234)).toEqual({
+ flushQueued: true,
+ since: 1234,
+ });
+ });
+
+ it('discards the queue for the accumulated byte history', () => {
+ const { CodemanApp } = loadAppHarness();
+ const app = Object.create(CodemanApp.prototype) as any;
+
+ expect(app._bufferLoadFinishOpts({ source: 'history' }, 1234).flushQueued).toBe(false);
+ });
+
+ it('discards the queue for a payload that names no source', () => {
+ // Fails toward the safe answer: a duplicated Ink redraw corrupts the screen,
+ // while a dropped tail is repaired by the CLI's next full repaint.
+ const { CodemanApp } = loadAppHarness();
+ const app = Object.create(CodemanApp.prototype) as any;
+
+ expect(app._bufferLoadFinishOpts({}, 1234).flushQueued).toBe(false);
+ expect(app._bufferLoadFinishOpts(undefined, 1234).flushQueued).toBe(false);
+ });
+
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
const { app } = loadTerminalUiHarness('codex');
const scrollToBottom = vi.fn();
@@ -337,20 +415,111 @@ describe('terminal flush budget', () => {
it('restores the user scroll position when Codex Working redraws move the viewport', () => {
const { app } = loadTerminalUiHarness('codex');
- const buffer = { viewportY: 40, baseY: 100 };
- app.terminal.buffer = { active: buffer };
- app.terminal.write = vi.fn(() => {
- buffer.viewportY = buffer.baseY;
- });
- app.terminal.scrollToLine = vi.fn((line: number) => {
- buffer.viewportY = line;
- });
+ const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
app._wasAtBottomBeforeWrite = true;
app._lastUserScrollUpAt = 0;
app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
app.flushPendingWrites();
+ parse();
expect(buffer.viewportY).toBe(40);
});
+
+ // Issue #358. xterm.js parses on its own schedule, so the buffer still holds
+ // the pre-write viewport the instant write() returns: restoring there compared
+ // the anchor against itself, did nothing, and left the redraw free to drag the
+ // viewport to the live bottom a tick later. The previous regression passed
+ // because its write mock moved the viewport synchronously, which real xterm
+ // never does. These drive the callback explicitly instead.
+ it('restores the history anchor only AFTER xterm has parsed the write (#358)', () => {
+ const { app } = loadTerminalUiHarness('codex');
+ const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
+ app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
+
+ app.flushPendingWrites();
+ // Nothing has parsed yet, so nothing may have been restored yet either.
+ expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
+ expect(buffer.viewportY).toBe(40);
+
+ parse();
+
+ expect(app.terminal.scrollToLine).toHaveBeenCalledWith(40);
+ expect(buffer.viewportY).toBe(40);
+ });
+
+ it('holds the anchor across consecutive Codex redraws', () => {
+ const { app } = loadTerminalUiHarness('codex');
+ const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
+
+ for (const frame of ['\x1b[55;1H\x1b[2m• Working (6s)', '\x1b[55;1H\x1b[2m• Working (7s)']) {
+ app.pendingWrites.push(frame);
+ app.flushPendingWrites();
+ parse();
+ expect(buffer.viewportY).toBe(40);
+ }
+ });
+
+ it('holds the anchor across a chunked write whose remainder is deferred', () => {
+ const { app } = loadTerminalUiHarness('codex');
+ const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
+ // Over the 32KB codex frame budget, so the flush defers a remainder and the
+ // second chunk goes out from the write callback's reschedule.
+ app.pendingWrites.push('x'.repeat(40000));
+
+ app.flushPendingWrites();
+ parse();
+ expect(buffer.viewportY).toBe(40);
+
+ app.flushPendingWrites();
+ parse();
+ expect(buffer.viewportY).toBe(40);
+ expect(app.pendingWrites).toHaveLength(0);
+ });
+
+ it('drops the anchor when the user switched sessions before the write parsed', () => {
+ // The anchor indexes the buffer it came from. selectSession() resets the
+ // terminal and chunk-loads a different scrollback, so replaying row 40 into
+ // that one is a jump to an arbitrary place, not a restore. Only reachable now
+ // that the restore runs a parse later than the write.
+ const { app } = loadTerminalUiHarness('codex');
+ const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
+ app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
+
+ app.flushPendingWrites();
+ app.activeSessionId = 'session-2'; // the user clicked another tab
+ parse();
+
+ expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
+ expect(buffer.viewportY).toBe(buffer.baseY);
+ });
+
+ it('drops the anchor while a buffer load is replaying history', () => {
+ const { app } = loadTerminalUiHarness('codex');
+ const { parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
+ app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
+
+ app.flushPendingWrites();
+ app._isLoadingBuffer = true; // chunkedTerminalWrite owns the viewport now
+ parse();
+
+ expect(app.terminal.scrollToLine).not.toHaveBeenCalled();
+ });
+
+ it('does not bounce off the bottom when the sticky flag and an anchor disagree', () => {
+ // _wasAtBottomBeforeWrite is captured at the frame's first batchTerminalWrite
+ // and the anchor at flush time, so a scroll-up in between leaves both live.
+ // The anchor wins: scrolling to the bottom and back would be a visible jump.
+ const { app } = loadTerminalUiHarness('codex');
+ const { buffer, parse } = attachAsyncParsingTerminal(app, { viewportY: 40, baseY: 100 });
+ app._wasAtBottomBeforeWrite = true;
+ app._lastUserScrollUpAt = 0;
+ app.pendingWrites.push('\x1b[55;1H\x1b[2m• Working (6s)');
+
+ app.flushPendingWrites();
+ parse();
+
+ expect(app.terminal.scrollToBottom).not.toHaveBeenCalled();
+ expect(buffer.viewportY).toBe(40);
+ });
});
diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts
index 8f980fc0..18b08d1e 100644
--- a/test/terminal-keycode229-recovery.browser.test.ts
+++ b/test/terminal-keycode229-recovery.browser.test.ts
@@ -9,7 +9,10 @@
* (stopPropagation, not stopImmediatePropagation) does not silence it;
* - a `composed: true` insertText preceded by a keydown — the shape Chrome on
* Android delivers — is dropped by xterm and recovered by us, exactly once;
- * - a keystroke xterm DOES handle is delivered exactly once, not twice.
+ * - a keystroke xterm DOES handle is delivered exactly once, not twice;
+ * - a character committed in the SAME page task as Enter reaches the send
+ * path ahead of the `\r`, which is the ordering the zero-delay timer
+ * alone cannot produce.
*
* Browser-driven, so it is excluded from `npm run test:ci` like the other
* Playwright suites. Run locally:
@@ -146,6 +149,84 @@ describe('orphaned terminal input recovery wiring', () => {
expect(second.sent.join('')).toBe('z');
});
+ /**
+ * The batched shape an Android soft keyboard actually delivers when the user
+ * taps the last character and then Enter: the character's keydown, its
+ * `composed: true` insertText, and Enter's keydown all land in ONE page task,
+ * before any zero-delay timer can run.
+ *
+ * This is the ordering half of the fix, and the half the unit harness cannot
+ * reach: the unit tests prove WHICH candidate is forwarded, this proves WHEN.
+ * Resolving the pending candidate only on its 0 ms timer loses the character
+ * outright here, because by the time that timer runs xterm has already
+ * emitted the `\r` and bumped the canonical counter past the candidate's
+ * snapshot, so it stands down. Draining at the next keydown, from xterm's
+ * custom key handler (which runs before xterm processes that key), puts the
+ * character on the wire ahead of the `\r`.
+ */
+ async function batchedCommitThenEnter(data: string) {
+ return page.evaluate(async (text) => {
+ const app = (window as any).app;
+ const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
+ const originalSessionId = app.activeSessionId;
+ const originalLocalEcho = app._localEchoEnabled;
+ const originalSendInput = app._sendInputAsync;
+ const originalPendingInput = app._pendingInput;
+ const originalLastKeystrokeTime = app._lastKeystrokeTime;
+ const sent: string[] = [];
+
+ try {
+ app.activeSessionId = 'cod388-browser-batched';
+ app._localEchoEnabled = false;
+ app._pendingInput = '';
+ app._lastKeystrokeTime = 0;
+ app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
+ textarea.focus();
+
+ // One task, no awaits between the three dispatches.
+ const charDown = new KeyboardEvent('keydown', {
+ key: 'Unidentified',
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ Object.defineProperties(charDown, { keyCode: { value: 65 }, which: { value: 65 } });
+ textarea.dispatchEvent(charDown);
+
+ textarea.value = text;
+ textarea.dispatchEvent(
+ new InputEvent('input', { data: text, inputType: 'insertText', bubbles: true, composed: true })
+ );
+
+ const enterDown = new KeyboardEvent('keydown', {
+ key: 'Enter',
+ code: 'Enter',
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ Object.defineProperties(enterDown, { keyCode: { value: 13 }, which: { value: 13 } });
+ textarea.dispatchEvent(enterDown);
+
+ await new Promise((resolve) => setTimeout(resolve, 80));
+ return { wire: sent.join('') };
+ } finally {
+ app.activeSessionId = originalSessionId;
+ app._localEchoEnabled = originalLocalEcho;
+ app._sendInputAsync = originalSendInput;
+ app._pendingInput = originalPendingInput;
+ app._lastKeystrokeTime = originalLastKeystrokeTime;
+ textarea.value = '';
+ }
+ }, data);
+ }
+
+ it('delivers a character committed in the same task as Enter BEFORE the carriage return', async () => {
+ const { wire } = await batchedCommitThenEnter('o');
+ // Not '\r' (character lost, the defect) and not '\ro' (recovered too late).
+ expect(wire).toBe('o\r');
+ });
+
it('sends nothing for a keydown that produces no input event', async () => {
const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
expect(sent).toEqual([]);
diff --git a/test/terminal-keycode229-recovery.test.ts b/test/terminal-keycode229-recovery.test.ts
index 6a8a38a6..ebddc629 100644
--- a/test/terminal-keycode229-recovery.test.ts
+++ b/test/terminal-keycode229-recovery.test.ts
@@ -218,6 +218,52 @@ describe('orphaned terminal input recovery', () => {
expect(reads).toEqual([]);
});
+ it('delivers the last character BEFORE the Enter that submits it (defect 4)', () => {
+ // Android soft keyboards commit the last character and send the Enter key in
+ // ONE InputConnection transaction, so the `input` event and the Enter keydown
+ // are processed before any zero-delay timer runs. Two things then went wrong
+ // with a candidate that only resolved on its timer:
+ //
+ // 1. ORDER — xterm emits '\r' synchronously from the Enter keydown, and the
+ // local-echo composer submits `pendingText` right there. The recovered
+ // character arrived one macrotask too late to be part of the prompt.
+ // 2. LOSS — that '\r' bumps the canonical counter, so by the time the
+ // candidate resolved, `canonicalCount > snapshot` read as "xterm spoke
+ // for this keystroke" and stood the recovery down. The character was
+ // dropped outright: every message sent from the phone lost its last
+ // character.
+ //
+ // Resolving pending candidates synchronously at the NEXT keydown fixes both:
+ // the counter still holds the value it had when that candidate was created,
+ // and the byte reaches the composer ahead of the Enter.
+ const h = harness();
+ h.keydown();
+ h.input('o');
+ expect(h.emitted).toEqual([]);
+
+ h.keydown({ key: 'Enter' });
+ expect(h.emitted).toEqual(['o']);
+
+ // xterm now emits '\r' for the Enter. The already-resolved candidate must
+ // not fire a second time when its timer is flushed.
+ h.controller.notifyCanonicalData();
+ h.flushTimers();
+ expect(h.emitted).toEqual(['o']);
+ expect(h.pendingTimers()).toBe(0);
+ });
+
+ it('still stands down at the next keydown when xterm spoke for the candidate', () => {
+ // The synchronous resolve must not become a "forward everything" path: a
+ // keystroke xterm delivered itself is still a duplicate if recovered.
+ const h = harness();
+ h.keydown();
+ h.input('x');
+ h.controller.notifyCanonicalData();
+ h.keydown({ key: 'Enter' });
+ h.flushTimers();
+ expect(h.emitted).toEqual([]);
+ });
+
it('ignores input events that are not committed text', () => {
const h = harness();
for (const inputType of ['insertCompositionText', 'deleteContentBackward', 'insertLineBreak', 'insertFromPaste']) {