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,