Merge pull request #439

feat(remote): wake a sleeping host (Wake-on-LAN) from input, banner and native magic packet
This commit is contained in:
Ark0N
2026-09-19 12:18:18 +02:00
committed by GitHub
26 changed files with 4021 additions and 14 deletions
+8 -1
View File
@@ -219,7 +219,8 @@ const _SSE_HANDLER_MAP = [
// Remote auto-reconnect (COD-108)
[SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'],
[SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'],
[SSE_EVENTS.REMOTE_HOST_WAKING, '_onRemoteHostWaking'],
[SSE_EVENTS.REMOTE_HOST_WAKE_FAILED, '_onRemoteHostWakeFailed'],
// Ralph
[SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'],
[SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'],
@@ -1824,6 +1825,9 @@ class CodemanApp {
_onInit(data) {
_crashDiag.log(`INIT: ${data.sessions?.length || 0} sessions`);
this.handleInit(data);
// Start the remote-host reachability poller even if no session switch follows
// (a page loaded with the remote tab already active) — see host-wake-ui.js.
this._ensureHostWakePoller?.();
}
_onSessionCreated(data) {
@@ -6190,6 +6194,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
+3
View File
@@ -1148,6 +1148,9 @@ const SSE_EVENTS = {
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
// Wake-on-LAN from user input on a sleeping remote host
REMOTE_HOST_WAKING: 'remote:hostWaking',
REMOTE_HOST_WAKE_FAILED: 'remote:hostWakeFailed',
// Ralph
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
+440
View File
@@ -0,0 +1,440 @@
/**
* @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:
* - Asks `GET /api/sessions/:id/reachability` for the ACTIVE remote session only:
* once when the tab is activated (a user action), and every `POLL_MS` while the tab
* is visible ONLY for a host with a wake target. The timer is the one thing here that
* is not user-driven, and each poll is a TCP connect to the host — the same
* timer-driven traffic invariant #2 rejects keepalives for: it cannot wake a host,
* but it can keep an activity-based suspend timer from firing. So a host Codeman
* could not wake anyway is never polled on a timer. A host behind a jump host or
* SOCKS proxy (`probeable: false`) is never polled at all: the probe cannot reach
* it, so its answer would only ever be a false "asleep". 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
* @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast)
* @dependency constants.js (SSE_EVENTS — the remote:hostWaking / remote:hostWakeFailed names)
* @loadorder 12.2 — loaded after session-ui.js, before webview-tabs.js
*/
const HOST_WAKE_POLL_MS = 30_000;
Object.assign(CodemanApp.prototype, {
/** Per-tab banner state (single active session at a time). */
_hostWake: null,
/** The page-wide poller interval (created once, see `_ensureHostWakePoller`). */
_hostWakeTimer: null,
/** Fresh state for a session we just switched to. */
_hostWakeState() {
return {
sessionId: 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: '',
/**
* False for a host the server's probe cannot reach (behind a jump host or SOCKS
* proxy): its reachability is unknown, so there is no banner and no polling.
*/
probeable: true,
/** True between clicking Wake and the answer coming back. */
waking: false,
/**
* True only when the server is actually holding bytes for this session (the typing
* path buffers them). Browser keystrokes go over the WebSocket, which never passes
* through the wake registry — so the Wake BUTTON must not claim input is queued.
*/
queuedInput: 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.
*
* ⚠️ The POLLER is page-wide and independent of this call on purpose: a session
* switch is not the only way the active tab changes (boot restore, a page loaded with
* the tab already active, and `selectSession`'s own early return for the tab you are
* already on), and the banner must not depend on any single one of those paths
* running — that is exactly how it could silently never appear.
*/
refreshHostWakeBanner(sessionId) {
this._ensureHostWakePoller();
const state = this._hostWake;
if (state && state.sessionId && state.sessionId !== sessionId) this._hostWake = null;
this._hostWakeTick();
},
/** Create the page-wide poller once (interval + a visibility wake-up). */
_ensureHostWakePoller() {
if (this._hostWakeTimer) return;
this._hostWakeTimer = setInterval(() => this._hostWakeTick({ periodic: true }), HOST_WAKE_POLL_MS);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this._hostWakeTick({ periodic: true });
});
},
/**
* One poller tick: resolve the ACTIVE session, reset the banner when it changed, and
* ask the server. No-op while the page is hidden (a background tab must not poll).
*
* `periodic` marks the timer (and the visibility wake-up) as opposed to a tab
* activation: a periodic tick polls only a host with a wake target, see the module
* comment. The activation poll is what still offers "Configure WoL" for a sleeping
* host that has none — one connect, on a user action.
*/
_hostWakeTick({ periodic = false } = {}) {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
const sessionId = this.activeSessionId;
const session = sessionId && this.sessions ? this.sessions.get(sessionId) : null;
if (!sessionId || !session || !session.remote) {
// Render unconditionally: `refreshHostWakeBanner` clears `_hostWake` BEFORE
// calling this tick, so a guard here would skip the repaint and leave the
// banner up on every chat (the clear and the repaint must not be coupled to
// whoever cleared the state). Idempotent — with a null state it just hides.
this._hostWake = null;
this._renderHostWakeBanner();
return;
}
let state = this._hostWake;
let fresh = false;
if (!state || state.sessionId !== sessionId) {
fresh = true;
state = this._hostWake = this._hostWakeState();
state.sessionId = sessionId;
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. The kind matters: the payload can say WHICH
// path is configured, so a command-only host is not mislabelled 'mac' until the
// first poll lands.
state.wakeConfigured = session.remote.wakeMac ? 'mac' : session.remote.wakeCommand ? 'command' : 'none';
// Known from the payload already: a proxied host is not probeable (the server
// says so too, on every answer), so not even the activation poll is worth a
// round trip whose verdict could only be a wrong "asleep".
state.probeable = !(session.remote.jumpHost || session.remote.socksProxy);
this._renderHostWakeBanner();
}
if (!state.probeable) return;
if (periodic && !fresh && state.wakeConfigured === 'none') return;
this._pollHostReachability();
},
/** 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;
// `reachable` is `null` (unknown, not unreachable) for a host the probe cannot
// reach — only a PROVEN `false` may raise the banner.
state.reachable = data.data.reachable !== false;
if (data.data.probeable === false) state.probeable = 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
? state.queuedInput
? 'input is queued until it is back'
: 'waiting for the host to come back'
: hasTarget
? `ssh ${state.host}`
: 'no wake-on-LAN configured';
}
// After a FAILED wake the only useful next step is fixing the target (wrong MAC,
// host moved NIC, command gone) — otherwise a configured-but-broken host would be
// stuck behind a button that keeps failing with no way to edit it.
const offerConfig = !hasTarget || Boolean(state.error);
action.textContent = state.waking ? 'Waking …' : offerConfig ? 'Configure WoL' : 'Wake';
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' || state.error) {
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;
// The button path holds nothing: whatever the user typed went into the stalled pane
// over the WebSocket and is gone. Saying otherwise is a promise the next keystroke
// disproves.
state.queuedInput = false;
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) {
// The ROUTE is the authority on whether a target is configured, so ask it again
// (`/reachability` reports `wakeConfigured`) rather than pattern-matching the
// error message: the message is prose, and the code is generic (`INVALID_INPUT`
// covers "Not a remote session" too).
state.error = data.error || 'Wake failed';
this._renderHostWakeBanner();
await this._pollHostReachability(true);
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();
}
},
/**
* Why the host could not be read. In multi-user mode `GET /api/remote-hosts` returns
* `[]` to a non-admin, so "Remote host not found" would blame a config the user simply
* is not allowed to see — the save is admin-only, and that is what it should say.
*/
_wakeConfigUnavailableMessage() {
const me = window.__codemanUser || {};
return me.multiUser && me.role !== 'admin' ? 'Wake-on-LAN configuration is admin-only' : 'Remote host not found';
},
/** 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 || '';
} else if (!host && this._wakeConfigHostId === hostId && status) {
// Say it up front rather than only when Save fails.
status.textContent = this._wakeConfigUnavailableMessage();
}
} 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(this._wakeConfigUnavailableMessage());
// 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).
*
* ⚠️ The ONLY definition of this handler: `panels-ui.js` must not define it too.
* Both mix into `Codeman.prototype` and this file loads later, so a second copy
* would be silently shadowed (the guard in `sse-dispatch-table.test.ts` sees that a
* handler exists, not that two modules claim the same name). The toast is
* deliberately UNCONDITIONAL — a wake can start for a background session (input on
* a non-active tab) where there is no banner to update.
*/
_onRemoteHostWaking(data) {
const label = data && data.label ? data.label : 'Remote host';
// A create-path wake (the user pressed Run / Attach) has no session yet, so
// nothing is queued behind it — the wording has to say what actually happens.
const forNewSession = Boolean(data && data.forNewSession);
// Only the typing path buffers bytes; the wake button and the send-and-wait path
// hold none, and a browser keystroke never reaches the registry at all.
const queuedInput = Boolean(data && data.queuedInput);
// Long enough to cover the wake + attach (~10s measured on a warm S3), and it
// is replaced by `remote:sessionReconnected` the moment the pane is back.
this.showToast(
forNewSession
? `Waking ${label} … the session starts when it is back`
: queuedInput
? `Waking ${label} … input is queued`
: `Waking ${label} … waiting for it to come back`,
'info',
{ duration: 12000 }
);
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = true;
state.queuedInput = queuedInput;
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 label = data && data.label ? data.label : 'Remote host';
const forNewSession = Boolean(data && data.forNewSession);
const queuedInput = Boolean(data && data.queuedInput);
this.showToast(
forNewSession
? `${label} did not wake up — no session was started`
: queuedInput
? `${label} did not wake up — queued input is still held`
: `${label} did not wake up`,
'error',
{ duration: 15000 }
);
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = false;
state.queuedInput = queuedInput;
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>
<!-- Reboot-restore offer: shown when the server found sessions a host reboot
killed and is asking whether to rebuild them. Populated by
reboot-restore-ui.js; nothing is created until the user clicks. -->
@@ -2878,6 +2890,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">
@@ -2886,6 +2903,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">
@@ -3481,6 +3503,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">
@@ -3556,6 +3608,7 @@
<script defer src="reboot-restore-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>
+13
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) {
@@ -116,6 +119,16 @@ Object.assign(CodemanApp.prototype, {
},
// Wake-on-LAN from user input on a sleeping remote host (see remote-wake.ts).
// ⚠️ The `remote:hostWaking` / `remote:hostWakeFailed` HANDLERS live in
// `host-wake-ui.js`, which owns the banner state. They are NOT redefined here:
// both files mix into `CodemanApp.prototype` and `host-wake-ui.js` is loaded
// later, so a second definition would silently shadow the banner update (and the
// toast would never fire — the exact silent no-op `sse-dispatch-table.test.ts`
// exists to prevent, which cannot see shadowing). The toasts are shown from the
// host-wake-ui handlers instead.
// Bash tools
_onBashToolStart(data) {
this.handleBashToolStart(data.sessionId, data.tool);
+14
View File
@@ -2512,6 +2512,10 @@ Object.assign(CodemanApp.prototype, {
'remoteHostSocksProxy',
'remoteHostJumpHost',
'remoteHostExtraSshOptions',
// Wake-on-LAN: they belong to the HOST being configured, so leaving them filled in
// would carry one host's MAC/command onto the next host this form saves.
'remoteHostWakeMac',
'remoteHostWakeCommand',
];
remoteFields.forEach(id => {
const el = document.getElementById(id);
@@ -3109,6 +3113,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())
@@ -3146,6 +3153,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', {
@@ -3606,6 +3615,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())
@@ -3625,6 +3637,8 @@ Object.assign(CodemanApp.prototype, {
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(wakeMac ? { wakeMac } : {}),
...(wakeCommand ? { wakeCommand } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
},
+12
View File
@@ -15389,6 +15389,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