mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
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:
+12
-10
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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 · <span id="wakeConfigHostLabel"></span></h3>
|
||||
<button class="modal-close" onclick="app.closeWakeConfigDialog()" aria-label="Close">×</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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user