feat(remote): wake a sleeping host from user input (Wake-on-LAN)

A durable remote session survives SSH drops (COD-104/108), but nothing brought
the HOST back: after the remote machine suspended, the local tmux pane's ssh
child stalled silently and `send-keys` SUCCEEDS against it, so typed input
vanished with no error anywhere.

Add an optional per-host `wakeCommand` (Wake-on-LAN wrapper, e.g. whuff) that
the input route runs when a wake-enabled host is unreachable: input is buffered,
the host is woken, the pane is reattached, and the buffer is flushed in order.
Detection is a throttled bare TCP probe on wake-enabled hosts only, and only
REAL user input may wake a host - the auto-reconnect watcher and boot recovery
deliberately cannot, or the host would be re-woken seconds after every suspend
and could never stay asleep.
This commit is contained in:
Randalix
2026-09-15 10:25:52 +02:00
parent 88e3faa456
commit a81f430e41
13 changed files with 1056 additions and 1 deletions
+6
View File
@@ -549,6 +549,9 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
port: host.port,
remotePath: remoteCase.remotePath,
commands: host.commands,
// Wake-on-LAN command travels with the session so the input route can wake a
// sleeping host without a second config read (see remote-wake.ts).
wakeCommand: host.wakeCommand,
// COD-105 — the COD-104 launch path creates the remote session, so we own it
// (an explicit kill may propagate a remote kill-session). Discovered+attached
// sessions go through `toAttachedSessionRemote` with `owned: false`.
@@ -587,6 +590,9 @@ export function toAttachedSessionRemote(
port: host.port,
remotePath,
commands: host.commands,
// An attached session can be woken exactly the same way — the identity of the
// creator does not change whether the host is asleep.
wakeCommand: host.wakeCommand,
// Discovered + attached — another Codeman created it. Detach-not-kill.
owned: false,
remoteSessionName,
+443
View File
@@ -0,0 +1,443 @@
/**
* @fileoverview Wake a SLEEPING remote host from user input (user-triggered Wake-on-LAN).
*
* A durable remote session survives SSH drops (COD-104) and auto-reconnects
* (COD-108), but nothing brings the HOST back: if the remote machine suspended,
* the local tmux pane's `ssh` child stalls silently. `tmux send-keys` then
* SUCCEEDS against a pane that will never deliver the bytes, so typed input is
* lost with no error anywhere — the failure this module exists to close.
*
* Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN):
* - ONLY real user input wakes a host. The auto-reconnect watcher and
* boot-recovery must never wake one, or a host would be re-woken ~45 s after
* each suspend and could never stay asleep (the "keepalive pings a sleeping
* host" failure already solved for a different consumer by
* `hufflepuff-mcp-lazy`).
* - Detection is a cheap TCP connect to the SSH port (no auth, no ssh client,
* a few hundred bytes — below any meaningful activity threshold), throttled
* per session. No SSH keepalive is added to the launch command: keepalives
* would move bytes into an otherwise idle connection every interval, which is
* exactly the "an open pipe keeps the host awake" bug the remote-side idle
* detector was rewritten to avoid.
* - While a wake is in flight, input is BUFFERED and flushed in order once the
* pane is reattached, so the user's first characters after a long pause are
* not the ones that get eaten.
*
* The pure decisions and the IO are separated so the decision table can be
* unit-tested without tmux, ssh, or a real host.
*
* @module remote-wake
*/
import { spawn } from 'node:child_process';
import net from 'node:net';
/** Minimum spacing between two reachability probes for the same session. */
export const REMOTE_WAKE_PROBE_MIN_INTERVAL_MS = 30_000;
/** TCP-connect timeout for a reachability probe (host awake ≈ a few ms). */
export const REMOTE_WAKE_PROBE_TIMEOUT_MS = 1_500;
/** Poll spacing while waiting for a woken host to accept SSH again. */
export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500;
/** Bounded wait for the host to come back after the wake command ran. */
export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000;
/** The wake command itself must not hang the wake flow. */
export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000;
/**
* Settle time between respawning the ssh pane and flushing buffered input: the
* respawned `ssh` needs a moment to run `tmux -L codeman-remote … -A` and attach,
* and bytes written into a still-connecting pane land in nothing.
*/
export const REMOTE_WAKE_ATTACH_SETTLE_MS = 1_500;
/**
* Cap on buffered input per session while a host is being woken. 4 KB is a lot
* of typing for a ~10 s wake; beyond it the OLDEST bytes are dropped (keeping the
* tail preserves what the user just typed, and a silently unbounded buffer would
* be a memory leak keyed on user input).
*/
export const REMOTE_WAKE_PENDING_MAX_BYTES = 4096;
/** Default SSH port used when the host config has no explicit `port`. */
export const DEFAULT_SSH_PORT = 22;
/** What the input path should do with a chunk of user input. Pure. */
export type RemoteInputAction = 'deliver' | 'probe' | 'buffer';
/**
* The caller-facing outcome of {@link RemoteWakeRegistry.handleInput}: either the
* caller writes the bytes as usual, or the registry took ownership of them.
*/
export type RemoteInputOutcome = 'deliver' | 'buffered';
/**
* Decide what to do with an input chunk on an input route. Mirrors
* {@link RemoteWakeRegistry.handleInput} so the throttle table has exactly ONE
* definition and is unit-testable:
*
* - a wake already in flight → buffer (the flush owns delivery),
* - no wake command configured → deliver (feature off, today's behavior),
* - the last probe said "down" → buffer (no second probe; re-probing a known
* sleeping host on every keystroke would add seconds of latency per character),
* - never probed / throttle window elapsed → probe,
* - probed "up" inside the window → deliver.
*
* Pure — no clock, no IO.
*/
export function decideRemoteInputAction(args: {
hasWakeCommand: boolean;
waking: boolean;
probeAgeMs: number;
lastReachable?: boolean;
minProbeIntervalMs?: number;
}): RemoteInputAction {
if (args.waking) return 'buffer';
if (!args.hasWakeCommand) return 'deliver';
if (args.lastReachable === false) return 'buffer';
const interval = args.minProbeIntervalMs ?? REMOTE_WAKE_PROBE_MIN_INTERVAL_MS;
if (args.probeAgeMs >= interval) return 'probe';
return 'deliver';
}
/**
* Append `data` to the pending buffer, dropping the OLDEST bytes when the cap is
* exceeded. Returns the resulting buffer. Pure.
*/
export function appendBoundedPending(
pending: string[],
data: string,
maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES
): string[] {
const next = [...pending, data];
let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
while (next.length > 1 && total > maxBytes) {
total -= Buffer.byteLength(next[0]);
next.shift();
}
return next;
}
/** The remote fields the wake flow needs. Structurally satisfied by `SessionRemote`. */
export interface WakeableRemote {
wakeCommand?: string;
hostId: string;
label: string;
host: string;
port?: number;
}
/**
* The slice of `Session` the wake flow uses — an interface rather than the
* concrete class so the registry is testable without a tmux server.
*/
export interface WakeableSession {
readonly id: string;
readonly remote: WakeableRemote | undefined;
/** COD-108 reattach: respawns the local ssh pane, idempotently attaching the durable remote tmux. */
reattachRemote(): Promise<boolean>;
/** Write bytes to the session's pane. */
writeViaMux(data: string): Promise<boolean>;
}
/** Injected IO so the registry holds no direct dependency on ssh/net/child_process in tests. */
export interface RemoteWakeDeps {
/** Cheap reachability probe. Must resolve false (never throw) for a sleeping host. */
probe(remote: WakeableRemote): Promise<boolean>;
/** Run the host's wake command. Resolves false when it fails to run. */
wake(command: string): Promise<boolean>;
/** Poll until the woken host accepts connections again. */
waitUntilReady(remote: WakeableRemote): Promise<boolean>;
/** Sleep helper (injected for tests). */
delay(ms: number): Promise<void>;
/** Notify the COD-108 watcher so an exhausted backoff is reset. */
noteReconnected?(sessionId: string, success: boolean): void;
/** SSE broadcast. */
broadcast?(
event: 'remote:hostWaking' | 'remote:hostWakeFailed' | 'remote:sessionReconnected',
payload: Record<string, unknown>
): void;
/** Structured diagnostics. */
log?(message: string): void;
}
/** Per-session wake bookkeeping. */
interface WakeState {
probedAt: number;
reachable?: boolean;
waking: Promise<boolean> | null;
pending: string[];
}
/**
* Per-session wake state + single-flight wake flow.
*
* One instance per web server (module singleton in the routes file, like the
* signal-wait registry). State is keyed by session id and dropped with the
* session.
*/
export class RemoteWakeRegistry {
private readonly states = new Map<string, WakeState>();
constructor(private readonly deps: RemoteWakeDeps) {}
/** Drop a session's state (session closed/killed). The pending buffer goes with it. */
drop(sessionId: string): void {
this.states.delete(sessionId);
}
/** Whether a wake is currently in flight (diagnostics/tests). */
isWaking(sessionId: string): boolean {
return this.states.get(sessionId)?.waking != null;
}
/** Buffered input bytes for a session (diagnostics/tests). */
pendingBytes(sessionId: string): number {
const state = this.states.get(sessionId);
if (!state) return 0;
return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
}
/**
* Decide + act for one input chunk.
*
* `'deliver'` means the caller writes it as usual (today's path, zero added
* cost). `'buffered'` means the registry took ownership of the bytes: it either
* queued them behind an in-flight wake or started a wake, and will flush them
* in order once the pane is reattached.
*/
async handleInput(session: WakeableSession, data: string): Promise<RemoteInputOutcome> {
const remote = session.remote;
const state = this._state(session.id);
const action = decideRemoteInputAction({
hasWakeCommand: Boolean(remote?.wakeCommand),
waking: state.waking != null,
probeAgeMs: Date.now() - state.probedAt,
lastReachable: state.reachable,
});
if (action === 'deliver') return 'deliver';
if (action === 'buffer') {
// A buffered decision with no wake in flight (the wake failed and the state
// was reset, or the very first input of a session in the throttle window)
// must still drive a wake, or the bytes would sit in the buffer forever.
if (state.waking == null && remote?.wakeCommand) {
this._enqueue(session.id, data);
void this.wake(session);
return 'buffered';
}
this._enqueue(session.id, data);
return 'buffered';
}
// action === 'probe' — the throttle window elapsed, so one TCP connect is owed.
state.probedAt = Date.now();
state.reachable = remote ? await this.deps.probe(remote) : true;
if (state.reachable) return 'deliver';
this._enqueue(session.id, data);
void this.wake(session);
return 'buffered';
}
/**
* Block until the host is reachable and the pane is reattached — the
* send-and-wait path, where the HTTP response stays open anyway and buffering
* would break the wait contract.
*/
async ensureAwake(session: WakeableSession): Promise<boolean> {
const remote = session.remote;
if (!remote?.wakeCommand) return true;
const state = this._state(session.id);
if (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS) {
state.probedAt = Date.now();
state.reachable = await this.deps.probe(remote);
}
if (state.reachable) return true;
return this.wake(session);
}
/**
* Single-flight wake: probe-free (the caller already knows the host is down),
* run the wake command, poll for readiness, reattach the pane, flush the buffer.
*/
async wake(session: WakeableSession): Promise<boolean> {
const remote = session.remote;
if (!remote?.wakeCommand) return true;
const state = this._state(session.id);
if (state.waking) return state.waking;
state.waking = (async (): Promise<boolean> => {
const id = session.id;
try {
this.deps.broadcast?.('remote:hostWaking', { sessionId: id, hostId: remote.hostId, label: remote.label });
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) for session ${id}`);
const woke = await this.deps.wake(remote.wakeCommand as string);
if (!woke) this.deps.log?.(`[RemoteWake] wake command failed for ${remote.label}: ${remote.wakeCommand}`);
const ready = await this.deps.waitUntilReady(remote);
if (!ready) {
this.deps.log?.(`[RemoteWake] ${remote.label} did not come back — input stays buffered`);
this.deps.broadcast?.('remote:hostWakeFailed', { sessionId: id, hostId: remote.hostId, label: remote.label });
// Reset the probe state so the NEXT user input probes and retries
// instead of trusting a stale "down" verdict forever.
state.probedAt = 0;
state.reachable = undefined;
return false;
}
state.reachable = true;
state.probedAt = Date.now();
const reattached = await session.reattachRemote();
if (!reattached) {
this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`);
return false;
}
// The reset also clears an EXHAUSTED COD-108 backoff, which otherwise
// never fires again for this session (see remote-reconnect.ts).
this.deps.noteReconnected?.(id, true);
this.deps.broadcast?.('remote:sessionReconnected', { sessionId: id });
this.deps.log?.(`[RemoteWake] ${remote.label} reattached for session ${id}`);
await this.deps.delay(REMOTE_WAKE_ATTACH_SETTLE_MS);
await this._flush(state, session);
return true;
} catch (err) {
this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
return false;
} finally {
state.waking = null;
}
})();
return state.waking;
}
private _state(sessionId: string): WakeState {
let state = this.states.get(sessionId);
if (!state) {
state = { probedAt: 0, reachable: undefined, waking: null, pending: [] };
this.states.set(sessionId, state);
}
return state;
}
private _enqueue(sessionId: string, data: string): void {
const state = this._state(sessionId);
const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
state.pending = appendBoundedPending(state.pending, data);
const after = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
if (before + Buffer.byteLength(data) > after) {
this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`);
}
}
private async _flush(state: WakeState, session: WakeableSession): Promise<void> {
while (state.pending.length > 0) {
const chunk = state.pending[0];
const ok = await session.writeViaMux(chunk).catch(() => false);
if (!ok) {
this.deps.log?.(
`[RemoteWake] flush failed for session ${session.id} — ${state.pending.length} chunk(s) retained`
);
return;
}
state.pending.shift();
}
}
}
// ========== Default IO ==========
/**
* Cheap reachability probe: a bare TCP connect to the SSH port.
*
* Deliberately NOT an `ssh … true` probe: that opens a full session (auth,
* remote log, process) every throttle window for a question a SYN already
* answers. Any byte count it does move is a few hundred bytes per probe, far
* below the remote idle detector's traffic threshold, so probing cannot keep a
* host awake.
*/
export function probeRemoteHostReachable(
remote: WakeableRemote,
timeoutMs = REMOTE_WAKE_PROBE_TIMEOUT_MS
): Promise<boolean> {
const port = remote.port ?? DEFAULT_SSH_PORT;
return new Promise((resolve) => {
let settled = false;
const finish = (value: boolean) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(value);
};
const socket = net.connect({ host: remote.host, port });
socket.setTimeout(timeoutMs, () => finish(false));
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
});
}
/**
* Run a host's wake command (e.g. a Wake-on-LAN wrapper script). No shell — the
* value is a single executable path, so nothing in it can be interpreted.
* Resolves false on any failure (missing binary, non-zero exit, timeout) rather
* than throwing: a broken wake command must not break the input route.
*/
export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_COMMAND_TIMEOUT_MS): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
let child: ReturnType<typeof spawn>;
try {
child = spawn(command, [], { stdio: 'ignore' });
} catch {
finish(false);
return;
}
const timer = setTimeout(() => {
child.kill('SIGKILL');
finish(false);
}, timeoutMs);
child.once('error', () => {
clearTimeout(timer);
finish(false);
});
child.once('exit', (code) => {
clearTimeout(timer);
finish(code === 0);
});
});
}
/** Poll the host until it accepts connections again, or the bound is hit. */
export async function waitUntilRemoteReady(
remote: WakeableRemote,
opts: { intervalMs?: number; timeoutMs?: number; probe?: (remote: WakeableRemote) => Promise<boolean> } = {}
): Promise<boolean> {
const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS;
const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_MS;
const probe = opts.probe ?? probeRemoteHostReachable;
const deadline = Date.now() + timeoutMs;
// Probe immediately: WoL from a warm S3 is fast (~7.5 s measured on this setup),
// and the first poll is what turns "just woke" into a sub-interval response.
for (;;) {
if (await probe(remote)) return true;
if (Date.now() + intervalMs > deadline) return false;
await delay(intervalMs);
}
}
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/** Production wiring: all IO defaults, overridable for tests. */
export function createDefaultRemoteWakeDeps(overrides: Partial<RemoteWakeDeps> = {}): RemoteWakeDeps {
return {
probe: probeRemoteHostReachable,
wake: runRemoteWakeCommand,
waitUntilReady: (remote) => waitUntilRemoteReady(remote),
delay,
...overrides,
};
}
+14
View File
@@ -97,6 +97,15 @@ export interface RemoteHost extends RemoteSshOptions {
username: string;
port?: number;
commands?: Partial<Record<RemoteCommandMode, string>>;
/**
* Optional Wake-on-LAN command that powers this host on from SLEEP (e.g. a
* wrapper script like `/home/joe/bin/whuff`). Absent = no wake support and
* today's behavior exactly. Executed WITHOUT a shell (a single executable
* path, never a command line), only from user input on a session whose host
* is unreachable — never from the auto-reconnect/boot-recovery path, which
* would re-wake a host seconds after each suspend.
*/
wakeCommand?: string;
}
export interface RemoteCase {
@@ -137,6 +146,11 @@ export interface SessionRemote extends RemoteSshOptions {
* session was created elsewhere. Only meaningful when `owned === false`.
*/
remoteSessionName?: string;
/**
* Wake-on-LAN command carried over from the host config (see `RemoteHost.wakeCommand`)
* so the input route can wake a sleeping host without re-reading the host list.
*/
wakeCommand?: string;
}
/**
+2
View File
@@ -219,6 +219,8 @@ const _SSE_HANDLER_MAP = [
// Remote auto-reconnect (COD-108)
[SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'],
[SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'],
[SSE_EVENTS.REMOTE_HOST_WAKING, '_onRemoteHostWaking'],
[SSE_EVENTS.REMOTE_HOST_WAKE_FAILED, '_onRemoteHostWakeFailed'],
// Ralph
[SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'],
+3
View File
@@ -1057,6 +1057,9 @@ const SSE_EVENTS = {
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
// Wake-on-LAN from user input on a sleeping remote host
REMOTE_HOST_WAKING: 'remote:hostWaking',
REMOTE_HOST_WAKE_FAILED: 'remote:hostWakeFailed',
// Ralph
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
+14
View File
@@ -116,6 +116,20 @@ Object.assign(CodemanApp.prototype, {
},
// Wake-on-LAN from user input on a sleeping remote host (see remote-wake.ts).
_onRemoteHostWaking(data) {
const label = data && data.label ? data.label : 'Remote host';
// Long enough to cover the wake + attach (~10s measured on a warm S3), and it
// is replaced by `remote:sessionReconnected` the moment the pane is back.
this.showToast(`Waking ${label} … input is queued`, 'info', { duration: 12000 });
},
_onRemoteHostWakeFailed(data) {
const label = data && data.label ? data.label : 'Remote host';
this.showToast(`${label} did not wake up — queued input is still held`, 'error', { duration: 15000 });
},
// Bash tools
_onBashToolStart(data) {
this.handleBashToolStart(data.sessionId, data.tool);
+49 -1
View File
@@ -67,6 +67,7 @@ import {
type WaitSignal,
type SignalWaitResult,
} from '../session-wait-registry.js';
import { RemoteWakeRegistry, createDefaultRemoteWakeDeps } from '../../remote-wake.js';
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
import {
autoConfigureRalph,
@@ -816,8 +817,32 @@ export function resolveOmpConfigForCreate(
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
options: { remoteWake?: RemoteWakeRegistry } = {}
): void {
// Wake-on-LAN for sleeping remote hosts (see remote-wake.ts). One registry per
// route registration (= one web server) — the same shape as the process-wide
// `sessionWaits` singleton, but without the global.
//
// ⚠️ The ONLY caller that may wake a host is the input route below. The
// auto-reconnect watcher and boot recovery deliberately have no access to this
// registry: waking there would re-wake the host seconds after every suspend, so
// it could never stay asleep.
const remoteWake =
options.remoteWake ??
new RemoteWakeRegistry(
createDefaultRemoteWakeDeps({
noteReconnected: (sessionId, success) => {
// Duck-typed exactly like server.ts: TmuxManager owns the COD-108 backoff
// state, and the port interface does not expose it.
const mux = ctx.mux as unknown as { noteRemoteReconnect?: (id: string, ok: boolean) => void };
mux.noteRemoteReconnect?.(sessionId, success);
},
broadcast: (event, payload) => ctx.broadcast(event, payload),
log: (message) => console.log(message),
})
);
// ═══════════════════════════════════════════════════════════════
// Auth
// ═══════════════════════════════════════════════════════════════
@@ -1279,6 +1304,7 @@ export function registerSessionRoutes(
}
const session = findSessionOrFail(ctx, id, req);
remoteWake.drop(session.id);
await ctx.cleanupSession(session.id, killMux, 'user_delete');
return {};
});
@@ -1555,6 +1581,28 @@ export function registerSessionRoutes(
return {};
}
// Wake-on-LAN (remote-wake.ts): a wake-enabled remote host that suspended leaves
// the local ssh pane STALLED, and `send-keys` succeeds against it — the bytes
// would vanish with no error anywhere. Give the registry the chance to probe the
// host, wake it, reattach, and own delivery before we write into nothing.
//
// Costs nothing for non-wake hosts (the `wakeCommand` guard) or while the host is
// known reachable inside the probe throttle window; the probe itself is a bare
// TCP connect on wake-enabled hosts only, at most once per
// REMOTE_WAKE_PROBE_MIN_INTERVAL_MS.
if (!duplicate && session.remote?.wakeCommand) {
if (wantsWait) {
// Send-and-wait keeps the response open anyway, so blocking on the wake is
// simpler and more correct than buffering (buffering would break the wait).
await remoteWake.ensureAwake(session);
} else if ((await remoteWake.handleInput(session, inputStr)) === 'buffered') {
// The registry holds the bytes and flushes them in order once the pane is
// reattached. The client's ACK is this 200 — a tagged retry is deduped
// (`shouldApplyInput` above already consumed the seq), so nothing is lost.
return {};
}
}
// Only a waiting request pays for the tmux probe: the browser's plain input path
// (thousands of calls per session) must stay exec-free.
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
+12
View File
@@ -737,6 +737,18 @@ export const RemoteHostSchema = z.object({
.max(32)
.optional(),
commands: RemoteCommandOverridesSchema,
// Wake-on-LAN: a single executable path (no arguments, no shell) run to power a
// SLEEPING host back on, e.g. `/home/joe/bin/whuff`. Executed via spawn without
// a shell, so there is no shell layer to escape; the regexes are belt-and-braces
// (and the no-whitespace rule rejects an argument list before it can fail as a
// confusing ENOENT at wake time). See docs/remote-sessions.md §Wake-on-LAN.
wakeCommand: z
.string()
.min(1)
.max(4096)
.regex(/^\S+$/, 'Wake command must be a single executable path (no arguments)')
.regex(NO_SHELL_META, 'Invalid characters in wake command')
.optional(),
});
export const RemoteCaseLinkSchema = z.object({
+9
View File
@@ -184,6 +184,13 @@ export const RemoteSessionDropped = 'remote:sessionDropped' as const;
export const RemoteSessionReconnected = 'remote:sessionReconnected' as const;
/** Auto-reconnect gave up after the bounded backoff cap — manual reconnect needed. */
export const RemoteReconnectExhausted = 'remote:reconnectExhausted' as const;
/**
* User input arrived for a session whose host is unreachable, so a Wake-on-LAN
* command was started (see `remote-wake.ts`). Input sent meanwhile is buffered.
*/
export const RemoteHostWaking = 'remote:hostWaking' as const;
/** The host did not come back within the wake timeout — buffered input is still held. */
export const RemoteHostWakeFailed = 'remote:hostWakeFailed' as const;
// ─── Respawn ─────────────────────────────────────────────────────────────────
@@ -535,6 +542,8 @@ export const SseEvent = {
RemoteSessionDropped,
RemoteSessionReconnected,
RemoteReconnectExhausted,
RemoteHostWaking,
RemoteHostWakeFailed,
// Respawn
RespawnStarted,