feat(remote): host-unreachable banner, manual wake, and native MAC wake-on-LAN

The reactive wake (typing into a session whose host slept) left the state invisible:
nothing told the user the machine was asleep, and with no wake target configured
there was nothing to do about it. Adds:

- RemoteHost.wakeMac (comma-separated) - Codeman builds and broadcasts the magic
  packet itself (UDP port 9), so the common case needs no external script. The
  existing wakeCommand stays as the explicit override.
- GET /api/sessions/:id/reachability - probes (throttled, cached, and it never
  wakes) and reports HOW the host can be woken, or that nothing is configured.
- POST /api/sessions/:id/wake - wakes, waits, reattaches the pane and flushes
  buffered input; 400 with a routable message when no target is configured.
- The amber host-unreachable banner + its 'Wake' / 'Configure WoL' action, and a
  small config dialog that saves via PUT /api/remote-hosts/:id.
- RemoteWakeDeps.resolveRemote: host config is re-resolved for LIVE sessions
  (throttled + cached), so saving the dialog takes effect without a restart.
This commit is contained in:
Randalix
2026-09-15 14:20:31 +02:00
parent 3f0bfde54a
commit 8b5a13435a
17 changed files with 959 additions and 77 deletions
+12 -10
View File
@@ -550,20 +550,20 @@ export function remoteDisplayPath(
* silently do nothing until the session is relaunched (which for an owned remote
* session means killing the remote tmux).
*
* Deliberately narrow: ONLY `wakeCommand` is taken from the host config, and the
* host is authoritative for it (removing it in the config turns the feature off
* again). The other host-level fields (`commands`, ssh options) stay as persisted
* so this cannot silently change how an existing pane connects.
* Deliberately narrow: ONLY `wakeCommand`/`wakeMac` are taken from the host config,
* and the host is authoritative for them (removing one in the config turns that
* wake path off again). The other host-level fields (`commands`, ssh options) stay as
* persisted so this cannot silently change how an existing pane connects.
*/
export function rehydrateRemoteHostFields(
remote: SessionRemote | undefined,
export function rehydrateRemoteHostFields<T extends { hostId: string; wakeCommand?: string; wakeMac?: string }>(
remote: T | undefined,
hostsById: ReadonlyMap<string, RemoteHost>
): SessionRemote | undefined {
): T | undefined {
if (!remote) return remote;
const host = hostsById.get(remote.hostId);
if (!host) return remote;
if (remote.wakeCommand === host.wakeCommand) return remote;
return { ...remote, wakeCommand: host.wakeCommand };
if (remote.wakeCommand === host.wakeCommand && remote.wakeMac === host.wakeMac) return remote;
return { ...remote, wakeCommand: host.wakeCommand, wakeMac: host.wakeMac };
}
export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote {
@@ -575,9 +575,10 @@ 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
// Wake-on-LAN command/MAC travel with the session so the input route can wake a
// sleeping host without a second config read (see remote-wake.ts).
wakeCommand: host.wakeCommand,
wakeMac: host.wakeMac,
// 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`.
@@ -619,6 +620,7 @@ export function toAttachedSessionRemote(
// 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,
wakeMac: host.wakeMac,
// Discovered + attached — another Codeman created it. Detach-not-kill.
owned: false,
remoteSessionName,
+221 -19
View File
@@ -30,6 +30,7 @@
*/
import { spawn } from 'node:child_process';
import dgram from 'node:dgram';
import net from 'node:net';
/** Minimum spacing between two reachability probes for the same session. */
@@ -60,7 +61,6 @@ 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.
@@ -82,14 +82,14 @@ export type RemoteInputOutcome = 'deliver' | 'buffered';
* Pure — no clock, no IO.
*/
export function decideRemoteInputAction(args: {
hasWakeCommand: boolean;
hasWakeTarget: boolean;
waking: boolean;
probeAgeMs: number;
lastReachable?: boolean;
minProbeIntervalMs?: number;
}): RemoteInputAction {
if (args.waking) return 'buffer';
if (!args.hasWakeCommand) return 'deliver';
if (!args.hasWakeTarget) return 'deliver';
if (args.lastReachable === false) return 'buffer';
const interval = args.minProbeIntervalMs ?? REMOTE_WAKE_PROBE_MIN_INTERVAL_MS;
if (args.probeAgeMs >= interval) return 'probe';
@@ -117,12 +117,72 @@ export function appendBoundedPending(
/** The remote fields the wake flow needs. Structurally satisfied by `SessionRemote`. */
export interface WakeableRemote {
wakeCommand?: string;
wakeMac?: string;
hostId: string;
label: string;
host: string;
port?: number;
}
/**
* A resolved wake path for a host. `command` wins over `mac` (an explicit override
* beats the default path), and `null` means the host cannot be woken at all — which
* is what the UI turns into "configure WoL" instead of "wake".
*/
export type WakeTarget = { kind: 'command'; command: string } | { kind: 'mac'; macs: number[][] } | null;
/**
* Resolve the wake target from host config. Pure.
*
* A malformed `wakeMac` resolves to `null` rather than throwing: the schema
* already rejects one at config time, so this can only be reached with a config
* written by hand, and a broken MAC must not break the input route.
*/
export function resolveWakeTarget(remote: WakeableRemote | undefined): WakeTarget {
if (!remote) return null;
if (remote.wakeCommand) return { kind: 'command', command: remote.wakeCommand };
if (remote.wakeMac) {
const macs = parseMacList(remote.wakeMac);
if (macs && macs.length > 0) return { kind: 'mac', macs };
}
return null;
}
/**
* Parse a comma-separated MAC list into byte arrays. Pure; returns null when any
* entry is malformed (all-or-nothing, so a typo cannot half-arm a host).
*/
export function parseMacList(value: string, maxMacs = 4): number[][] | null {
const parts = value
.split(',')
.map((part) => part.trim())
.filter((part) => part.length > 0);
if (parts.length === 0 || parts.length > maxMacs) return null;
const macs: number[][] = [];
for (const part of parts) {
const match =
/^([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})[:-]([0-9a-fA-F]{2})$/.exec(
part
);
if (!match) return null;
macs.push(match.slice(1).map((hex) => Number.parseInt(hex, 16)));
}
return macs;
}
/**
* Build a Wake-on-LAN "magic packet": six `0xFF` bytes then the MAC repeated 16
* times. Pure — the shape is asserted byte-for-byte in the tests because a packet
* that is off by one byte simply never wakes anything.
*/
export function buildMagicPacket(mac: number[]): Buffer {
const packet = Buffer.alloc(6 + 16 * 6, 0xff);
for (let repeat = 0; repeat < 16; repeat++) {
Buffer.from(mac).copy(packet, 6 + repeat * 6);
}
return packet;
}
/**
* The slice of `Session` the wake flow uses — an interface rather than the
* concrete class so the registry is testable without a tmux server.
@@ -140,8 +200,8 @@ export interface WakeableSession {
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>;
/** Run the resolved wake target (magic packet or host command). Resolves false on failure. */
wake(target: NonNullable<WakeTarget>): Promise<boolean>;
/** Poll until the woken host accepts connections again. */
waitUntilReady(remote: WakeableRemote): Promise<boolean>;
/** Sleep helper (injected for tests). */
@@ -155,6 +215,32 @@ export interface RemoteWakeDeps {
): void;
/** Structured diagnostics. */
log?(message: string): void;
/**
* Resolve the host's CURRENT wake config for a session whose persisted `remote`
* snapshot predates it (or was configured after launch). Called at most once per
* `REMOTE_WAKE_RESOLVE_TTL_MS` per session, and only when the session's own copy
* has no wake target — so a config saved in the UI works without restarting the
* session, without a per-keystroke config read.
*/
resolveRemote?(session: WakeableSession): Promise<WakeableRemote | undefined>;
}
/** Probe freshness for the UI's reachability check (a tab switch is not a hammer). */
export const REMOTE_WAKE_REACHABILITY_TTL_MS = 5_000;
/** How long a resolved host config is trusted before asking the resolver again. */
export const REMOTE_WAKE_RESOLVE_TTL_MS = 30_000;
/**
* What the UI is allowed to offer for a host: how it can be woken, if at all. The
* `'none'` case is what the banner turns into "configure WoL" instead of "wake".
*/
export type WakeConfigured = 'command' | 'mac' | 'none';
/** Which wake path a host config provides (mirrors {@link resolveWakeTarget}). Pure. */
export function wakeConfigured(remote: WakeableRemote | undefined): WakeConfigured {
const target = resolveWakeTarget(remote);
if (!target) return 'none';
return target.kind;
}
/** Per-session wake bookkeeping. */
@@ -163,6 +249,9 @@ interface WakeState {
reachable?: boolean;
waking: Promise<boolean> | null;
pending: string[];
/** Host config resolved after launch (see `RemoteWakeDeps.resolveRemote`). */
resolvedRemote?: WakeableRemote;
resolvedAt: number;
}
/**
@@ -194,6 +283,35 @@ export class RemoteWakeRegistry {
return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
}
/** Whether this session's host has any wake path configured at all. */
async hasWakeTarget(session: WakeableSession): Promise<boolean> {
return resolveWakeTarget(await this._effectiveRemote(session)) !== null;
}
/** Which wake path is configured (`'none'` when the UI should offer configuration). */
async wakeConfigured(session: WakeableSession): Promise<WakeConfigured> {
return wakeConfigured(await this._effectiveRemote(session));
}
/**
* Reachability for the UI: probe unless a recent result is still fresh.
*
* Shares the per-session probe state with the input path on purpose — a fresh
* answer is exactly what the input ladder wants, and an `unreachable` verdict here
* makes the next keystroke buffer + wake instead of vanishing into a stalled pane.
*/
async checkReachable(session: WakeableSession, opts: { force?: boolean; ttlMs?: number } = {}): Promise<boolean> {
const remote = await this._effectiveRemote(session);
if (!remote) return true;
const state = this._state(session.id);
const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
if (Date.now() - state.probedAt >= ttl) {
state.probedAt = Date.now();
state.reachable = await this.deps.probe(remote);
}
return state.reachable === true;
}
/**
* Decide + act for one input chunk.
*
@@ -203,10 +321,11 @@ export class RemoteWakeRegistry {
* in order once the pane is reattached.
*/
async handleInput(session: WakeableSession, data: string): Promise<RemoteInputOutcome> {
const remote = session.remote;
const remote = await this._effectiveRemote(session);
const state = this._state(session.id);
const target = resolveWakeTarget(remote);
const action = decideRemoteInputAction({
hasWakeCommand: Boolean(remote?.wakeCommand),
hasWakeTarget: target !== null,
waking: state.waking != null,
probeAgeMs: Date.now() - state.probedAt,
lastReachable: state.reachable,
@@ -218,7 +337,7 @@ export class RemoteWakeRegistry {
// A buffered verdict with no wake in flight still has to DRIVE a wake (the
// previous one failed and reset the probe state, or the ladder landed here
// directly) — otherwise the bytes would sit in the buffer forever.
if (state.waking == null && remote?.wakeCommand) void this.wake(session);
if (state.waking == null && target) void this.wake(session);
return 'buffered';
}
@@ -237,11 +356,13 @@ export class RemoteWakeRegistry {
* 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;
async ensureAwake(session: WakeableSession, opts: { force?: boolean } = {}): Promise<boolean> {
const remote = await this._effectiveRemote(session);
if (!remote || !resolveWakeTarget(remote)) return true;
const state = this._state(session.id);
if (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS) {
// `force` is the manual path (a user pressed "wake"): a cached "reachable" from
// seconds ago must not talk the button out of waking a host that just slept.
if (opts.force || (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS)) {
state.probedAt = Date.now();
state.reachable = await this.deps.probe(remote);
}
@@ -254,8 +375,9 @@ export class RemoteWakeRegistry {
* 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 remote = await this._effectiveRemote(session);
const target = resolveWakeTarget(remote);
if (!remote || !target) return true;
const state = this._state(session.id);
if (state.waking) return state.waking;
@@ -263,10 +385,14 @@ export class RemoteWakeRegistry {
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}`);
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} 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 woke = await this.deps.wake(target);
if (!woke) {
this.deps.log?.(
`[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}`
);
}
const ready = await this.deps.waitUntilReady(remote);
if (!ready) {
@@ -309,12 +435,43 @@ export class RemoteWakeRegistry {
private _state(sessionId: string): WakeState {
let state = this.states.get(sessionId);
if (!state) {
state = { probedAt: 0, reachable: undefined, waking: null, pending: [] };
state = { probedAt: 0, reachable: undefined, waking: null, pending: [], resolvedAt: 0 };
this.states.set(sessionId, state);
}
return state;
}
/**
* The host config to act on: the session's own `remote` when it can wake, else a
* freshly resolved one.
*
* The persisted `remote` snapshot is taken at launch, so a wake target configured
* AFTER the session started (e.g. through the banner's config dialog, or by adding
* `wakeMac` to `remote-hosts.json`) is invisible to it. Recovery rehydration
* (server.ts) covers restarts; this covers the live session, and it is why saving
* the dialog takes effect without restarting anything. The resolver is asked at
* most once per TTL, and never for a session that already has a usable target.
*/
private async _effectiveRemote(session: WakeableSession): Promise<WakeableRemote | undefined> {
const state = this._state(session.id);
if (state.resolvedRemote && resolveWakeTarget(state.resolvedRemote)) return state.resolvedRemote;
if (resolveWakeTarget(session.remote)) return session.remote;
if (!session.remote || !this.deps.resolveRemote) return state.resolvedRemote ?? session.remote;
if (Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) {
return state.resolvedRemote ?? session.remote;
}
state.resolvedAt = Date.now();
try {
const resolved = await this.deps.resolveRemote(session);
if (resolved) state.resolvedRemote = resolved;
} catch (err) {
this.deps.log?.(
`[RemoteWake] host config lookup failed for session ${session.id}: ${err instanceof Error ? err.message : String(err)}`
);
}
return state.resolvedRemote ?? session.remote;
}
private _enqueue(sessionId: string, data: string): void {
const state = this._state(sessionId);
const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
@@ -407,6 +564,51 @@ export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_CO
});
}
/**
* Send Wake-on-LAN magic packets for every MAC, over UDP to the broadcast address.
*
* This is the whole reason `wakeMac` exists: the common case needs no external
* script. Broadcast on 255.255.255.255 is what the CLI `wakeonlan` does and what the
* NICs here answer to; the socket is closed as soon as the packets are queued, so a
* sleeping host cannot leave a handle behind. Resolves false on any failure (no
* interface to broadcast on, permission) rather than throwing — a broken network
* must not break the wake flow, which reports the failure itself.
*/
export function sendWakePackets(addresses: number[][], port = 9): Promise<boolean> {
if (addresses.length === 0) return Promise.resolve(false);
return new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
let settled = false;
const finish = (value: boolean) => {
if (settled) return;
settled = true;
try {
socket.close();
} catch {
/* already closed */
}
resolve(value);
};
socket.once('error', () => finish(false));
try {
socket.setBroadcast(true);
} catch {
finish(false);
return;
}
let pending = addresses.length;
let failed = false;
for (const mac of addresses) {
const packet = buildMagicPacket(mac);
socket.send(packet, port, '255.255.255.255', (err) => {
if (err) failed = true;
pending--;
if (pending === 0) finish(!failed);
});
}
});
}
/** Poll the host until it accepts connections again, or the bound is hit. */
export async function waitUntilRemoteReady(
remote: WakeableRemote,
@@ -431,7 +633,7 @@ const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
export function createDefaultRemoteWakeDeps(overrides: Partial<RemoteWakeDeps> = {}): RemoteWakeDeps {
return {
probe: probeRemoteHostReachable,
wake: runRemoteWakeCommand,
wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)),
waitUntilReady: (remote) => waitUntilRemoteReady(remote),
delay,
...overrides,
+17 -5
View File
@@ -97,13 +97,23 @@ export interface RemoteHost extends RemoteSshOptions {
username: string;
port?: number;
commands?: Partial<Record<RemoteCommandMode, string>>;
/**
* Optional Wake-on-LAN MAC address(es), comma-separated (e.g.
* `04:d9:f5:80:c6:58`). Codeman sends the magic packet itself (UDP port 9
* broadcast), so the common case needs no external script. A SLEEPING host's
* port-22 probe still fails, which is what triggers the wake — this only
* controls HOW the host is woken.
*/
wakeMac?: 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.
* wrapper script like `/home/joe/bin/whuff`). TAKES PRECEDENCE over `wakeMac`
* (an explicit override for hosts that need a router/other-host wake). 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 or an
* explicit wake request 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;
}
@@ -151,6 +161,8 @@ export interface SessionRemote extends RemoteSshOptions {
* so the input route can wake a sleeping host without re-reading the host list.
*/
wakeCommand?: string;
/** Wake-on-LAN MAC address(es) from the host config (see `RemoteHost.wakeMac`). */
wakeMac?: string;
}
/**
+3 -1
View File
@@ -221,7 +221,6 @@ const _SSE_HANDLER_MAP = [
[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'],
[SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'],
@@ -6076,6 +6075,9 @@ class CodemanApp {
// bar (issue #262). Also disarms a one-shot Ctrl left over from the tab we
// just left, so it can never fire against the session we just opened.
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession();
// Remote-host reachability banner: only meaningful for a remote session, so this
// also clears it when the newly active tab is local.
this.refreshHostWakeBanner?.(sessionId);
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
// the async buffer load. Without this, the offset is 0 during the
+307
View File
@@ -0,0 +1,307 @@
/**
* @fileoverview Remote-host wake-on-LAN: the "host unreachable" banner + its config dialog.
*
* A sleeping remote host does not fail loudly. The local tmux pane runs `ssh`, and when
* the machine suspends, that ssh child stalls: `tmux send-keys` still SUCCEEDS, so typed
* input disappears with no error and the pane looks alive. The server side
* (`src/remote-wake.ts`) buffers input and wakes the host when the user types; this
* module makes the state VISIBLE and gives it a button, which is what turns "why is
* nothing happening" into one click.
*
* Behavior:
* - Polls `GET /api/sessions/:id/reachability` for the ACTIVE remote session only
* (on tab activation and every `POLL_MS` while the tab is visible). The endpoint
* shares the server's probe cache with the input path, so opening the tab also
* primes the wake path.
* - Unreachable + a configured wake target → "Wake" button → `POST /api/sessions/:id/wake`
* (which wakes, waits, reattaches the pane and flushes buffered input).
* - Unreachable + NO wake target → "Configure WoL" → `#wakeConfigModal`, a small form
* for this host's MAC/command that saves via `PUT /api/remote-hosts/:id`. The server
* re-resolves host config while the session is live, so saving takes effect without
* restarting the session.
* - SSE (`remote:hostWaking`, `remote:hostWakeFailed`, `remote:sessionReconnected`)
* keeps the banner in sync while a wake is running.
*
* @mixin Extends CodemanApp.prototype via Object.assign
*/
const HOST_WAKE_POLL_MS = 30_000;
Object.assign(CodemanApp.prototype, {
/** Per-tab banner state (single active session at a time). */
_hostWake: null,
/** Fresh state for a session we just switched to. */
_hostWakeState() {
return {
sessionId: null,
timer: null,
/** Last reachability answer, or null before the first poll. */
reachable: null,
/** 'command' | 'mac' | 'none' — what the banner action should do. */
wakeConfigured: 'none',
host: '',
label: '',
/** True between clicking Wake and the answer coming back. */
waking: false,
/** Set when the last wake attempt or poll failed. */
error: '',
};
},
/**
* Entry point from the session switcher — called for every active session, remote or
* not, so it must be cheap and must clear the banner for local sessions.
*/
refreshHostWakeBanner(sessionId) {
const state = (this._hostWake = this._hostWakeState());
if (state.timer) clearInterval(state.timer);
state.sessionId = sessionId || null;
const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null;
if (!session || !session.remote) {
this._renderHostWakeBanner();
return;
}
state.host = session.remote.host || '';
state.label = session.remote.label || 'Remote host';
// Text from the session payload first (instant, no round trip), corrected by the
// poll — a session whose wake config was added after launch only knows it after
// the server resolves host config.
state.wakeConfigured = session.remote.wakeMac || session.remote.wakeCommand ? 'mac' : 'none';
this._renderHostWakeBanner();
this._pollHostReachability();
if (state.timer) clearInterval(state.timer);
state.timer = setInterval(() => {
if (document.visibilityState === 'hidden') return;
if (this.activeSessionId !== state.sessionId) return;
this._pollHostReachability();
}, HOST_WAKE_POLL_MS);
},
/** One reachability check for the active remote session. */
async _pollHostReachability(force = false) {
const state = this._hostWake;
if (!state || !state.sessionId) return;
const sessionId = state.sessionId;
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/reachability${force ? '?force=1' : ''}`);
const data = await res.json();
if (!data.success) return;
// The tab may have changed while this was in flight.
if (this._hostWake !== state || state.sessionId !== sessionId) return;
state.reachable = data.data.reachable !== false;
state.wakeConfigured = data.data.wakeConfigured || 'none';
if (data.data.host) state.host = data.data.host;
if (data.data.label) state.label = data.data.label;
if (state.reachable) {
state.waking = false;
state.error = '';
}
this._renderHostWakeBanner();
} catch {
/* A failed poll is not a state change: leave the banner as it was. */
}
},
/** Draw the banner from `_hostWake`. */
_renderHostWakeBanner() {
const state = this._hostWake;
const banner = this.$('hostWakeBanner');
const text = this.$('hostWakeBannerText');
const detail = this.$('hostWakeBannerDetail');
const action = this.$('hostWakeBannerAction');
if (!banner || !text || !action) return;
const visible = Boolean(state && state.sessionId && state.reachable === false);
banner.hidden = !visible;
if (!visible) return;
const hasTarget = state.wakeConfigured !== 'none';
const target = state.label || state.host || 'Remote host';
if (state.waking) {
text.textContent = `Waking ${target} …`;
} else if (state.error) {
text.textContent = `${target} did not wake up`;
} else {
text.textContent = `${target} is not reachable`;
}
if (detail) {
detail.textContent = state.waking
? 'input is queued until it is back'
: hasTarget
? `ssh ${state.host}`
: 'no wake-on-LAN configured';
}
action.textContent = state.waking ? 'Waking …' : hasTarget ? 'Wake' : 'Configure WoL';
action.disabled = state.waking;
},
/** Banner button: wake the host, or open the setup dialog when nothing is configured. */
hostWakeAction() {
const state = this._hostWake;
if (!state || !state.sessionId || state.waking) return;
if (state.wakeConfigured === 'none') {
this.openWakeConfigDialog();
return;
}
this.wakeRemoteHost();
},
/** POST the manual wake for the active session and follow the result. */
async wakeRemoteHost() {
const state = this._hostWake;
if (!state || !state.sessionId) return;
const sessionId = state.sessionId;
state.waking = true;
state.error = '';
this._renderHostWakeBanner();
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/wake`, { method: 'POST' });
const data = await res.json();
if (this._hostWake !== state || state.sessionId !== sessionId) return;
state.waking = false;
if (!data.success) {
// Most likely: no wake target configured after all (the route is the authority).
state.error = data.error || 'Wake failed';
if (String(data.error || '').includes('No wake-on-LAN target')) state.wakeConfigured = 'none';
this._renderHostWakeBanner();
return;
}
state.reachable = data.data.reachable !== false;
state.wakeConfigured = data.data.wakeConfigured || state.wakeConfigured;
if (state.reachable) {
this.showToast(`${state.label || 'Remote host'} is awake`, 'success');
} else {
state.error = 'timeout';
}
this._renderHostWakeBanner();
} catch (err) {
if (this._hostWake !== state) return;
state.waking = false;
state.error = err && err.message ? err.message : 'Wake failed';
this._renderHostWakeBanner();
}
},
/** Open the small WoL dialog for the banner's host, pre-filled from the host config. */
async openWakeConfigDialog() {
const state = this._hostWake;
const session = state && state.sessionId && this.sessions ? this.sessions.get(state.sessionId) : null;
if (!session || !session.remote) return;
const hostId = session.remote.hostId;
const label = this.$('wakeConfigHostLabel');
const mac = this.$('wakeConfigMac');
const command = this.$('wakeConfigCommand');
const status = this.$('wakeConfigStatus');
if (!mac || !command) return;
mac.value = session.remote.wakeMac || '';
command.value = session.remote.wakeCommand || '';
if (label) label.textContent = session.remote.label || hostId;
if (status) status.textContent = '';
this._wakeConfigHostId = hostId;
const modal = this.$('wakeConfigModal');
if (modal) modal.classList.add('active');
// Read the saved host so the dialog shows what is actually persisted (the session
// payload may predate a change made in another tab).
try {
const res = await fetch('/api/remote-hosts');
const data = await res.json();
const hosts = data.success ? data.data : [];
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
if (host && this._wakeConfigHostId === hostId) {
mac.value = host.wakeMac || '';
command.value = host.wakeCommand || '';
}
} catch {
/* The form is already usable from the session payload. */
}
},
closeWakeConfigDialog() {
const modal = this.$('wakeConfigModal');
if (modal) modal.classList.remove('active');
this._wakeConfigHostId = null;
},
/** Save MAC/command for the host, then re-check whether the session can wake now. */
async saveWakeConfig() {
const hostId = this._wakeConfigHostId;
const mac = this.$('wakeConfigMac');
const command = this.$('wakeConfigCommand');
const status = this.$('wakeConfigStatus');
const save = this.$('wakeConfigSave');
if (!hostId || !mac || !command) return;
const macValue = mac.value.trim();
const commandValue = command.value.trim();
if (
macValue &&
!/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/.test(macValue)
) {
if (status) status.textContent = 'MAC must look like 04:d9:f5:80:c6:58 (comma-separated for several).';
return;
}
if (commandValue && /\s/.test(commandValue)) {
if (status) status.textContent = 'The wake command must be a single executable path (no arguments).';
return;
}
if (save) save.disabled = true;
if (status) status.textContent = 'Saving …';
try {
const listRes = await fetch('/api/remote-hosts');
const listData = await listRes.json();
const hosts = listData.success ? listData.data : [];
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
if (!host) throw new Error('Remote host not found');
// PUT takes the whole host (schema-validated), so send back everything we know and
// only replace the wake fields. `undefined` drops the key entirely.
const payload = {
...host,
wakeMac: macValue || undefined,
wakeCommand: commandValue || undefined,
};
const res = await fetch(`/api/remote-hosts/${encodeURIComponent(hostId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Save failed');
this.showToast('Wake settings saved', 'success');
this.closeWakeConfigDialog();
// The server re-resolves host config for live sessions, so the banner can offer
// the wake right away — probe fresh instead of waiting out the poll interval.
await this._pollHostReachability(true);
} catch (err) {
if (status) status.textContent = err && err.message ? err.message : 'Save failed';
} finally {
if (save) save.disabled = false;
}
},
/** SSE `remote:hostWaking` — a wake is running (ours or one started by typing). */
_onRemoteHostWaking(data) {
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = true;
state.error = '';
if (data.label) state.label = data.label;
this._renderHostWakeBanner();
},
/** SSE `remote:hostWakeFailed` — the host did not come back in time. */
_onRemoteHostWakeFailed(data) {
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = false;
state.error = 'timeout';
state.reachable = false;
this._renderHostWakeBanner();
},
});
+53
View File
@@ -213,6 +213,18 @@
<button class="offline-banner-retry" id="offlineBannerRetry" onclick="app.retryConnection()">Retry now</button>
</div>
<!-- Remote-host unreachable: the machine SLEEPS, the local ssh pane stalls
silently (send-keys succeeds against it, so typed input would vanish) and
Codeman can wake it. Amber, not red: the session is fine, the host is
asleep. Without a configured wake target the action becomes "Configure
WoL" and opens the small config dialog. -->
<div class="offline-banner host-wake-banner" id="hostWakeBanner" role="status" hidden>
<span class="offline-banner-dot" aria-hidden="true"></span>
<span class="offline-banner-text" id="hostWakeBannerText">Remote host is unreachable</span>
<span class="offline-banner-detail" id="hostWakeBannerDetail"></span>
<button class="offline-banner-retry" id="hostWakeBannerAction" onclick="app.hostWakeAction()">Wake</button>
</div>
<!-- Timer Banner (shown when timed run is active) -->
<div class="timer-banner" id="timerBanner" style="display: none;">
<div class="timer-content">
@@ -2853,6 +2865,11 @@
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
</div>
<div class="form-row">
<label>Wake-on-LAN MAC</label>
<input type="text" id="remoteHostWakeMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. Comma-separated for several NICs. Codeman sends the magic packet itself so a sleeping host can be woken from the session banner.</span>
</div>
<div class="form-row">
<label>Codex Command Override</label>
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
@@ -2861,6 +2878,11 @@
<details class="advanced-options">
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Advanced SSH</span></summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Wake Command</label>
<input type="text" id="remoteHostWakeCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Optional override for the MAC above (takes precedence). A single executable path, run without a shell — use it when the host needs a router/other machine to send the packet.</span>
</div>
<div class="form-row">
<label>Identity File</label>
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
@@ -3456,6 +3478,36 @@
text is set via value/textContent only: predictor output derives from
observable (injectable) content, and the explicit click here is the
security boundary (nothing is ever auto-sent). -->
<!-- Wake-on-LAN setup for a remote host whose session cannot be woken yet. Kept
deliberately small (host is fixed, only the wake fields are editable) so it can
be opened from the banner with one click. Persists via PUT /api/remote-hosts/:id. -->
<div class="modal" id="wakeConfigModal">
<div class="modal-backdrop" onclick="app.closeWakeConfigDialog()"></div>
<div class="modal-content">
<div class="modal-header">
<h3>Wake-on-LAN &middot; <span id="wakeConfigHostLabel"></span></h3>
<button class="modal-close" onclick="app.closeWakeConfigDialog()" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<label>MAC address(es)</label>
<input type="text" id="wakeConfigMac" placeholder="04:d9:f5:80:c6:58" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Comma-separated for several NICs. Codeman sends the magic packet itself (UDP port 9, broadcast).</span>
</div>
<div class="form-row">
<label>Wake command (optional)</label>
<input type="text" id="wakeConfigCommand" placeholder="/home/user/bin/wake-this-host" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Takes precedence over the MAC. A single executable path, run without a shell.</span>
</div>
<div class="form-hint" id="wakeConfigStatus"></div>
</div>
<div class="modal-footer">
<button class="btn-toolbar" onclick="app.closeWakeConfigDialog()">Cancel</button>
<button class="btn-toolbar btn-primary" id="wakeConfigSave" onclick="app.saveWakeConfig()">Save</button>
</div>
</div>
</div>
<div class="modal" id="readMyMindModal">
<div class="modal-backdrop" onclick="app.closeReadMyMind()"></div>
<div class="modal-content readmymind-modal">
@@ -3530,6 +3582,7 @@
<script defer src="approvals-ui.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="host-wake-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
+3
View File
@@ -92,6 +92,9 @@ Object.assign(CodemanApp.prototype, {
_onRemoteSessionReconnected(data) {
const id = this.getShortId(data.sessionId);
this.showToast(`Remote session ${id} reconnected`, 'success');
// A successful reattach (the wake flow's own, or the watcher's) means the host is
// back: drop the "unreachable" banner without waiting out the poll interval.
if (this.activeSessionId === data.sessionId) this._pollHostReachability?.(true);
},
_onRemoteReconnectExhausted(data) {
+10
View File
@@ -3071,6 +3071,9 @@ Object.assign(CodemanApp.prototype, {
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
// Wake-on-LAN: keep in sync with `_readRemoteHostFromForm` (the Discover path).
const wakeMac = document.getElementById('remoteHostWakeMac').value.trim();
const wakeCommand = document.getElementById('remoteHostWakeCommand').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
@@ -3108,6 +3111,8 @@ Object.assign(CodemanApp.prototype, {
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(wakeMac ? { wakeMac } : {}),
...(wakeCommand ? { wakeCommand } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
const hostRes = await fetch('/api/remote-hosts', {
@@ -3568,6 +3573,9 @@ Object.assign(CodemanApp.prototype, {
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
// Wake-on-LAN: keep in sync with `linkRemoteCase`'s inline payload.
const wakeMac = document.getElementById('remoteHostWakeMac').value.trim();
const wakeCommand = document.getElementById('remoteHostWakeCommand').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
@@ -3587,6 +3595,8 @@ Object.assign(CodemanApp.prototype, {
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(wakeMac ? { wakeMac } : {}),
...(wakeCommand ? { wakeCommand } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
},
+12
View File
@@ -15306,6 +15306,18 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
background: rgba(255, 255, 255, 0.24);
}
/* Remote-host unreachable (host asleep, can be woken). Reuses the offline-banner
layout and children; amber instead of red because the Codeman session itself is
perfectly healthy — only the machine is asleep. */
.host-wake-banner {
background: linear-gradient(90deg, #b45309, #92400e);
}
.host-wake-banner .offline-banner-retry:disabled {
opacity: 0.6;
cursor: default;
}
/* Above the mobile fixed header (1200) and modals (1300): this is a blocking
"nothing works right now" state, and it only appears before any session
state has loaded, so there is no modal underneath to bury. Stays below the
+62 -1
View File
@@ -134,6 +134,7 @@ import {
checkRemoteTmuxAvailable,
readRemoteCases,
readRemoteHosts,
rehydrateRemoteHostFields,
toAttachedSessionRemote,
toSessionRemote,
} from '../../remote-hosts.js';
@@ -841,6 +842,16 @@ export function registerSessionRoutes(
},
broadcast: (event, payload) => ctx.broadcast(event, payload),
log: (message) => console.log(message),
// The session's `remote` block is a launch-time snapshot, so a wake target
// configured later (banner's config dialog, or a hand-edited remote-hosts.json)
// is resolved here — throttled by the registry, and only for sessions that
// have no usable target of their own.
resolveRemote: async (session) => {
const remote = session.remote;
if (!remote) return undefined;
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
return rehydrateRemoteHostFields(remote, new Map(hosts.map((host) => [host.id, host])));
},
})
);
// ═══════════════════════════════════════════════════════════════
@@ -1541,6 +1552,56 @@ export function registerSessionRoutes(
// Terminal I/O (input, resize, buffer)
// ═══════════════════════════════════════════════════════════════
// ========== Wake-on-LAN: state + manual trigger ==========
//
// Both routes are session-scoped (not host-scoped) because the wake flow needs the
// SESSION: a woken host whose pane is not reattached is still a dead terminal, and an
// exhausted COD-108 backoff never retries on its own. The probe in `/reachability` is
// the same cheap TCP connect the input path uses and it NEVER wakes a host — the UI
// decides that, with the button.
app.get('/api/sessions/:id/reachability', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
const remote = session.remote;
if (!remote) return { success: true, data: { reachable: true, wakeConfigured: 'none' as const } };
const force = (req.query as { force?: string })?.force === '1';
const reachable = await remoteWake.checkReachable(session, { force });
return {
success: true,
data: {
reachable,
wakeConfigured: await remoteWake.wakeConfigured(session),
host: remote.host,
label: remote.label,
},
};
});
app.post('/api/sessions/:id/wake', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
if (!session.remote) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Not a remote session');
}
// The UI uses this to route to the host config dialog instead of a dead button.
if (!(await remoteWake.hasWakeTarget(session))) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'No wake-on-LAN target configured for this host (set a MAC address or a wake command)'
);
}
const woke = await remoteWake.ensureAwake(session, { force: true });
return {
success: true,
data: {
woke,
reachable: await remoteWake.checkReachable(session),
wakeConfigured: await remoteWake.wakeConfigured(session),
},
};
});
// ========== Send Input ==========
app.post('/api/sessions/:id/input', async (req, reply) => {
@@ -1591,7 +1652,7 @@ export function registerSessionRoutes(
// 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 (!duplicate && (await remoteWake.hasWakeTarget(session))) {
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).
+12
View File
@@ -749,6 +749,18 @@ export const RemoteHostSchema = z.object({
.regex(/^\S+$/, 'Wake command must be a single executable path (no arguments)')
.regex(NO_SHELL_META, 'Invalid characters in wake command')
.optional(),
// Wake-on-LAN MAC address(es), comma-separated. Structural: only hex pairs with
// `:`/`-` separators, so nothing here can be a shell token even by accident (the
// value never reaches a shell — Codeman builds the magic packet itself).
wakeMac: z
.string()
.min(11)
.max(128)
.regex(
/^[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5}(\s*,\s*[0-9a-fA-F]{2}([:-][0-9a-fA-F]{2}){5})*$/,
'Wake MAC must be one or more MAC addresses, comma-separated'
)
.optional(),
});
export const RemoteCaseLinkSchema = z.object({