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
+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,